@magnusekdahl/parallix 1.3.3 → 1.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/docs/agents.md +1 -1
  2. package/docs/use-cases.md +1 -1
  3. package/lib/agents/agents.js +20 -3
  4. package/lib/agents/agents.ts +14 -3
  5. package/lib/agents/mistral.js +128 -14
  6. package/lib/agents/mistral.ts +145 -5
  7. package/lib/commands/active.js +69 -39
  8. package/lib/commands/active.ts +97 -60
  9. package/lib/commands/config.ts +3 -3
  10. package/lib/commands/coverage-gate.ts +1 -1
  11. package/lib/commands/draft.js +1 -1
  12. package/lib/commands/draft.ts +1 -1
  13. package/lib/commands/handoff.js +13 -8
  14. package/lib/commands/handoff.ts +24 -18
  15. package/lib/commands/rebase.js +1 -1
  16. package/lib/commands/rebase.ts +2 -2
  17. package/lib/commands/repair-handoff.js +141 -20
  18. package/lib/commands/repair-handoff.ts +185 -45
  19. package/lib/commands/resolve-conflict.js +1 -1
  20. package/lib/commands/resolve-conflict.ts +2 -2
  21. package/lib/commands/stats-backfill.ts +10 -10
  22. package/lib/commands/stats.js +38 -11
  23. package/lib/commands/stats.ts +40 -96
  24. package/lib/core/fmt.ts +2 -2
  25. package/lib/core/git.ts +2 -2
  26. package/lib/core/gitignore.ts +2 -2
  27. package/lib/core/mission-utils.js +2 -2
  28. package/lib/core/mission-utils.ts +2 -2
  29. package/lib/core/persistent-data-migration.ts +2 -2
  30. package/lib/core/spawn-tee.ts +1 -1
  31. package/lib/core/state-map.ts +2 -2
  32. package/lib/core/storage.ts +1 -1
  33. package/lib/core/verification.ts +1 -1
  34. package/lib/review/rebase.ts +12 -12
  35. package/lib/review/review-artifacts.ts +35 -35
  36. package/lib/review/review-commands.ts +40 -40
  37. package/lib/review/review-events.ts +12 -12
  38. package/lib/review/review-loop.js +236 -7
  39. package/lib/review/review-loop.ts +338 -22
  40. package/lib/review/review-polling.ts +6 -6
  41. package/lib/review/review-prompts.js +8 -4
  42. package/lib/review/review-prompts.ts +12 -8
  43. package/lib/review/review-state.js +1 -1
  44. package/lib/review/review-state.ts +2 -2
  45. package/package.json +3 -2
  46. package/prompts/review-verbose.md +1 -1
  47. package/prompts/review.md +1 -1
