@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.
@@ -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 qwen telemetry drops to all
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 qwen/vLLM backend failures that opencode surfaces as a non-zero
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: 'qwen',
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,
@@ -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, qwen)
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', {
@@ -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, mainRepo);
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(mainRepo, normalizedSlug);
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
- recordDraftStats({
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: mainRepo,
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
- const detectFn = getConflictFilesFn || ((_rootDir, _branch) => getConflictFiles(worktreePath, getPrimaryBranch()));
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 ${getPrimaryBranch()}...`);
1445
+ fmt.log.info(`Detecting conflicts in ${worktreePath} against ${targetBranch}...`);
1418
1446
  let conflictFiles;
1419
1447
  try {
1420
- conflictFiles = detectFn(worktreePath, getPrimaryBranch());
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 ${getPrimaryBranch()}`);
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 ${getPrimaryBranch()}`);
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 ${getPrimaryBranch()} branch:`,
1489
- ` git fetch review ${getPrimaryBranch()}`,
1490
- ` git rebase ${getPrimaryBranch()}`,
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, run } = require('../core/git');
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.runFn || run;
38
+ const runFn = options.gitFn || git;
39
39
 
40
40
  const explicitSlug = args[0];
41
41
  const slug = inferSlugFn(explicitSlug);
@@ -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
- 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]);
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 = getPrimaryBranch(worktreePath, gitFn);
578
+ primaryBranch = resolveMissionBaseBranch(slug, worktreePath, { gitFn });
556
579
  } catch (_) {
557
- primaryBranch = 'main';
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])qwen([^a-z]|$)/.test(normalized)) return 'qwen';
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';
@@ -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,
@@ -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
- qwen: 'yellow',
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
- if (family === 'qwen' && text === 'qwen') {
70
- label = 'qwen (opencode)';
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;