@magnusekdahl/parallix 1.0.4 → 1.1.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/README.md +13 -17
- package/config/agents.json +3 -3
- package/config/workflow.config.schema.json +1 -1
- package/docs/agents.md +8 -8
- package/docs/authority-reference.md +2 -2
- package/docs/doc-standards.md +89 -0
- package/docs/forgejo-setup.md +28 -4
- package/docs/migration/task-classification.md +1 -1
- package/docs/operator-setup.md +1 -1
- package/docs/readme-rewrite-benchmark.md +1 -1
- package/docs/use-cases.md +2 -2
- package/examples/README.md +1 -1
- package/index.js +7 -0
- package/lib/agents/agents.js +24 -8
- package/lib/agents/limit-hit.js +1 -1
- package/lib/agents/opencode-telemetry.js +28 -18
- package/lib/agents/opencode.js +18 -4
- package/lib/commands/active.js +3 -2
- package/lib/commands/draft.js +34 -23
- package/lib/commands/integrate.js +47 -13
- package/lib/commands/mission-start.js +2 -2
- package/lib/commands/rebase.js +34 -7
- package/lib/commands/stats-backfill.js +1 -1
- package/lib/commands/stats.js +11 -9
- package/lib/commands/status.js +33 -1
- package/lib/core/fmt.js +7 -3
- package/lib/core/git.js +46 -0
- package/lib/core/persistent-data-migration.js +9 -1
- package/lib/review/review-loop.js +21 -9
- package/lib/review/review-prompts.js +1 -1
- package/lib/review/review.js +1 -0
- package/lib/tools/forgejo.js +53 -5
- package/package.json +1 -1
- package/prompts/draft.md +13 -1
- package/prompts/review-verbose.md +17 -1
- package/prompts/review.md +36 -3
- package/tools/setup-forgejo-docker.sh +5 -2
package/lib/agents/opencode.js
CHANGED
|
@@ -39,7 +39,7 @@ function __setJsonFormatDetectForTest(fn) {
|
|
|
39
39
|
// 1. Legacy footer: "Continue opencode -s ses_<id>" (older opencode TUI).
|
|
40
40
|
// 2. JSON stream: `"sessionID":"ses_<id>"` (opencode v2.0.0
|
|
41
41
|
// `run --format json`). v2.0.0's plain `run` no longer prints the footer,
|
|
42
|
-
// so without the JSON field the id is lost and
|
|
42
|
+
// so without the JSON field the id is lost and custom telemetry drops to all
|
|
43
43
|
// zeros (task-1339). We request `--format json` in buildOpencodeInvocation
|
|
44
44
|
// so this field is always present.
|
|
45
45
|
const OPENCODE_SESSION_ID_RE = /opencode\s+-s\s+(ses_\S+)/i;
|
|
@@ -132,7 +132,7 @@ function buildOpencodeInvocation({ prompt, worktree, env, resume = false, sessio
|
|
|
132
132
|
};
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
-
// Transient
|
|
135
|
+
// Transient custom/vLLM backend failures that opencode surfaces as a non-zero
|
|
136
136
|
// exit. These are recoverable: the same prompt frequently succeeds on a second
|
|
137
137
|
// attempt, so the launcher retries once in-family before agents.js reroutes to a
|
|
138
138
|
// different agent family. Limit-hit phrases are intentionally NOT listed here —
|
|
@@ -181,6 +181,19 @@ function isHardOpencodeFailure(result) {
|
|
|
181
181
|
return HARD_OPENCODE_PATTERNS.some((re) => re.test(text));
|
|
182
182
|
}
|
|
183
183
|
|
|
184
|
+
// Opencode v2.0.0 JSON-mode sometimes exits with code 1 during post-run
|
|
185
|
+
// cleanup (race closing the stdout pipe, EPIPE, or draining pending events).
|
|
186
|
+
// When the transcript contains a valid "reason":"stop" completion event and
|
|
187
|
+
// no "type":"error" event, the exit is spurious – the agent actually
|
|
188
|
+
// completed successfully. See opencode issues #31446 and #33653.
|
|
189
|
+
function isSpuriousOpencodeExit(result) {
|
|
190
|
+
if (!result || result.status !== 1) return false;
|
|
191
|
+
const text = failureText(result);
|
|
192
|
+
const hasStop = /"reason"\s*:\s*"stop"/.test(text);
|
|
193
|
+
const hasError = /"type"\s*:\s*"error"/.test(text);
|
|
194
|
+
return hasStop && !hasError;
|
|
195
|
+
}
|
|
196
|
+
|
|
184
197
|
function isTransientOpencodeFailure(result) {
|
|
185
198
|
if (!result) return false;
|
|
186
199
|
const text = failureText(result);
|
|
@@ -197,7 +210,7 @@ function shouldRetryOpencodeFailure(result) {
|
|
|
197
210
|
if (result.signal) return false;
|
|
198
211
|
if (!(typeof result.status === 'number' && result.status !== 0)) return false;
|
|
199
212
|
if (detectLimitHit({
|
|
200
|
-
agent: '
|
|
213
|
+
agent: 'custom',
|
|
201
214
|
stdout: result.stdout,
|
|
202
215
|
stderr: result.stderr,
|
|
203
216
|
status: result.status,
|
|
@@ -261,7 +274,7 @@ function startOpencodeAgent({
|
|
|
261
274
|
try {
|
|
262
275
|
const exportJson = await _captureExport(result.sessionId, { worktree, env });
|
|
263
276
|
if (exportJson) {
|
|
264
|
-
const telemetry = extractOpencodeTelemetryFromExport(exportJson);
|
|
277
|
+
const telemetry = extractOpencodeTelemetryFromExport(exportJson, model);
|
|
265
278
|
if (telemetry) {
|
|
266
279
|
result.telemetry = telemetry;
|
|
267
280
|
if (telemetry.model) result.model = telemetry.model;
|
|
@@ -313,6 +326,7 @@ module.exports = {
|
|
|
313
326
|
extractOpencodeSessionId,
|
|
314
327
|
resolveOpencodeCommand,
|
|
315
328
|
startOpencodeAgent,
|
|
329
|
+
isSpuriousOpencodeExit,
|
|
316
330
|
isTransientOpencodeFailure,
|
|
317
331
|
isHardOpencodeFailure,
|
|
318
332
|
shouldRetryOpencodeFailure,
|
package/lib/commands/active.js
CHANGED
|
@@ -7,7 +7,7 @@ const agents = require('../agents/agents');
|
|
|
7
7
|
const { findMissionDir, findCheckpoints, getFirstLine, resolveWorktree, inferSlug, getMissionYear, missionDirForSlug, isWorkflowGeneratedArtifact } = require('../core/mission-utils');
|
|
8
8
|
const handoff = require('./handoff');
|
|
9
9
|
const { resolveTaskFile, transitionTask, getTaskStatus, getTaskImplementer } = require('../tools/backlog');
|
|
10
|
-
const { loadAdapterConfig } = require('../core/product-config');
|
|
10
|
+
const { loadAdapterConfig, resolveAgentModel } = require('../core/product-config');
|
|
11
11
|
const review = require('../review/review');
|
|
12
12
|
const repairHandoff = require('./repair-handoff');
|
|
13
13
|
const stats = require('./stats');
|
|
@@ -125,6 +125,7 @@ async function active(args, {
|
|
|
125
125
|
slug: normalizedSlug,
|
|
126
126
|
rootDir: worktree,
|
|
127
127
|
implementer: agent,
|
|
128
|
+
model: resolveAgentModel(agent, worktree),
|
|
128
129
|
// Use the windowed Codex rollout (bounded by sinceMs) when present, else
|
|
129
130
|
// the launcher-attached telemetry — same resolution as the review hook,
|
|
130
131
|
// so the execute phase records this stage's delta, not cumulative usage.
|
|
@@ -307,7 +308,7 @@ async function attemptAgentRelaunch(slug, worktree, errorMsg, agent, {
|
|
|
307
308
|
const prompt = buildRelaunchPromptFn(errorMsg, slug, worktree);
|
|
308
309
|
|
|
309
310
|
log(`Attempting to relaunch ${fmt.agent(agent)} to fix repairable handoff error...`);
|
|
310
|
-
// startAgent handles resume flags internally for resume-capable agents (codex, claude, gemini,
|
|
311
|
+
// startAgent handles resume flags internally for resume-capable agents (codex, claude, gemini, custom)
|
|
311
312
|
|
|
312
313
|
try {
|
|
313
314
|
const result = await startAgentFn('active', {
|
package/lib/commands/draft.js
CHANGED
|
@@ -9,7 +9,7 @@ const { findMissionArea, findMissionDir, inferSlug, getMissionYear, resolveMainR
|
|
|
9
9
|
const { transitionVirtual } = require('../core/state-map');
|
|
10
10
|
const stats = require('./stats');
|
|
11
11
|
const { formatVerificationCommand } = require('../core/verification');
|
|
12
|
-
const { ensureStandaloneMissionBaseline } = require('../core/product-config');
|
|
12
|
+
const { ensureStandaloneMissionBaseline, resolveAgentModel } = require('../core/product-config');
|
|
13
13
|
const { ensureWorkflowGitignore } = require('../core/gitignore');
|
|
14
14
|
const { unquoteGitStatusPath } = require('./active');
|
|
15
15
|
|
|
@@ -114,6 +114,7 @@ async function runDraftCommand(args, {
|
|
|
114
114
|
transitionTaskFn = transitionTask,
|
|
115
115
|
transitionVirtualFn = transitionVirtual,
|
|
116
116
|
recordDraftImplementerFn = recordDraftImplementer,
|
|
117
|
+
recordDraftStatsFn = recordDraftStats,
|
|
117
118
|
enforceDraftCommitSafetyFn = enforceDraftCommitSafety,
|
|
118
119
|
validateDraftClassificationFn = validateDraftClassification,
|
|
119
120
|
normalizeDraftClassificationFn = normalizeDraftClassification,
|
|
@@ -169,8 +170,32 @@ async function runDraftCommand(args, {
|
|
|
169
170
|
return;
|
|
170
171
|
}
|
|
171
172
|
|
|
173
|
+
// Detect the branch HEAD is on at draft time (where the human stands). This is
|
|
174
|
+
// the single source of truth for the mission's base. A non-primary feature
|
|
175
|
+
// branch is recorded as the mission base; the primary branch (or a detached
|
|
176
|
+
// HEAD) leaves no record and keeps the byte-identical legacy behaviour.
|
|
177
|
+
let launchBase = null;
|
|
178
|
+
try {
|
|
179
|
+
launchBase = detectLaunchBaseBranchFn(process.cwd());
|
|
180
|
+
} catch (error) {
|
|
181
|
+
errorFn(fmt.status('FAIL', error.message));
|
|
182
|
+
exitFn(1);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
let recordedBase = null;
|
|
186
|
+
if (launchBase && launchBase !== getPrimaryBranch(mainRepo)) {
|
|
187
|
+
recordedBase = launchBase;
|
|
188
|
+
logFn(fmt.status('INFO', `Feature-branch mission: base branch detected as ${fmt.branch(recordedBase)}.`));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// A feature-branch mission may reference a task that was only committed on that
|
|
192
|
+
// feature branch, so it is absent from the primary checkout (mainRepo). In that
|
|
193
|
+
// case the task lives in the current worktree (where HEAD is on the feature
|
|
194
|
+
// branch), which the mission branch is cut from. Resolve preflight checks there.
|
|
195
|
+
const taskLookupRoot = recordedBase ? process.cwd() : mainRepo;
|
|
196
|
+
|
|
172
197
|
// Preflight: Ensure Backlog task exists and is unambiguous before side effects
|
|
173
|
-
const mainResolution = resolveTaskFileFn(normalizedSlug,
|
|
198
|
+
const mainResolution = resolveTaskFileFn(normalizedSlug, taskLookupRoot);
|
|
174
199
|
if (!mainResolution.ok && !syntheticTask) {
|
|
175
200
|
reportTaskResolutionFn(mainResolution, normalizedSlug, errorFn);
|
|
176
201
|
exitFn(1);
|
|
@@ -178,7 +203,7 @@ async function runDraftCommand(args, {
|
|
|
178
203
|
}
|
|
179
204
|
|
|
180
205
|
// Preflight: Backlog integrity check (filename vs frontmatter ID)
|
|
181
|
-
const relevantIssues = syntheticTask ? [] : checkBacklogIntegrityFn(
|
|
206
|
+
const relevantIssues = syntheticTask ? [] : checkBacklogIntegrityFn(taskLookupRoot, normalizedSlug);
|
|
182
207
|
if (relevantIssues.length > 0) {
|
|
183
208
|
errorFn(fmt.status('FAIL', `Backlog integrity issues detected for ${normalizedSlug}:`));
|
|
184
209
|
relevantIssues.forEach(issue => {
|
|
@@ -193,24 +218,6 @@ async function runDraftCommand(args, {
|
|
|
193
218
|
return;
|
|
194
219
|
}
|
|
195
220
|
|
|
196
|
-
// Detect the branch HEAD is on at draft time (where the human stands). This is
|
|
197
|
-
// the single source of truth for the mission's base. A non-primary feature
|
|
198
|
-
// branch is recorded as the mission base; the primary branch (or a detached
|
|
199
|
-
// HEAD) leaves no record and keeps the byte-identical legacy behaviour.
|
|
200
|
-
let launchBase = null;
|
|
201
|
-
try {
|
|
202
|
-
launchBase = detectLaunchBaseBranchFn(process.cwd());
|
|
203
|
-
} catch (error) {
|
|
204
|
-
errorFn(fmt.status('FAIL', error.message));
|
|
205
|
-
exitFn(1);
|
|
206
|
-
return;
|
|
207
|
-
}
|
|
208
|
-
let recordedBase = null;
|
|
209
|
-
if (launchBase && launchBase !== getPrimaryBranch(mainRepo)) {
|
|
210
|
-
recordedBase = launchBase;
|
|
211
|
-
logFn(fmt.status('INFO', `Feature-branch mission: base branch detected as ${fmt.branch(recordedBase)}.`));
|
|
212
|
-
}
|
|
213
|
-
|
|
214
221
|
const branchName = missionBranchName(normalizedSlug, mainRepo);
|
|
215
222
|
logFn(fmt.bold(`Step 1: Setting up branch ${fmt.branch(branchName)}...`));
|
|
216
223
|
ensureMissionBranchFn(mainRepo, branchName, { logFn, baseBranch: recordedBase });
|
|
@@ -299,9 +306,12 @@ async function runDraftCommand(args, {
|
|
|
299
306
|
worktree: targetWorktree
|
|
300
307
|
});
|
|
301
308
|
|
|
302
|
-
|
|
309
|
+
// Record stats from the mission worktree, where the backlog task always lives
|
|
310
|
+
// (a feature-branch task is absent from mainRepo/the primary checkout). This
|
|
311
|
+
// matches how the review and active stages record stats (rootDir: worktree).
|
|
312
|
+
recordDraftStatsFn({
|
|
303
313
|
slug: normalizedSlug,
|
|
304
|
-
rootDir:
|
|
314
|
+
rootDir: targetWorktree,
|
|
305
315
|
agentFamily: actualAgent,
|
|
306
316
|
result,
|
|
307
317
|
log: logFn,
|
|
@@ -954,6 +964,7 @@ function recordDraftStats({ slug, rootDir, agentFamily, result, log = fmt.log.pl
|
|
|
954
964
|
stage: 'draft',
|
|
955
965
|
rootDir,
|
|
956
966
|
implementer: agentFamily,
|
|
967
|
+
model: resolveAgentModel(agentFamily, rootDir),
|
|
957
968
|
telemetry: result.telemetry || null,
|
|
958
969
|
durationMinutes,
|
|
959
970
|
});
|
|
@@ -2,7 +2,7 @@ const fs = require('fs');
|
|
|
2
2
|
const os = require('os');
|
|
3
3
|
const path = require('path');
|
|
4
4
|
const child_process = require('child_process');
|
|
5
|
-
const { git, getCurrentBranch } = require('../core/git');
|
|
5
|
+
const { detectRebaseState, git, getCurrentBranch } = require('../core/git');
|
|
6
6
|
const { resolveTaskFile, getTaskStatus, setTaskStatus, completeTask, getTaskAssignee } = require('../tools/backlog');
|
|
7
7
|
const { toVirtual, toActual } = require('../core/state-map');
|
|
8
8
|
const { getPrStatus, getLatestReviewDecision, syncMerged, readToken, resolveTokenFile, resolveForgejoUser, resolveForgejoHome, isForgejoPath, listOpenPrsForSlug } = require('../tools/forgejo');
|
|
@@ -587,7 +587,8 @@ async function integrate(args) {
|
|
|
587
587
|
const syncResult = syncMerged(branch, mergedCommit, {
|
|
588
588
|
rootDir: baseWorktree,
|
|
589
589
|
forgejoUser: context.forgejoUser,
|
|
590
|
-
token: context.forgejoToken
|
|
590
|
+
token: context.forgejoToken,
|
|
591
|
+
baseBranch: context.baseBranch
|
|
591
592
|
});
|
|
592
593
|
if (!syncResult.ok) {
|
|
593
594
|
reportSyncMergedFailure(syncResult);
|
|
@@ -617,7 +618,7 @@ async function integrate(args) {
|
|
|
617
618
|
for (const line of formatMatrixSummary(buildAutonomousReviewMatrix())) {
|
|
618
619
|
fmt.log.info(line);
|
|
619
620
|
}
|
|
620
|
-
for (const line of buildConflictResolutionPrompt(slug, context.area)) {
|
|
621
|
+
for (const line of buildConflictResolutionPrompt(slug, context.area, { baseBranch: context.baseBranch })) {
|
|
621
622
|
fmt.log.info(line);
|
|
622
623
|
}
|
|
623
624
|
throw new IntegrationAbort();
|
|
@@ -703,7 +704,8 @@ async function integrate(args) {
|
|
|
703
704
|
const syncResult = syncMerged(branch, mergedCommit, {
|
|
704
705
|
rootDir: baseWorktree,
|
|
705
706
|
forgejoUser: context.forgejoUser,
|
|
706
|
-
token: context.forgejoToken
|
|
707
|
+
token: context.forgejoToken,
|
|
708
|
+
baseBranch: context.baseBranch
|
|
707
709
|
});
|
|
708
710
|
if (!syncResult.ok) {
|
|
709
711
|
reportSyncMergedFailure(syncResult);
|
|
@@ -935,6 +937,7 @@ function printIntegrationPreflight(
|
|
|
935
937
|
{
|
|
936
938
|
readTokenFn = readToken,
|
|
937
939
|
resolveTokenFileFn = resolveTokenFile,
|
|
940
|
+
detectRebaseStateFn = detectRebaseState,
|
|
938
941
|
getUnresolvedIndexConflictsFn = getUnresolvedIndexConflicts,
|
|
939
942
|
findMissionDocInBranchesFn = findMissionDocInBranches,
|
|
940
943
|
isForgejoReviewEnabledFn = isForgejoReviewEnabled,
|
|
@@ -1088,6 +1091,23 @@ function printIntegrationPreflight(
|
|
|
1088
1091
|
log(fmt.status('INFO', `Retry with: git -C ${baseWorktree} checkout ${expectedPrimaryBranch}`));
|
|
1089
1092
|
}
|
|
1090
1093
|
|
|
1094
|
+
const rebaseState = detectRebaseStateFn(baseWorktree);
|
|
1095
|
+
if (rebaseState.inProgress) {
|
|
1096
|
+
failures.push('rebase-in-progress');
|
|
1097
|
+
log(fmt.status('FAIL', `Integration checkout rebase: rebase in progress in ${baseWorktree}`));
|
|
1098
|
+
if (rebaseState.rebaseHead) {
|
|
1099
|
+
log(fmt.status('INFO', `Current rebase head: ${rebaseState.rebaseHead}`));
|
|
1100
|
+
}
|
|
1101
|
+
if (rebaseState.unmergedFiles.length > 0) {
|
|
1102
|
+
rebaseState.unmergedFiles.forEach(file => log(fmt.status('INFO', ` - ${file}`)));
|
|
1103
|
+
}
|
|
1104
|
+
log(fmt.status('INFO', 'Finish or abort the existing rebase before retrying:'));
|
|
1105
|
+
log(fmt.status('INFO', ` git -C ${baseWorktree} rebase --continue`));
|
|
1106
|
+
log(fmt.status('INFO', ` git -C ${baseWorktree} rebase --abort`));
|
|
1107
|
+
log(fmt.status('INFO', ` git -C ${baseWorktree} rebase --skip`));
|
|
1108
|
+
log(fmt.status('INFO', `Retry with: px integrate ${context.slug} --dry-run`));
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1091
1111
|
const indexConflicts = getUnresolvedIndexConflictsFn(baseWorktree);
|
|
1092
1112
|
if (!indexConflicts.ok) {
|
|
1093
1113
|
failures.push('main-index-conflict-check');
|
|
@@ -1396,7 +1416,7 @@ function findExistingSquashCommit(rootDir, slug) {
|
|
|
1396
1416
|
* @param {{ getConflictFilesFn?: Function }} [options] - Overrides for testing
|
|
1397
1417
|
* @returns {{ ok: boolean, conflictFiles: string[], sharedFiles: string[], missionSpecificFiles: string[] }}
|
|
1398
1418
|
*/
|
|
1399
|
-
function resolveConflictsForMission(slug, area, { getConflictFilesFn, resolveWorktreeFn = resolveWorktree, worktreePathOverride = null, rootDir = getPrimaryWorktree() } = {}) {
|
|
1419
|
+
function resolveConflictsForMission(slug, area, { getConflictFilesFn, resolveWorktreeFn = resolveWorktree, worktreePathOverride = null, rootDir = getPrimaryWorktree(), baseBranch = null } = {}) {
|
|
1400
1420
|
// Resolve the actual attached worktree dynamically; fall back to the conventional
|
|
1401
1421
|
// path only when git worktree list has no entry for this mission branch.
|
|
1402
1422
|
const conventionalPath = `${conventionalWorktreePath(slug, rootDir)}`;
|
|
@@ -1406,7 +1426,15 @@ function resolveConflictsForMission(slug, area, { getConflictFilesFn, resolveWor
|
|
|
1406
1426
|
fmt.log.warn(`No registered worktree found for ${missionBranchName(slug, rootDir)}; falling back to conventional path.`);
|
|
1407
1427
|
}
|
|
1408
1428
|
|
|
1409
|
-
|
|
1429
|
+
// Conflicts must be detected against the branch the mission integrates back
|
|
1430
|
+
// into. For a feature-branch mission that is the recorded base branch, not the
|
|
1431
|
+
// primary branch; for legacy missions resolveMissionBaseBranch returns primary.
|
|
1432
|
+
let targetBranch = baseBranch;
|
|
1433
|
+
if (!targetBranch) {
|
|
1434
|
+
try { targetBranch = resolveMissionBaseBranch(slug, rootDir); } catch (_) { targetBranch = getPrimaryBranch(); }
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
const detectFn = getConflictFilesFn || ((_rootDir, _branch) => getConflictFiles(worktreePath, targetBranch));
|
|
1410
1438
|
|
|
1411
1439
|
if (!fs.existsSync(worktreePath)) {
|
|
1412
1440
|
fmt.log.fail(`Mission worktree not found: ${worktreePath}`);
|
|
@@ -1414,10 +1442,10 @@ function resolveConflictsForMission(slug, area, { getConflictFilesFn, resolveWor
|
|
|
1414
1442
|
return { ok: false, error: 'worktree-missing', conflictFiles: [], sharedFiles: [], missionSpecificFiles: [], worktreePath };
|
|
1415
1443
|
}
|
|
1416
1444
|
|
|
1417
|
-
fmt.log.info(`Detecting conflicts in ${worktreePath} against ${
|
|
1445
|
+
fmt.log.info(`Detecting conflicts in ${worktreePath} against ${targetBranch}...`);
|
|
1418
1446
|
let conflictFiles;
|
|
1419
1447
|
try {
|
|
1420
|
-
conflictFiles = detectFn(worktreePath,
|
|
1448
|
+
conflictFiles = detectFn(worktreePath, targetBranch);
|
|
1421
1449
|
} catch (err) {
|
|
1422
1450
|
fmt.log.fail('Merge check failed with a non-conflict error:');
|
|
1423
1451
|
fmt.log.fail(err.message);
|
|
@@ -1447,7 +1475,7 @@ function resolveConflictsForMission(slug, area, { getConflictFilesFn, resolveWor
|
|
|
1447
1475
|
sharedFiles.forEach(f => fmt.log.plain(` - ${f}`));
|
|
1448
1476
|
fmt.log.info('Manual resolution path:');
|
|
1449
1477
|
fmt.log.info(` cd ${quotedWorktreePath}`);
|
|
1450
|
-
fmt.log.info(` git rebase ${
|
|
1478
|
+
fmt.log.info(` git rebase ${targetBranch}`);
|
|
1451
1479
|
fmt.log.info(' # resolve each shared file conflict manually');
|
|
1452
1480
|
fmt.log.info(' git add <resolved-files>');
|
|
1453
1481
|
fmt.log.info(' git rebase --continue');
|
|
@@ -1459,7 +1487,7 @@ function resolveConflictsForMission(slug, area, { getConflictFilesFn, resolveWor
|
|
|
1459
1487
|
fmt.log.info(`All ${missionSpecificFiles.length} conflict(s) are in mission-specific files.`);
|
|
1460
1488
|
fmt.log.info('Skip-all-conflicts path (use --theirs to keep the mission version):');
|
|
1461
1489
|
fmt.log.info(` cd ${quotedWorktreePath}`);
|
|
1462
|
-
fmt.log.info(` git rebase ${
|
|
1490
|
+
fmt.log.info(` git rebase ${targetBranch}`);
|
|
1463
1491
|
fmt.log.info(' # after the rebase pauses on conflicts:');
|
|
1464
1492
|
missionSpecificFiles.forEach(f => fmt.log.info(` git checkout --theirs "${f}" && git add "${f}"`));
|
|
1465
1493
|
fmt.log.info(' git rebase --continue');
|
|
@@ -1473,6 +1501,12 @@ function buildConflictResolutionPrompt(slug = '<slug>', area = '<area>', options
|
|
|
1473
1501
|
const rootDir = options.rootDir || getPrimaryWorktree();
|
|
1474
1502
|
const worktreePath = options.worktreePath || resolveWorktree(slug) || conventionalWorktreePath(slug, rootDir);
|
|
1475
1503
|
const quotedWorktreePath = shellQuote(worktreePath);
|
|
1504
|
+
// Rebase guidance must target the branch the mission integrates back into: the
|
|
1505
|
+
// recorded base for feature-branch missions, the primary branch otherwise.
|
|
1506
|
+
let targetBranch = options.baseBranch;
|
|
1507
|
+
if (!targetBranch) {
|
|
1508
|
+
try { targetBranch = resolveMissionBaseBranch(slug, rootDir); } catch (_) { targetBranch = getPrimaryBranch(); }
|
|
1509
|
+
}
|
|
1476
1510
|
return [
|
|
1477
1511
|
'Conflict resolution options:',
|
|
1478
1512
|
'',
|
|
@@ -1485,9 +1519,9 @@ function buildConflictResolutionPrompt(slug = '<slug>', area = '<area>', options
|
|
|
1485
1519
|
` 1. Stay in the mission worktree (${worktreePath}); do not resolve non-trivial conflicts inside ${rootDir}.`,
|
|
1486
1520
|
` cd ${quotedWorktreePath}`,
|
|
1487
1521
|
' git status --short',
|
|
1488
|
-
` 2. Rebase the mission branch onto the local ${
|
|
1489
|
-
` git fetch review ${
|
|
1490
|
-
` git rebase ${
|
|
1522
|
+
` 2. Rebase the mission branch onto the local ${targetBranch} branch:`,
|
|
1523
|
+
` git fetch review ${targetBranch}`,
|
|
1524
|
+
` git rebase ${targetBranch}`,
|
|
1491
1525
|
` 3. Re-run the mission gate: ${formatVerificationCommand(area, worktreePath)}`,
|
|
1492
1526
|
` 4. Retry the dry-run integration preflight: px integrate ${slug} --dry-run`
|
|
1493
1527
|
];
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
const fs = require('fs');
|
|
2
2
|
const path = require('path');
|
|
3
3
|
const fmt = require('../core/fmt');
|
|
4
|
-
const { getCurrentBranch, getLastCommit,
|
|
4
|
+
const { getCurrentBranch, getLastCommit, git } = require('../core/git');
|
|
5
5
|
const { resolveTaskFile, getTaskStatus, reportTaskResolution } = require('../tools/backlog');
|
|
6
6
|
const { adapterChecklist, evaluateRepositoryReadiness } = require('../core/product-config');
|
|
7
7
|
const { evaluateReviewSetup } = require('../tools/setup-review');
|
|
@@ -35,7 +35,7 @@ function missionStart(args, options = {}) {
|
|
|
35
35
|
const fsExistsSync = options.fsExistsSync || fs.existsSync;
|
|
36
36
|
const resolveMissionBaseBranchFn = options.resolveMissionBaseBranchFn || resolveMissionBaseBranch;
|
|
37
37
|
const getPrimaryBranchFn = options.getPrimaryBranchFn || getPrimaryBranch;
|
|
38
|
-
const runFn = options.
|
|
38
|
+
const runFn = options.gitFn || git;
|
|
39
39
|
|
|
40
40
|
const explicitSlug = args[0];
|
|
41
41
|
const slug = inferSlugFn(explicitSlug);
|
package/lib/commands/rebase.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
const path = require('path');
|
|
2
|
-
const { git, getCurrentBranch } = require('../core/git');
|
|
2
|
+
const { detectRebaseState, git, getCurrentBranch } = require('../core/git');
|
|
3
3
|
const { resolveConflictsForMission } = require('./integrate');
|
|
4
|
-
const { findMissionDir, findMissionArea, inferSlug, resolveWorktree, conventionalWorktreePath, getPrimaryWorktree, getPrimaryBranch, missionBranchName, missionDirForSlug, getMissionYear } = require('../core/mission-utils');
|
|
4
|
+
const { findMissionDir, findMissionArea, inferSlug, resolveWorktree, conventionalWorktreePath, getPrimaryWorktree, getPrimaryBranch, resolveMissionBaseBranch, missionBranchName, missionDirForSlug, getMissionYear } = require('../core/mission-utils');
|
|
5
5
|
const { startAgent } = require('../agents/agents');
|
|
6
6
|
const { createPr, readToken, resolveForgejoUser } = require('../tools/forgejo');
|
|
7
7
|
const { resolveTaskFile, getTaskImplementer } = require('../tools/backlog');
|
|
@@ -28,6 +28,8 @@ async function rebase(args, {
|
|
|
28
28
|
resolveForgejoUserFn = resolveForgejoUser,
|
|
29
29
|
resolveTaskFileFn = resolveTaskFile,
|
|
30
30
|
getTaskImplementerFn = getTaskImplementer,
|
|
31
|
+
detectRebaseStateFn = detectRebaseState,
|
|
32
|
+
resolveMissionBaseBranchFn = resolveMissionBaseBranch,
|
|
31
33
|
gitFn = git,
|
|
32
34
|
exitFn = (code) => process.exit(code),
|
|
33
35
|
isForgejoReviewEnabledFn = isForgejoReviewEnabled,
|
|
@@ -47,6 +49,24 @@ async function rebase(args, {
|
|
|
47
49
|
const area = missionDir ? findMissionAreaFn(missionDir) : 'docs';
|
|
48
50
|
const branch = missionBranchName(slug);
|
|
49
51
|
|
|
52
|
+
const existingRebase = detectRebaseStateFn(process.cwd());
|
|
53
|
+
if (existingRebase.inProgress) {
|
|
54
|
+
fmt.log.fail(`Rebase already in progress for ${fmt.branch(branch)}.`);
|
|
55
|
+
if (existingRebase.rebaseHead) {
|
|
56
|
+
fmt.log.info(`Current rebase head: ${existingRebase.rebaseHead}`);
|
|
57
|
+
}
|
|
58
|
+
if (existingRebase.unmergedFiles.length > 0) {
|
|
59
|
+
fmt.log.info('Unmerged files:');
|
|
60
|
+
existingRebase.unmergedFiles.forEach(file => fmt.log.info(` - ${file}`));
|
|
61
|
+
}
|
|
62
|
+
fmt.log.info('Recovery commands:');
|
|
63
|
+
fmt.log.info(` ${fmt.command('git rebase --continue')}`);
|
|
64
|
+
fmt.log.info(` ${fmt.command('git rebase --abort')}`);
|
|
65
|
+
fmt.log.info(` ${fmt.command('git rebase --skip')}`);
|
|
66
|
+
exitFn(1);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
50
70
|
const performPush = async () => {
|
|
51
71
|
if (!isPush) return;
|
|
52
72
|
if (!isForgejoReviewEnabledFn(process.cwd())) {
|
|
@@ -89,9 +109,12 @@ async function rebase(args, {
|
|
|
89
109
|
return;
|
|
90
110
|
}
|
|
91
111
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
112
|
+
// Honor the mission's recorded base branch (a feature-branch mission rebases
|
|
113
|
+
// onto its base, e.g. skunkworks — not the primary branch). Falls back to the
|
|
114
|
+
// primary branch for every mission without a recorded Base-Branch.
|
|
115
|
+
const baseBranch = resolveMissionBaseBranchFn(slug, process.cwd(), { gitFn });
|
|
116
|
+
fmt.log.info(`Rebasing ${fmt.branch(branch)} onto local ${fmt.branch(baseBranch)}...`);
|
|
117
|
+
const rebaseResult = gitFn(['-c', 'core.editor=true', '-c', 'merge.autoedit=no', 'rebase', baseBranch]);
|
|
95
118
|
|
|
96
119
|
// Rebase succeeded (status 0) or was already up to date
|
|
97
120
|
// Also handle "Already up to date" variants
|
|
@@ -552,9 +575,13 @@ function buildRebasePrompt({ slug, area, worktreePath, missionSpecificFiles, sha
|
|
|
552
575
|
|
|
553
576
|
let primaryBranch = 'main';
|
|
554
577
|
try {
|
|
555
|
-
primaryBranch =
|
|
578
|
+
primaryBranch = resolveMissionBaseBranch(slug, worktreePath, { gitFn });
|
|
556
579
|
} catch (_) {
|
|
557
|
-
|
|
580
|
+
try {
|
|
581
|
+
primaryBranch = getPrimaryBranch(worktreePath, gitFn);
|
|
582
|
+
} catch (_e) {
|
|
583
|
+
primaryBranch = 'main';
|
|
584
|
+
}
|
|
558
585
|
}
|
|
559
586
|
|
|
560
587
|
return [
|
|
@@ -46,7 +46,7 @@ function normalizeHistoricalImplementer(value) {
|
|
|
46
46
|
if (/(^|[^a-z])codex([^a-z]|$)/.test(normalized)) return 'codex';
|
|
47
47
|
if (/(^|[^a-z])claude([^a-z]|$)/.test(normalized)) return 'claude';
|
|
48
48
|
if (/(^|[^a-z])gemini([^a-z]|$)/.test(normalized)) return 'gemini';
|
|
49
|
-
if (/(^|[^a-z])
|
|
49
|
+
if (/(^|[^a-z])custom([^a-z]|$)/.test(normalized)) return 'custom';
|
|
50
50
|
if (/(^|[^a-z])mistral([^a-z]|$)/.test(normalized)) return 'mistral';
|
|
51
51
|
if (/(^|[^a-z])magnus([^a-z]|$)/.test(normalized)) return 'magnus';
|
|
52
52
|
if (/(^|[^a-z])human([^a-z]|$)/.test(normalized)) return 'human';
|
package/lib/commands/stats.js
CHANGED
|
@@ -1348,12 +1348,12 @@ function recordIntegrationStats({
|
|
|
1348
1348
|
* before/after/delta attribution is deferred to the follow-up mission that adds
|
|
1349
1349
|
* the regression model.
|
|
1350
1350
|
*/
|
|
1351
|
-
function telemetryToStatsFields(telemetry, { agentFamily, durationMinutes = 0 } = {}) {
|
|
1351
|
+
function telemetryToStatsFields(telemetry, { agentFamily, durationMinutes = 0, model } = {}) {
|
|
1352
1352
|
const t = telemetry || null;
|
|
1353
1353
|
const usageAfter = t && typeof t.usagePercent === 'number' ? Math.round(t.usagePercent) : 0;
|
|
1354
1354
|
return {
|
|
1355
|
-
provider: (t && t.provider) || agentFamily || '',
|
|
1356
|
-
model: (t && t.model) || agentFamily || '',
|
|
1355
|
+
provider: (t && t.provider) || model || agentFamily || '',
|
|
1356
|
+
model: (t && t.model) || model || agentFamily || '',
|
|
1357
1357
|
input_tokens: String((t && t.inputTokens) || 0),
|
|
1358
1358
|
output_tokens: String((t && t.outputTokens) || 0),
|
|
1359
1359
|
cached_tokens: String((t && t.cachedTokens) || 0),
|
|
@@ -1412,6 +1412,7 @@ function recordStageStats({
|
|
|
1412
1412
|
prFixRounds = '0',
|
|
1413
1413
|
telemetry = null,
|
|
1414
1414
|
durationMinutes = 0,
|
|
1415
|
+
model = null,
|
|
1415
1416
|
} = {}) {
|
|
1416
1417
|
if (!slug) throw new Error('recordStageStats requires a mission slug.');
|
|
1417
1418
|
if (!stage) throw new Error('recordStageStats requires a stage.');
|
|
@@ -1431,7 +1432,7 @@ function recordStageStats({
|
|
|
1431
1432
|
implementer_agent: implementer || '',
|
|
1432
1433
|
reviewer_agent: reviewer || '',
|
|
1433
1434
|
stage,
|
|
1434
|
-
...telemetryToStatsFields(telemetry, { agentFamily, durationMinutes }),
|
|
1435
|
+
...telemetryToStatsFields(telemetry, { agentFamily, durationMinutes, model }),
|
|
1435
1436
|
}, { filePath, rootDir });
|
|
1436
1437
|
}
|
|
1437
1438
|
|
|
@@ -1446,6 +1447,7 @@ function accumulateStageStats({
|
|
|
1446
1447
|
prFixRounds = '0',
|
|
1447
1448
|
telemetry = null,
|
|
1448
1449
|
durationMinutes = 0,
|
|
1450
|
+
model = null,
|
|
1449
1451
|
} = {}) {
|
|
1450
1452
|
if (!slug) throw new Error('accumulateStageStats requires a mission slug.');
|
|
1451
1453
|
if (!stage) throw new Error('accumulateStageStats requires a stage.');
|
|
@@ -1464,7 +1466,7 @@ function accumulateStageStats({
|
|
|
1464
1466
|
implementer_agent: implementer || '',
|
|
1465
1467
|
reviewer_agent: reviewer || '',
|
|
1466
1468
|
stage,
|
|
1467
|
-
...telemetryToStatsFields(telemetry, { agentFamily, durationMinutes }),
|
|
1469
|
+
...telemetryToStatsFields(telemetry, { agentFamily, durationMinutes, model }),
|
|
1468
1470
|
}, { rootDir });
|
|
1469
1471
|
|
|
1470
1472
|
const data = loadStatsCsv(filePath, { rootDir });
|
|
@@ -1513,22 +1515,22 @@ function defaultPrFixRounds(slug, rootDir, provided) {
|
|
|
1513
1515
|
return derived ? String(derived.prFixRounds) : '0';
|
|
1514
1516
|
}
|
|
1515
1517
|
|
|
1516
|
-
function recordActiveStats({ stage = 'active', slug, rootDir = process.cwd(), prFixRounds, ...rest } = {}) {
|
|
1518
|
+
function recordActiveStats({ stage = 'active', slug, rootDir = process.cwd(), prFixRounds, model, ...rest } = {}) {
|
|
1517
1519
|
return recordStageStats({
|
|
1518
|
-
stage, slug, rootDir,
|
|
1520
|
+
stage, slug, rootDir, model,
|
|
1519
1521
|
prFixRounds: defaultPrFixRounds(slug, rootDir, prFixRounds),
|
|
1520
1522
|
...rest,
|
|
1521
1523
|
});
|
|
1522
1524
|
}
|
|
1523
1525
|
|
|
1524
|
-
function recordReviewStats({ stage = 'review', slug, rootDir = process.cwd(), reviewer, implementer, prFixRounds, ...rest } = {}) {
|
|
1526
|
+
function recordReviewStats({ stage = 'review', slug, rootDir = process.cwd(), reviewer, implementer, prFixRounds, model, ...rest } = {}) {
|
|
1525
1527
|
// A review row's TOKENS belong to the reviewer (telemetry is the reviewer's
|
|
1526
1528
|
// session), but the row stays keyed to the MISSION'S implementer so the weekly
|
|
1527
1529
|
// per-implementer summary counts the mission under whoever implemented it — not
|
|
1528
1530
|
// under the reviewer. The phase report surfaces the reviewer for review phases
|
|
1529
1531
|
// via `reviewer_agent` (see renderMissionPhaseReport), so no information is lost.
|
|
1530
1532
|
return recordStageStats({
|
|
1531
|
-
stage, slug, rootDir, reviewer,
|
|
1533
|
+
stage, slug, rootDir, reviewer, model,
|
|
1532
1534
|
implementer: implementer || reviewer,
|
|
1533
1535
|
prFixRounds: defaultPrFixRounds(slug, rootDir, prFixRounds),
|
|
1534
1536
|
...rest,
|
package/lib/commands/status.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
const { getCurrentBranch, getUncommittedCount, getLastThreeCommits, run } = require('../core/git');
|
|
1
|
+
const { detectRebaseState, getCurrentBranch, getUncommittedCount, getLastThreeCommits, run } = require('../core/git');
|
|
2
2
|
const { findTaskFile, getTaskStatus } = require('../tools/backlog');
|
|
3
3
|
const { findMissionDir, findCheckpoints, getFirstLine, inferSlug, missionBranchPrefix, missionBranchName } = require('../core/mission-utils');
|
|
4
4
|
const { WORKFLOW_AGENT_NAMES, eligibleAgentsForStep, readAgentConfigOrExit, workflowLauncherStatus } = require('../agents/agents');
|
|
@@ -77,6 +77,7 @@ function findStaleMissionWorktrees({
|
|
|
77
77
|
return {
|
|
78
78
|
slug,
|
|
79
79
|
path: entry.path,
|
|
80
|
+
branch: branchRef,
|
|
80
81
|
taskStatus: taskStatus || 'missing',
|
|
81
82
|
cleanupCommand: taskStatus === 'done'
|
|
82
83
|
? `scripts/cleanup-mission-worktree.sh ${slug}`
|
|
@@ -86,6 +87,19 @@ function findStaleMissionWorktrees({
|
|
|
86
87
|
.filter(Boolean);
|
|
87
88
|
}
|
|
88
89
|
|
|
90
|
+
function formatWorktreeBranch(ref) {
|
|
91
|
+
if (!ref) return '(detached HEAD)';
|
|
92
|
+
return ref.replace(/^refs\/heads\//, '');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function logRebaseDiagnostics(log, label, rebaseState) {
|
|
96
|
+
const detachedText = rebaseState.detached ? 'detached HEAD, ' : '';
|
|
97
|
+
log(`${label}: ${detachedText}${rebaseState.unmergedFiles.length} unmerged file(s)`);
|
|
98
|
+
rebaseState.unmergedFiles.forEach(file => {
|
|
99
|
+
log(` - ${file}`);
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
89
103
|
function status(args, options = {}) {
|
|
90
104
|
const exit = options.exit || process.exit;
|
|
91
105
|
const log = options.log || fmt.log.plain;
|
|
@@ -104,6 +118,7 @@ function status(args, options = {}) {
|
|
|
104
118
|
const workflowLauncherStatusFn = options.workflowLauncherStatusFn || workflowLauncherStatus;
|
|
105
119
|
const getLastThreeCommitsFn = options.getLastThreeCommitsFn || getLastThreeCommits;
|
|
106
120
|
const getUncommittedCountFn = options.getUncommittedCountFn || getUncommittedCount;
|
|
121
|
+
const detectRebaseStateFn = options.detectRebaseStateFn || detectRebaseState;
|
|
107
122
|
|
|
108
123
|
const explicitSlug = args[0];
|
|
109
124
|
const slug = inferSlugFn(explicitSlug);
|
|
@@ -112,6 +127,15 @@ function status(args, options = {}) {
|
|
|
112
127
|
log(`Branch: ${fmt.branch(getCurrentBranchFn())}`);
|
|
113
128
|
log(`Worktree: ${fmt.path(process.cwd())}`);
|
|
114
129
|
|
|
130
|
+
try {
|
|
131
|
+
const rebaseState = detectRebaseStateFn(process.cwd());
|
|
132
|
+
if (rebaseState.inProgress && rebaseState.detached) {
|
|
133
|
+
logRebaseDiagnostics(log, 'Detached HEAD: rebase in progress', rebaseState);
|
|
134
|
+
}
|
|
135
|
+
} catch (_) {
|
|
136
|
+
// Ignore transient worktree/git-state failures in status output.
|
|
137
|
+
}
|
|
138
|
+
|
|
115
139
|
if (slug) {
|
|
116
140
|
const taskFile = findTaskFileFn(slug);
|
|
117
141
|
const taskStatus = getTaskStatusFn(taskFile);
|
|
@@ -146,6 +170,14 @@ function status(args, options = {}) {
|
|
|
146
170
|
const staleWorktrees = findStaleMissionWorktreesFn();
|
|
147
171
|
staleWorktrees.forEach(entry => {
|
|
148
172
|
log(`Stale worktree: ${fmt.path(entry.path)} (task: ${entry.taskStatus})`);
|
|
173
|
+
try {
|
|
174
|
+
const rebaseState = detectRebaseStateFn(entry.path);
|
|
175
|
+
if (rebaseState.inProgress) {
|
|
176
|
+
logRebaseDiagnostics(log, `Rebase in progress on ${formatWorktreeBranch(entry.branch)}`, rebaseState);
|
|
177
|
+
}
|
|
178
|
+
} catch (_) {
|
|
179
|
+
// Ignore stale worktrees that disappear during inspection.
|
|
180
|
+
}
|
|
149
181
|
log(` Cleanup: ${fmt.command(entry.cleanupCommand)}`);
|
|
150
182
|
});
|
|
151
183
|
}
|
package/lib/core/fmt.js
CHANGED
|
@@ -31,7 +31,7 @@ const agentMap = {
|
|
|
31
31
|
codex: 'magenta',
|
|
32
32
|
claude: 'blue',
|
|
33
33
|
gemini: 'cyan',
|
|
34
|
-
|
|
34
|
+
custom: 'yellow',
|
|
35
35
|
};
|
|
36
36
|
|
|
37
37
|
function colorize(format, text) {
|
|
@@ -66,8 +66,12 @@ function status(type, text) {
|
|
|
66
66
|
*/
|
|
67
67
|
function agent(family, text = family) {
|
|
68
68
|
let label = text;
|
|
69
|
-
|
|
70
|
-
|
|
69
|
+
// Disambiguate the opencode-backed `custom` family only when the caller
|
|
70
|
+
// passes a distinct display text (e.g. a model id): show `custom (opencode)`
|
|
71
|
+
// so the underlying runtime is explicit. When text is just `custom`, render
|
|
72
|
+
// it plainly to avoid the redundant `custom (opencode)` suffix.
|
|
73
|
+
if (family === 'custom' && text !== 'custom') {
|
|
74
|
+
label = 'custom (opencode)';
|
|
71
75
|
}
|
|
72
76
|
const format = agentMap[family];
|
|
73
77
|
if (!format) return label;
|