@@ -302,36 +302,37 @@ function applyExecuteFallback(opts) {
302
302
  * @returns {Promise<{relaunched: boolean, error?: string}>} Result of relaunch attempt
303
303
  */
304
304
  /**
305
- * @param {string} slug
306
- * @param {string} worktree
307
- * @param {string} errorMsg
308
- * @param {string} agent
309
- * @param {{isRelaunchableErrorFn?: Function, buildRelaunchPromptFn?: Function, workflowLauncherStatusFn?: Function, startAgentFn?: Function, log?: Function, error?: Function}} [options]
310
- */
311
- async function attemptAgentRelaunch(slug, worktree, errorMsg, agent, options = {}) {
312
- const {
313
- isRelaunchableErrorFn = repairHandoff.isRelaunchableError,
314
- buildRelaunchPromptFn = repairHandoff.buildRelaunchPrompt,
315
- workflowLauncherStatusFn = agents.workflowLauncherStatus,
316
- startAgentFn = agents.startAgent,
317
- log = fmt.log.plain,
318
- error = fmt.log.plainError
319
- } = options;
320
- // Check if this is a relaunchable error
321
- if (!isRelaunchableErrorFn(errorMsg)) {
322
- log(`Error is not relaunchable: ${errorMsg}`);
323
- return { relaunched: false, error: 'Error is not relaunchable for agent relaunch' };
324
- }
325
-
326
- // Check if the agent launcher is available
327
- const status = workflowLauncherStatusFn(agent);
328
- if (!status.supported) {
329
- error(`Agent ${fmt.agent(agent)} is not available for relaunch: ${status.detail || status.reason || 'unknown'}`);
330
- return { relaunched: false, error: `Agent ${agent} launcher is not available` };
331
- }
332
-
333
- // Build the relaunch prompt
334
- const prompt = buildRelaunchPromptFn(errorMsg, slug, worktree);
305
+ * @param {string} slug
306
+ * @param {string} worktree
307
+ * @param {string} errorMsg
308
+ * @param {string} agent
309
+ * @param {{isRelaunchableErrorFn?: Function, buildRelaunchPromptFn?: Function, workflowLauncherStatusFn?: Function, startAgentFn?: Function, log?: Function, error?: Function, gateOutput?: {stdout: string, stderr: string}}} [options]
310
+ */
311
+ async function attemptAgentRelaunch(slug, worktree, errorMsg, agent, options = {}) {
312
+ const {
313
+ isRelaunchableErrorFn = repairHandoff.isRelaunchableError,
314
+ buildRelaunchPromptFn = repairHandoff.buildRelaunchPrompt,
315
+ workflowLauncherStatusFn = agents.workflowLauncherStatus,
316
+ startAgentFn = agents.startAgent,
317
+ log = fmt.log.plain,
318
+ error = fmt.log.plainError,
319
+ gateOutput
320
+ } = options;
321
+ // Check if this is a relaunchable error
322
+ if (!isRelaunchableErrorFn(errorMsg)) {
323
+ log(`Error is not relaunchable: ${errorMsg}`);
324
+ return { relaunched: false, error: 'Error is not relaunchable for agent relaunch' };
325
+ }
326
+
327
+ // Check if the agent launcher is available
328
+ const status = workflowLauncherStatusFn(agent);
329
+ if (!status.supported) {
330
+ error(`Agent ${fmt.agent(agent)} is not available for relaunch: ${status.detail || status.reason || 'unknown'}`);
331
+ return { relaunched: false, error: `Agent ${agent} launcher is not available` };
332
+ }
333
+
334
+ // Build the relaunch prompt, passing captured gate output if available (task-1387)
335
+ const prompt = buildRelaunchPromptFn(errorMsg, slug, worktree, gateOutput);
335
336
 
336
337
  log(`Attempting to relaunch ${fmt.agent(agent)} to fix repairable handoff error...`);
337
338
  // startAgent handles resume flags internally for resume-capable agents (codex, claude, gemini, custom)
@@ -430,7 +431,7 @@ async function runHandoffAndReview(slug, worktree, agent, options = {}) {
430
431
  validateCheckpointsBeforeHandoffFn = validateCheckpointsBeforeHandoff,
431
432
  performHandoff: _performHandoff = (/** @type{string} */ s, /** @type{object} */ o) => handoff.performHandoff(s, o),
432
433
  startReviewLoop: _startReviewLoop = (/** @type{string} */ s, /** @type{object} */ o) => review.startReviewLoop(s, o),
433
- repairHandoffFn = /** @type{(s: string, w: string, e: string, o: object) => Promise<{repaired: boolean, blocker?: string}>} */(repairHandoff),
434
+ repairHandoffFn = /** @type{(s: string, w: string, e: string, o: object) => Promise<{repaired: boolean, blocker?: string}>} */(repairHandoff.default),
434
435
  attemptAgentRelaunchFn = attemptAgentRelaunch,
435
436
  log = fmt.log.plain,
436
437
  error = fmt.log.plainError
@@ -449,37 +450,73 @@ async function runHandoffAndReview(slug, worktree, agent, options = {}) {
449
450
  let handoffResult = await _performHandoff(slug, { forgejoUser: agent, worktree });
450
451
 
451
452
  if (!handoffResult.ok) {
452
- // Attempt single repair for routine hygiene issues (dirty artifacts, rebase needed)
453
- log(`\nAutomated handoff failed: ${handoffResult.error}`);
454
- log(`Attempting post-execute repair...`);
455
- const { repaired, blocker } = await /** @type{Function} */(repairHandoffFn)(slug, worktree, /** @type{string} */(handoffResult.error), { taskFile, log, error });
456
- if (repaired) {
457
- log(`Repair successful. Retrying automated handoff...`);
458
- handoffResult = await _performHandoff(slug, { forgejoUser: agent, worktree, force: true });
459
- } else if (blocker) {
460
- // If repair failed but provided a specific blocker (e.g. rebase failure),
461
- // report that blocker as the final error instead of the original handoff error.
462
- handoffResult.error = blocker;
463
- } else if (!repaired && repairHandoff.isRelaunchableError(handoffResult.error)) {
464
- // Attempt agent relaunch for repairable content errors (missing goal-check table)
465
- log(`Content error detected. Attempting agent relaunch to fix...`);
466
- const { relaunched, error: relaunchError } = await attemptAgentRelaunchFn(
467
- slug, worktree, /** @type{string} */(handoffResult.error), agent, { log, error }
468
- );
469
- if (relaunched) {
470
- // Agent was relaunched successfully; re-invoke performHandoff to verify
471
- // the handoff-to-review transition actually completed, matching the
472
- // contract of the repair-success path above.
473
- log(`Agent relaunched. It will fix the checkpoint and retry handoff.`);
453
+ // Check for genuine gate failure (task-1387): automatic relaunch with captured output
454
+ const isGenuineGateFailure = handoffResult.gateOutput ||
455
+ (handoffResult.error && (
456
+ /verification gate failed/i.test(handoffResult.error) ||
457
+ (/\bdeclared gate\b/i.test(handoffResult.error) && /\bfailed\b/i.test(handoffResult.error))
458
+ ));
459
+
460
+ if (isGenuineGateFailure) {
461
+ // Automatic relaunch with captured gate output, bounded to max 2 attempts
462
+ let relaunchCount = 0;
463
+ const maxRelaunches = 2;
464
+
465
+ while (relaunchCount < maxRelaunches) {
466
+ relaunchCount++;
467
+ log(`\nGenuine gate failure detected. Relaunch attempt ${relaunchCount}/${maxRelaunches}...`);
468
+ const { relaunched, error: relaunchError } = await attemptAgentRelaunchFn(
469
+ slug, worktree, /** @type{string} */(handoffResult.error), agent,
470
+ { log, error, gateOutput: handoffResult.gateOutput }
471
+ );
472
+ if (relaunched) {
473
+ handoffResult = await _performHandoff(slug, { forgejoUser: agent, worktree, force: true });
474
+ if (handoffResult.ok) {
475
+ break; // Success — proceed to review loop
476
+ }
477
+ // Handoff still failed; continue loop for another relaunch attempt
478
+ } else {
479
+ log(`Agent relaunch failed: ${relaunchError || 'unknown error'}`);
480
+ break; // Relaunch itself failed; stop
481
+ }
482
+ }
483
+
484
+ if (!handoffResult.ok && relaunchCount >= maxRelaunches) {
485
+ handoffResult.error = `Gate failure persisting after ${maxRelaunches} relaunch attempts. Manual intervention required.`;
486
+ }
487
+ } else {
488
+ // Original logic: attempt single repair for routine hygiene issues (dirty artifacts, rebase needed)
489
+ log(`\nAutomated handoff failed: ${handoffResult.error}`);
490
+ log(`Attempting post-execute repair...`);
491
+ const { repaired, blocker } = await /** @type{Function} */(repairHandoffFn)(slug, worktree, /** @type{string} */(handoffResult.error), { taskFile, log, error });
492
+ if (repaired) {
493
+ log(`Repair successful. Retrying automated handoff...`);
474
494
  handoffResult = await _performHandoff(slug, { forgejoUser: agent, worktree, force: true });
475
- if (!handoffResult.ok) {
476
- handoffResult.error = `Post-relaunch handoff failed: ${handoffResult.error || 'unknown'}`;
495
+ } else if (blocker) {
496
+ // If repair failed but provided a specific blocker (e.g. rebase failure),
497
+ // report that blocker as the final error instead of the original handoff error.
498
+ handoffResult.error = blocker;
499
+ } else if (!repaired && repairHandoff.isRelaunchableError(handoffResult.error)) {
500
+ // Attempt agent relaunch for repairable content errors (missing goal-check table)
501
+ log(`Content error detected. Attempting agent relaunch to fix...`);
502
+ const { relaunched, error: relaunchError } = await attemptAgentRelaunchFn(
503
+ slug, worktree, /** @type{string} */(handoffResult.error), agent, { log, error }
504
+ );
505
+ if (relaunched) {
506
+ // Agent was relaunched successfully; re-invoke performHandoff to verify
507
+ // the handoff-to-review transition actually completed, matching the
508
+ // contract of the repair-success path above.
509
+ log(`Agent relaunched. It will fix the checkpoint and retry handoff.`);
510
+ handoffResult = await _performHandoff(slug, { forgejoUser: agent, worktree, force: true });
511
+ if (!handoffResult.ok) {
512
+ handoffResult.error = `Post-relaunch handoff failed: ${handoffResult.error || 'unknown'}`;
513
+ }
514
+ // Fall through to gatekeeper pushback / review loop / failure handling below.
515
+ } else {
516
+ // Relaunch failed or was not possible
517
+ log(`Agent relaunch failed: ${relaunchError || 'unknown error'}`);
518
+ // Fall through to manual handoff message
477
519
  }
478
- // Fall through to gatekeeper pushback / review loop / failure handling below.
479
- } else {
480
- // Relaunch failed or was not possible
481
- log(`Agent relaunch failed: ${relaunchError || 'unknown error'}`);
482
- // Fall through to manual handoff message
483
520
  }
484
521
  }
485
522
  }
@@ -4,9 +4,9 @@ import * as fmt from '../core/fmt.js';
4
4
  import { loadEffectiveConfig, loadWorkflowConfig, validateWorkflowConfig } from '../core/product-config.js';
5
5
 
6
6
  interface ConfigOptions {
7
- logFn?: (msg: string) => void;
8
- errorFn?: (msg: string) => void;
9
- exitFn?: (code: number) => void;
7
+ logFn?: (_msg: string) => void;
8
+ errorFn?: (_msg: string) => void;
9
+ exitFn?: (_code: number) => void;
10
10
  rootDir?: string;
11
11
  }
12
12
 
@@ -301,7 +301,7 @@ if (typeof require !== 'undefined' && require.main === module) {
301
301
  }
302
302
 
303
303
  interface CoverageGateOptions {
304
- exitFn?: (code: number) => void;
304
+ exitFn?: (_code: number) => void;
305
305
  }
306
306
 
307
307
  function run(args: string[], options: CoverageGateOptions = {}) {
@@ -468,7 +468,7 @@ function ensureWorktree(mainRepo, targetWorktree, branchName, { existsFn = fs.ex
468
468
  try {
469
469
  gitFn(['-C', mainRepo, 'worktree', 'add', targetWorktree, branchName]);
470
470
  }
471
- catch (error) {
471
+ catch (_error) {
472
472
  // Ignore "already exists" style failures; the directory is already usable.
473
473
  }
474
474
  return;
@@ -488,7 +488,7 @@ function ensureWorktree(mainRepo, targetWorktree, branchName, {
488
488
  logFn(fmt.status('PASS', `Worktree directory ${fmt.path(targetWorktree)} already exists.`));
489
489
  try {
490
490
  gitFn(['-C', mainRepo, 'worktree', 'add', targetWorktree, branchName]);
491
- } catch (error) {
491
+ } catch (_error) {
492
492
  // Ignore "already exists" style failures; the directory is already usable.
493
493
  }
494
494
  return;
@@ -94,9 +94,9 @@ function verifyHandoff(slug, options = {}) {
94
94
  * @returns {Promise<{ ok: boolean, error?: string, gatekeeperPushedBack?: boolean }>}
95
95
  */
96
96
  async function performHandoff(slug, options = {}) {
97
- /** @type{{skipGate?: boolean, worktree?: string|null, force?: boolean, forceWithLease?: boolean, log?: Function, error?: Function, rebaseFn?: Function, isForgejoReviewEnabledFn?: Function}} */
97
+ /** @type{{skipGate?: boolean, worktree?: string|null, force?: boolean, forceWithLease?: boolean, log?: Function, error?: Function, rebaseFn?: Function, isForgejoReviewEnabledFn?: Function, runVerificationGateFn?: Function}} */
98
98
  const opts = options;
99
- const { skipGate = false, worktree = null, force = false, forceWithLease = true, log = fmt.log.info, error = fmt.log.fail, rebaseFn = rebase_js_1.rebaseBeforeReviewRound } = opts;
99
+ const { skipGate = false, worktree = null, force = false, forceWithLease = true, log = fmt.log.info, error = fmt.log.fail, rebaseFn = rebase_js_1.rebaseBeforeReviewRound, runVerificationGateFn = verification_js_1.runVerificationGate } = opts;
100
100
  const verification = verifyHandoff(slug, { worktree: worktree || undefined });
101
101
  if (!verification.ok) {
102
102
  error(verification.error);
@@ -213,15 +213,17 @@ async function performHandoff(slug, options = {}) {
213
213
  }
214
214
  else {
215
215
  log(`Step 1: Running final verification gate for area: ${fmt.bold(area || 'docs')}...`);
216
- const verifyResult = (0, verification_js_1.runVerificationGate)(area || 'docs', {
216
+ const verifyResult = runVerificationGateFn(area || 'docs', {
217
217
  rootDir,
218
- stdio: 'inherit',
218
+ stdio: 'pipe',
219
219
  runFn: git.run
220
220
  });
221
221
  if (verifyResult.status !== 0) {
222
+ const stdout = (verifyResult.stdout || '').trim();
223
+ const stderr = (verifyResult.stderr || '').trim();
222
224
  const msg = 'Final verification gate failed. Fix errors before submitting or use --no-gate if appropriate.';
223
225
  error(msg);
224
- return { ok: false, error: msg };
226
+ return { ok: false, error: msg, gateOutput: { stdout, stderr } };
225
227
  }
226
228
  }
227
229
  // Step 1.5: Rebase mission branch onto latest primary before PR creation
@@ -356,7 +358,7 @@ async function performHandoff(slug, options = {}) {
356
358
  if (!gatesResult.ok) {
357
359
  const msg = `Declared gate "${gatesResult.gate}" failed for ${fmt.slug(slug)}: ${gatesResult.error || gatesResult.reason}. Blocking handoff — task remains in active.`;
358
360
  error(msg);
359
- return { ok: false, error: msg };
361
+ return { ok: false, error: msg, gateOutput: { stdout: (gatesResult.stdout || ''), stderr: (gatesResult.stderr || '') } };
360
362
  }
361
363
  if (gatesResult.skipped) {
362
364
  log(`No declared gates for ${fmt.slug(slug)} (${gatesResult.reason}).`);
@@ -464,15 +466,18 @@ function runDeclaredGates(missionDir, rootDir, options = {}) {
464
466
  const result = (0, node_child_process_1.spawnSync)('bash', ['-c', cmd], {
465
467
  cwd: rootDir,
466
468
  encoding: 'utf8',
467
- stdio: ['inherit', 'pipe', 'pipe']
469
+ stdio: 'pipe'
468
470
  });
469
471
  if (result.status !== 0) {
472
+ const stdout = (result.stdout || '').trim();
470
473
  const stderr = (result.stderr || '').trim();
471
474
  return {
472
475
  ok: false,
473
476
  gate: cmd,
474
477
  reason: 'gate-failed',
475
- error: stderr || `Gate exited with status ${result.status}`
478
+ error: stderr || `Gate exited with status ${result.status}`,
479
+ stdout,
480
+ stderr
476
481
  };
477
482
  }
478
483
  }
@@ -58,18 +58,19 @@ import * as nels from '../core/nels.js';
58
58
  * @param {{skipGate?: boolean, worktree?: string|null, force?: boolean, forceWithLease?: boolean, log?: Function, error?: Function, rebaseFn?: Function, isForgejoReviewEnabledFn?: Function}} [options]
59
59
  * @returns {Promise<{ ok: boolean, error?: string, gatekeeperPushedBack?: boolean }>}
60
60
  */
61
- async function performHandoff(slug, options = {}) {
62
- /** @type{{skipGate?: boolean, worktree?: string|null, force?: boolean, forceWithLease?: boolean, log?: Function, error?: Function, rebaseFn?: Function, isForgejoReviewEnabledFn?: Function}} */
63
- const opts = options;
64
- const {
65
- skipGate = false,
66
- worktree = null,
67
- force = false,
68
- forceWithLease = true,
69
- log = fmt.log.info,
70
- error = fmt.log.fail,
71
- rebaseFn = rebaseBeforeReviewRound
72
- } = opts;
61
+ async function performHandoff(slug, options = {}) {
62
+ /** @type{{skipGate?: boolean, worktree?: string|null, force?: boolean, forceWithLease?: boolean, log?: Function, error?: Function, rebaseFn?: Function, isForgejoReviewEnabledFn?: Function, runVerificationGateFn?: Function}} */
63
+ const opts = options;
64
+ const {
65
+ skipGate = false,
66
+ worktree = null,
67
+ force = false,
68
+ forceWithLease = true,
69
+ log = fmt.log.info,
70
+ error = fmt.log.fail,
71
+ rebaseFn = rebaseBeforeReviewRound,
72
+ runVerificationGateFn = runVerificationGate
73
+ } = opts;
73
74
 
74
75
  const verification = verifyHandoff(slug, { worktree: worktree || undefined });
75
76
  if (!verification.ok) {
@@ -198,15 +199,17 @@ import * as nels from '../core/nels.js';
198
199
  fmt.log.warn('Step 1: Skipping final verification gate (--no-gate)');
199
200
  } else {
200
201
  log(`Step 1: Running final verification gate for area: ${fmt.bold(area || 'docs')}...`);
201
- const verifyResult = runVerificationGate(area || 'docs', {
202
+ const verifyResult = runVerificationGateFn(area || 'docs', {
202
203
  rootDir,
203
- stdio: 'inherit',
204
+ stdio: 'pipe',
204
205
  runFn: git.run
205
206
  });
206
207
  if (verifyResult.status !== 0) {
208
+ const stdout = (verifyResult.stdout || '').trim();
209
+ const stderr = (verifyResult.stderr || '').trim();
207
210
  const msg = 'Final verification gate failed. Fix errors before submitting or use --no-gate if appropriate.';
208
211
  error(msg);
209
- return { ok: false, error: msg };
212
+ return { ok: false, error: msg, gateOutput: { stdout, stderr } };
210
213
  }
211
214
  }
212
215
 
@@ -346,7 +349,7 @@ import * as nels from '../core/nels.js';
346
349
  if (!gatesResult.ok) {
347
350
  const msg = `Declared gate "${gatesResult.gate}" failed for ${fmt.slug(slug)}: ${gatesResult.error || gatesResult.reason}. Blocking handoff — task remains in active.`;
348
351
  error(msg);
349
- return { ok: false, error: msg };
352
+ return { ok: false, error: msg, gateOutput: { stdout: (gatesResult.stdout || ''), stderr: (gatesResult.stderr || '') } };
350
353
  }
351
354
  if (gatesResult.skipped) {
352
355
  log(`No declared gates for ${fmt.slug(slug)} (${gatesResult.reason}).`);
@@ -463,16 +466,19 @@ function runDeclaredGates(missionDir, rootDir, options = {}) {
463
466
  const result = spawnSync('bash', ['-c', cmd], {
464
467
  cwd: rootDir,
465
468
  encoding: 'utf8',
466
- stdio: ['inherit', 'pipe', 'pipe']
469
+ stdio: 'pipe'
467
470
  });
468
471
 
469
472
  if (result.status !== 0) {
473
+ const stdout = (result.stdout || '').trim();
470
474
  const stderr = (result.stderr || '').trim();
471
475
  return {
472
476
  ok: false,
473
477
  gate: cmd,
474
478
  reason: 'gate-failed',
475
- error: stderr || `Gate exited with status ${result.status}`
479
+ error: stderr || `Gate exited with status ${result.status}`,
480
+ stdout,
481
+ stderr
476
482
  };
477
483
  }
478
484
  }
@@ -58,7 +58,7 @@ const verification_js_1 = require("../core/verification.js");
58
58
  * Usage: px rebase [<slug>] [--push]
59
59
  */
60
60
  /** @param {string[]} args @param {{inferSlugFn?: Function, findMissionDirFn?: Function, findMissionAreaFn?: Function, getCurrentBranchFn?: Function, resolveConflictsFn?: Function, startAgentFn?: Function, createPrFn?: Function, readTokenFn?: Function, resolveForgejoUserFn?: Function, resolveTaskFileFn?: Function, getTaskImplementerFn?: Function, detectRebaseStateFn?: Function, resolveMissionBaseBranchFn?: Function, gitFn?: Function, exitFn?: Function, isForgejoReviewEnabledFn?: Function, fetchReviewBranchFn?: Function}} opts */
61
- async function rebase(args, { inferSlugFn = mission_utils_js_1.inferSlug, findMissionDirFn = mission_utils_js_1.findMissionDir, findMissionAreaFn = mission_utils_js_1.findMissionArea, getCurrentBranchFn = git_js_1.getCurrentBranch, resolveConflictsFn = integrate_js_1.default.resolveConflictsForMission, startAgentFn = agents_js_1.startAgent, createPrFn = forgejo_js_1.createPr, readTokenFn = forgejo_js_1.readToken, resolveForgejoUserFn = forgejo_js_1.resolveForgejoUser, resolveTaskFileFn = backlog_js_1.resolveTaskFile, getTaskImplementerFn = backlog_js_1.getTaskImplementer, detectRebaseStateFn = git_js_1.detectRebaseState, resolveMissionBaseBranchFn = mission_utils_js_1.resolveMissionBaseBranch, gitFn = git_js_1.git, exitFn = ((code) => process.exit(code)), isForgejoReviewEnabledFn = product_config_js_1.isForgejoReviewEnabled, fetchReviewBranchFn = forgejo_js_1.fetchReviewBranch, } = {}) {
61
+ async function rebase(args, { inferSlugFn = mission_utils_js_1.inferSlug, findMissionDirFn = mission_utils_js_1.findMissionDir, findMissionAreaFn = mission_utils_js_1.findMissionArea, getCurrentBranchFn = git_js_1.getCurrentBranch, resolveConflictsFn = integrate_js_1.default.resolveConflictsForMission, startAgentFn = agents_js_1.startAgent, createPrFn = forgejo_js_1.createPr, readTokenFn = forgejo_js_1.readToken, resolveForgejoUserFn = forgejo_js_1.resolveForgejoUser, resolveTaskFileFn = backlog_js_1.resolveTaskFile, getTaskImplementerFn = backlog_js_1.getTaskImplementer, detectRebaseStateFn = git_js_1.detectRebaseState, resolveMissionBaseBranchFn = mission_utils_js_1.resolveMissionBaseBranch, gitFn = git_js_1.git, exitFn = ((_code) => process.exit(_code)), isForgejoReviewEnabledFn = product_config_js_1.isForgejoReviewEnabled, fetchReviewBranchFn = forgejo_js_1.fetchReviewBranch, } = {}) {
62
62
  const flags = args.filter(a => a.startsWith('--'));
63
63
  const params = args.filter(a => !a.startsWith('--'));
64
64
  const isPush = flags.includes('--push');
@@ -32,10 +32,10 @@ async function rebase(args: string[], {
32
32
  detectRebaseStateFn = detectRebaseState,
33
33
  resolveMissionBaseBranchFn = resolveMissionBaseBranch,
34
34
  gitFn = git,
35
- exitFn = ((code: number) => process.exit(code)) as (code: number) => void,
35
+ exitFn = ((_code: number) => process.exit(_code)) as (_code: number) => void,
36
36
  isForgejoReviewEnabledFn = isForgejoReviewEnabled,
37
37
  fetchReviewBranchFn = fetchReviewBranch,
38
- }: {inferSlugFn?: Function, findMissionDirFn?: Function, findMissionAreaFn?: Function, getCurrentBranchFn?: Function, resolveConflictsFn?: Function, startAgentFn?: Function, createPrFn?: Function, readTokenFn?: Function, resolveForgejoUserFn?: Function, resolveTaskFileFn?: Function, getTaskImplementerFn?: Function, detectRebaseStateFn?: Function, resolveMissionBaseBranchFn?: Function, gitFn?: Function, exitFn?: (code: number) => void, isForgejoReviewEnabledFn?: Function, fetchReviewBranchFn?: Function} = {}) {
38
+ }: {inferSlugFn?: Function, findMissionDirFn?: Function, findMissionAreaFn?: Function, getCurrentBranchFn?: Function, resolveConflictsFn?: Function, startAgentFn?: Function, createPrFn?: Function, readTokenFn?: Function, resolveForgejoUserFn?: Function, resolveTaskFileFn?: Function, getTaskImplementerFn?: Function, detectRebaseStateFn?: Function, resolveMissionBaseBranchFn?: Function, gitFn?: Function, exitFn?: (_code: number) => void, isForgejoReviewEnabledFn?: Function, fetchReviewBranchFn?: Function} = {}) {
39
39
  const flags = args.filter(a => a.startsWith('--'));
40
40
  const params = args.filter(a => !a.startsWith('--'));
41
41
  const isPush = flags.includes('--push');
@@ -36,6 +36,9 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
36
36
  return (mod && mod.__esModule) ? mod : { "default": mod };
37
37
  };
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
+ exports.DispatchAction = exports.FailureClass = void 0;
40
+ exports.getDispatchAction = getDispatchAction;
41
+ exports.classifyError = classifyError;
39
42
  exports.repairHandoff = repairHandoff;
40
43
  exports.isRelaunchableError = isRelaunchableError;
41
44
  exports.buildRelaunchPrompt = buildRelaunchPrompt;
@@ -43,19 +46,129 @@ const git_js_1 = require("../core/git.js");
43
46
  const missionUtils = __importStar(require("../core/mission-utils.js"));
44
47
  const rebase_js_1 = __importDefault(require("./rebase.js"));
45
48
  const fmt = __importStar(require("../core/fmt.js"));
49
+ // ── FailureClass: 8 classes from ADR 0048 ────────────────────────────────────
50
+ const FailureClass = {
51
+ UnverifiableClaims: 'UnverifiableClaims',
52
+ MalformedGates: 'MalformedGates',
53
+ MissingArtifacts: 'MissingArtifacts',
54
+ IncompleteEvidence: 'IncompleteEvidence',
55
+ GitBlockers: 'GitBlockers',
56
+ GateFailure: 'GateFailure',
57
+ InfraBlocker: 'InfraBlocker',
58
+ StateMachineViolation: 'StateMachineViolation',
59
+ };
60
+ exports.FailureClass = FailureClass;
61
+ // ── DispatchAction: 3 actions from ADR 0048 ──────────────────────────────────
62
+ const DispatchAction = {
63
+ AutoRepair: 'AutoRepair',
64
+ AutoSendBack: 'AutoSendBack',
65
+ HumanOnly: 'HumanOnly',
66
+ };
67
+ exports.DispatchAction = DispatchAction;
68
+ // ── Dispatch table: maps each failure class to its prescribed action (ADR 0048) ─
69
+ const DISPATCH_TABLE = {
70
+ [FailureClass.UnverifiableClaims]: DispatchAction.AutoSendBack,
71
+ [FailureClass.MalformedGates]: DispatchAction.AutoRepair,
72
+ [FailureClass.MissingArtifacts]: DispatchAction.AutoSendBack,
73
+ [FailureClass.IncompleteEvidence]: DispatchAction.AutoSendBack,
74
+ [FailureClass.GitBlockers]: DispatchAction.AutoRepair,
75
+ [FailureClass.GateFailure]: DispatchAction.AutoSendBack,
76
+ [FailureClass.InfraBlocker]: DispatchAction.HumanOnly,
77
+ [FailureClass.StateMachineViolation]: DispatchAction.HumanOnly,
78
+ };
79
+ /**
80
+ * Look up the dispatch action for a given failure class.
81
+ *
82
+ * @param failureClass - One of the 8 failure class values from ADR 0048
83
+ * @returns The prescribed dispatch action, or null if unknown
84
+ */
85
+ function getDispatchAction(failureClass) {
86
+ return DISPATCH_TABLE[failureClass] ?? null;
87
+ }
88
+ /**
89
+ * Classify an error message into one of the 8 failure classes from ADR 0048
90
+ * and return the associated dispatch action.
91
+ *
92
+ * Patterns are checked in order of specificity to avoid collisions.
93
+ * Returns a `reason` field for GitBlockers to distinguish dirty-artifact from behind-branch errors,
94
+ * allowing callers to derive repair-strategy flags without duplicating pattern matching.
95
+ *
96
+ * @param errorMsg - The error message to classify
97
+ * @returns Object with failureClass, dispatchAction, and (for GitBlockers) reason
98
+ */
99
+ function classifyError(errorMsg) {
100
+ if (!errorMsg || typeof errorMsg !== 'string') {
101
+ return { failureClass: FailureClass.InfraBlocker, dispatchAction: DispatchAction.HumanOnly };
102
+ }
103
+ // 1. IncompleteEvidence: goal-check table missing evidence rows (most specific — checked before generic gate patterns)
104
+ if (errorMsg.includes('has a "## Goal Check" section but no evidence rows') &&
105
+ errorMsg.includes('A goal-check table with real evidence is required before handoff')) {
106
+ return { failureClass: FailureClass.IncompleteEvidence, dispatchAction: DispatchAction.AutoSendBack };
107
+ }
108
+ // 2. GitBlockers: dirty/uncommitted mission artifacts (mechanical git blocker — auto-repairable)
109
+ if (errorMsg.includes('is modified but uncommitted') ||
110
+ errorMsg.includes('Commit the mission contract before handoff') ||
111
+ errorMsg.includes('Commit the implementation evidence before handoff')) {
112
+ return { failureClass: FailureClass.GitBlockers, dispatchAction: DispatchAction.AutoRepair, reason: 'dirty' };
113
+ }
114
+ // 3. GitBlockers: branch behind primary / push rejected (mechanical git blocker — auto-repairable via rebase)
115
+ if (errorMsg.includes('Updates were rejected') ||
116
+ errorMsg.includes('fetch first') ||
117
+ errorMsg.includes('non-fast-forward') ||
118
+ errorMsg.includes('behind its remote') ||
119
+ (errorMsg.includes('git push failed') && (errorMsg.includes('rejected') ||
120
+ errorMsg.includes('remote contains work')))) {
121
+ return { failureClass: FailureClass.GitBlockers, dispatchAction: DispatchAction.AutoRepair, reason: 'behind' };
122
+ }
123
+ // 4. GateFailure: verification gate failed
124
+ if (/verification gate failed/i.test(errorMsg)) {
125
+ return { failureClass: FailureClass.GateFailure, dispatchAction: DispatchAction.AutoSendBack };
126
+ }
127
+ // 5. GateFailure: declared gate failed
128
+ if (/\bdeclared gate\b/i.test(errorMsg) && /\bfailed\b/i.test(errorMsg)) {
129
+ return { failureClass: FailureClass.GateFailure, dispatchAction: DispatchAction.AutoSendBack };
130
+ }
131
+ // 6. UnverifiableClaims: test claims that cannot be verified
132
+ if (/test(s?\s+)?passed/i.test(errorMsg) && /cannot\s+verify|unverifiable|proof\s+(not\s+)?found|stale\s+proof/i.test(errorMsg)) {
133
+ return { failureClass: FailureClass.UnverifiableClaims, dispatchAction: DispatchAction.AutoSendBack };
134
+ }
135
+ // 7. MalformedGates: malformed or non-runnable declared gates
136
+ if (/malformed\s+gate|invalid\s+gate\s+config|gate\s+command\s+(not\s+found|syntax\s+error|not\s+runnable)/i.test(errorMsg) ||
137
+ (/gate/i.test(errorMsg) && /syntax\s+error|not\s+found|missing\s+(file|command)/i.test(errorMsg))) {
138
+ return { failureClass: FailureClass.MalformedGates, dispatchAction: DispatchAction.AutoRepair };
139
+ }
140
+ // 8. MissingArtifacts: mandatory mission artifacts missing
141
+ if (/mandatory\s+(artifact|file|document)|missing\s+(mission\s+)?(artifact|file|document)|required\s+(artifact|file|document)\s+(not\s+)?found/i.test(errorMsg) ||
142
+ (/gatekeeper/i.test(errorMsg) && /missing\s+(artifact|file|document)/i.test(errorMsg))) {
143
+ return { failureClass: FailureClass.MissingArtifacts, dispatchAction: DispatchAction.AutoSendBack };
144
+ }
145
+ // 9. StateMachineViolation: task state machine violations
146
+ if (/state\s+violation|invalid\s+state|transition\s+not\s+allowed|cannot\s+(move|transition)\s+(from|to)\s+\w+\s+(to|from)/i.test(errorMsg) ||
147
+ (/task\s+state/i.test(errorMsg) && /invalid|violation|incorrect/i.test(errorMsg))) {
148
+ return { failureClass: FailureClass.StateMachineViolation, dispatchAction: DispatchAction.HumanOnly };
149
+ }
150
+ // 10. InfraBlocker: forgejo/infrastructure blockers
151
+ if (/forgejo|infrastructure|authentication\s+failed|token\s+(expired|invalid|missing)|forbidden|unauthorized\s+(access|request)|rate\s+limit|connection\s+(refused|timed?\s*out)|network\s+error/i.test(errorMsg)) {
152
+ return { failureClass: FailureClass.InfraBlocker, dispatchAction: DispatchAction.HumanOnly };
153
+ }
154
+ // Default: human-only for unrecognized errors
155
+ return { failureClass: FailureClass.InfraBlocker, dispatchAction: DispatchAction.HumanOnly };
156
+ }
46
157
  /**
47
158
  * Check if an error message indicates a relaunchable content error (missing/empty goal-check table).
159
+ * Delegates to classifyError for backward-compatible classification.
48
160
  *
49
- * @param {string} errorMsg - The error message to check
50
- * @returns {boolean} True if the error is relaunchable
161
+ * @param errorMsg - The error message to check
162
+ * @returns True if the error is relaunchable (IncompleteEvidence or GateFailure)
51
163
  */
52
164
  function isRelaunchableError(errorMsg) {
53
165
  if (!errorMsg || typeof errorMsg !== 'string') {
54
166
  return false;
55
167
  }
56
- // Match the exact error message from handoff.js when final checkpoint has no evidence rows
57
- return errorMsg.includes('has a "## Goal Check" section but no evidence rows') &&
58
- errorMsg.includes('A goal-check table with real evidence is required before handoff');
168
+ const { failureClass } = classifyError(errorMsg);
169
+ // IncompleteEvidence and GateFailure are the only classes that were relaunchable under the old logic
170
+ return failureClass === FailureClass.IncompleteEvidence
171
+ || failureClass === FailureClass.GateFailure;
59
172
  }
60
173
  /**
61
174
  * Build a relaunch prompt for an agent to fix a relaunchable error.
@@ -65,10 +178,10 @@ function isRelaunchableError(errorMsg) {
65
178
  * @param {string} worktree - Path to the mission worktree
66
179
  * @returns {string} The relaunch prompt
67
180
  */
68
- function buildRelaunchPrompt(errorMsg, slug, worktree) {
181
+ function buildRelaunchPrompt(errorMsg, slug, worktree, gateOutput) {
69
182
  const year = missionUtils.getMissionYear(slug, worktree);
70
183
  const missionDir = missionUtils.findMissionDir(slug, worktree) || missionUtils.missionDirForSlug(worktree, slug);
71
- return `Automated handoff failed for mission ${slug} with a repairable error: ${errorMsg}
184
+ let prompt = `Automated handoff failed for mission ${slug} with a repairable error: ${errorMsg}
72
185
 
73
186
  ` +
74
187
  `Please fix the final checkpoint document in ${missionDir} by adding a Goal Check table ` +
@@ -103,6 +216,16 @@ function buildRelaunchPrompt(errorMsg, slug, worktree) {
103
216
 
104
217
  ` +
105
218
  `Do NOT add placeholder or generic evidence. Each row must cite real, verifiable artifacts.`;
219
+ // Append captured gate output if available (task-1387)
220
+ if (gateOutput && (gateOutput.stdout || gateOutput.stderr)) {
221
+ const totalOutput = (gateOutput.stdout || '') + (gateOutput.stderr || '');
222
+ // Truncate if total output exceeds 16000 chars; keep last 8000 chars
223
+ const truncated = totalOutput.length > 16000
224
+ ? `[truncated — total ${totalOutput.length} chars, showing last 8000]\n` + totalOutput.slice(-8000)
225
+ : totalOutput;
226
+ prompt += `\n\n--- Captured Gate Output ---\n${truncated}`;
227
+ }
228
+ return prompt;
106
229
  }
107
230
  /**
108
231
  * Attempt to repair a failed automated handoff by auto-committing mission
@@ -129,23 +252,17 @@ async function repairHandoff(slug, worktree, errorMsg, options = {}) {
129
252
  const cleanPath = (pathPart.startsWith('"') && pathPart.endsWith('"')) ? pathPart.slice(1, -1) : pathPart;
130
253
  return { xy, file: cleanPath };
131
254
  }
132
- // 0. Check if error is repairable
133
- const isDirtyError = errorMsg && (errorMsg.includes('is modified but uncommitted') ||
134
- errorMsg.includes('Commit the mission contract before handoff') ||
135
- errorMsg.includes('Commit the implementation evidence before handoff'));
136
- const isBehind = errorMsg && (errorMsg.includes('Updates were rejected') ||
137
- errorMsg.includes('fetch first') ||
138
- errorMsg.includes('non-fast-forward') ||
139
- errorMsg.includes('behind its remote') ||
140
- // Ensure we don't match generic git push failed unless it has non-fast-forward hints
141
- (errorMsg.includes('git push failed') && (errorMsg.includes('rejected') ||
142
- errorMsg.includes('remote contains work'))));
143
- if (!isDirtyError && !isBehind) {
255
+ // 0. Check if error is repairable via classifyError
256
+ const classification = classifyError(errorMsg);
257
+ const isGitBlocker = classification.failureClass === FailureClass.GitBlockers;
258
+ // Derive isBehind from classification result (avoids duplicating classifyError's behind-branch patterns)
259
+ const isBehind = classification.reason === 'behind';
260
+ if (!isGitBlocker) {
144
261
  log(`Handoff error is not automatically repairable: ${errorMsg}`);
145
262
  return { repaired: false, blocker: null };
146
263
  }
147
264
  // 1. Auto-commit mission artifacts if uncommitted
148
- if (isDirtyError || isBehind) {
265
+ if (isGitBlocker) {
149
266
  const statusResult = gitFn(['-C', rootDir, 'status', '--porcelain']);
150
267
  if (statusResult.status === 0 && statusResult.stdout) {
151
268
  const dirtyLines = statusResult.stdout.split('\n')
@@ -236,6 +353,10 @@ async function repairHandoff(slug, worktree, errorMsg, options = {}) {
236
353
  }
237
354
  repairHandoff.isRelaunchableError = isRelaunchableError;
238
355
  repairHandoff.buildRelaunchPrompt = buildRelaunchPrompt;
356
+ repairHandoff.classifyError = classifyError;
357
+ repairHandoff.getDispatchAction = getDispatchAction;
358
+ repairHandoff.FailureClass = FailureClass;
359
+ repairHandoff.DispatchAction = DispatchAction;
239
360
  exports.default = repairHandoff;
240
361
  if (typeof module !== 'undefined') {
241
362
  module.exports = repairHandoff;