@magnusekdahl/parallix 1.3.2 → 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 (50) 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-telemetry.js +122 -23
  6. package/lib/agents/mistral-telemetry.ts +141 -26
  7. package/lib/agents/mistral.js +128 -14
  8. package/lib/agents/mistral.ts +145 -5
  9. package/lib/commands/active.js +69 -39
  10. package/lib/commands/active.ts +97 -60
  11. package/lib/commands/config.ts +3 -3
  12. package/lib/commands/coverage-gate.ts +1 -1
  13. package/lib/commands/draft.js +1 -1
  14. package/lib/commands/draft.ts +1 -1
  15. package/lib/commands/handoff.js +13 -8
  16. package/lib/commands/handoff.ts +24 -18
  17. package/lib/commands/rebase.js +1 -1
  18. package/lib/commands/rebase.ts +2 -2
  19. package/lib/commands/repair-handoff.js +141 -20
  20. package/lib/commands/repair-handoff.ts +185 -45
  21. package/lib/commands/resolve-conflict.js +1 -1
  22. package/lib/commands/resolve-conflict.ts +2 -2
  23. package/lib/commands/stats-backfill.ts +10 -10
  24. package/lib/commands/stats.js +38 -11
  25. package/lib/commands/stats.ts +40 -96
  26. package/lib/core/fmt.ts +2 -2
  27. package/lib/core/git.ts +2 -2
  28. package/lib/core/gitignore.ts +2 -2
  29. package/lib/core/mission-utils.js +2 -2
  30. package/lib/core/mission-utils.ts +2 -2
  31. package/lib/core/persistent-data-migration.ts +2 -2
  32. package/lib/core/spawn-tee.ts +1 -1
  33. package/lib/core/state-map.ts +2 -2
  34. package/lib/core/storage.ts +1 -1
  35. package/lib/core/verification.ts +1 -1
  36. package/lib/review/rebase.ts +12 -12
  37. package/lib/review/review-artifacts.ts +35 -35
  38. package/lib/review/review-commands.ts +40 -40
  39. package/lib/review/review-events.ts +12 -12
  40. package/lib/review/review-loop.js +236 -7
  41. package/lib/review/review-loop.ts +338 -22
  42. package/lib/review/review-polling.ts +6 -6
  43. package/lib/review/review-prompts.js +8 -4
  44. package/lib/review/review-prompts.ts +12 -8
  45. package/lib/review/review-state.js +1 -1
  46. package/lib/review/review-state.ts +2 -2
  47. package/package.json +3 -2
  48. package/prompts/review-verbose.md +1 -1
  49. package/prompts/review.md +1 -1
  50. package/px.js +8 -4
@@ -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;
@@ -3,19 +3,155 @@ import * as missionUtils from '../core/mission-utils.js';
3
3
  import rebase from './rebase.js';
4
4
  import * as fmt from '../core/fmt.js';
5
5
 
6
+ // ── FailureClass: 8 classes from ADR 0048 ────────────────────────────────────
7
+ const FailureClass = {
8
+ UnverifiableClaims: 'UnverifiableClaims',
9
+ MalformedGates: 'MalformedGates',
10
+ MissingArtifacts: 'MissingArtifacts',
11
+ IncompleteEvidence: 'IncompleteEvidence',
12
+ GitBlockers: 'GitBlockers',
13
+ GateFailure: 'GateFailure',
14
+ InfraBlocker: 'InfraBlocker',
15
+ StateMachineViolation: 'StateMachineViolation',
16
+ } as const;
17
+
18
+ type FailureClassType = (typeof FailureClass)[keyof typeof FailureClass];
19
+
20
+ // ── DispatchAction: 3 actions from ADR 0048 ──────────────────────────────────
21
+ const DispatchAction = {
22
+ AutoRepair: 'AutoRepair',
23
+ AutoSendBack: 'AutoSendBack',
24
+ HumanOnly: 'HumanOnly',
25
+ } as const;
26
+
27
+ type DispatchActionType = (typeof DispatchAction)[keyof typeof DispatchAction];
28
+
29
+ // ── Dispatch table: maps each failure class to its prescribed action (ADR 0048) ─
30
+ const DISPATCH_TABLE: Record<FailureClassType, DispatchActionType> = {
31
+ [FailureClass.UnverifiableClaims]: DispatchAction.AutoSendBack,
32
+ [FailureClass.MalformedGates]: DispatchAction.AutoRepair,
33
+ [FailureClass.MissingArtifacts]: DispatchAction.AutoSendBack,
34
+ [FailureClass.IncompleteEvidence]: DispatchAction.AutoSendBack,
35
+ [FailureClass.GitBlockers]: DispatchAction.AutoRepair,
36
+ [FailureClass.GateFailure]: DispatchAction.AutoSendBack,
37
+ [FailureClass.InfraBlocker]: DispatchAction.HumanOnly,
38
+ [FailureClass.StateMachineViolation]: DispatchAction.HumanOnly,
39
+ };
40
+
41
+ /**
42
+ * Look up the dispatch action for a given failure class.
43
+ *
44
+ * @param failureClass - One of the 8 failure class values from ADR 0048
45
+ * @returns The prescribed dispatch action, or null if unknown
46
+ */
47
+ export function getDispatchAction(failureClass: FailureClassType): DispatchActionType | null {
48
+ return DISPATCH_TABLE[failureClass] ?? null;
49
+ }
50
+
51
+ /**
52
+ * Sub-reason for GitBlockers classification: distinguishes dirty-artifact from behind-branch errors.
53
+ * Used by repairHandoff() to decide whether to auto-rebase (behind) vs auto-commit only (dirty).
54
+ */
55
+ type GitBlockerReason = 'dirty' | 'behind' | 'other';
56
+
57
+ /**
58
+ * Classify an error message into one of the 8 failure classes from ADR 0048
59
+ * and return the associated dispatch action.
60
+ *
61
+ * Patterns are checked in order of specificity to avoid collisions.
62
+ * Returns a `reason` field for GitBlockers to distinguish dirty-artifact from behind-branch errors,
63
+ * allowing callers to derive repair-strategy flags without duplicating pattern matching.
64
+ *
65
+ * @param errorMsg - The error message to classify
66
+ * @returns Object with failureClass, dispatchAction, and (for GitBlockers) reason
67
+ */
68
+ export function classifyError(errorMsg: string): { failureClass: FailureClassType; dispatchAction: DispatchActionType; reason?: GitBlockerReason } {
69
+ if (!errorMsg || typeof errorMsg !== 'string') {
70
+ return { failureClass: FailureClass.InfraBlocker, dispatchAction: DispatchAction.HumanOnly };
71
+ }
72
+
73
+ // 1. IncompleteEvidence: goal-check table missing evidence rows (most specific — checked before generic gate patterns)
74
+ if (errorMsg.includes('has a "## Goal Check" section but no evidence rows') &&
75
+ errorMsg.includes('A goal-check table with real evidence is required before handoff')) {
76
+ return { failureClass: FailureClass.IncompleteEvidence, dispatchAction: DispatchAction.AutoSendBack };
77
+ }
78
+
79
+ // 2. GitBlockers: dirty/uncommitted mission artifacts (mechanical git blocker — auto-repairable)
80
+ if (errorMsg.includes('is modified but uncommitted') ||
81
+ errorMsg.includes('Commit the mission contract before handoff') ||
82
+ errorMsg.includes('Commit the implementation evidence before handoff')) {
83
+ return { failureClass: FailureClass.GitBlockers, dispatchAction: DispatchAction.AutoRepair, reason: 'dirty' };
84
+ }
85
+
86
+ // 3. GitBlockers: branch behind primary / push rejected (mechanical git blocker — auto-repairable via rebase)
87
+ if (errorMsg.includes('Updates were rejected') ||
88
+ errorMsg.includes('fetch first') ||
89
+ errorMsg.includes('non-fast-forward') ||
90
+ errorMsg.includes('behind its remote') ||
91
+ (errorMsg.includes('git push failed') && (
92
+ errorMsg.includes('rejected') ||
93
+ errorMsg.includes('remote contains work')
94
+ ))) {
95
+ return { failureClass: FailureClass.GitBlockers, dispatchAction: DispatchAction.AutoRepair, reason: 'behind' };
96
+ }
97
+
98
+ // 4. GateFailure: verification gate failed
99
+ if (/verification gate failed/i.test(errorMsg)) {
100
+ return { failureClass: FailureClass.GateFailure, dispatchAction: DispatchAction.AutoSendBack };
101
+ }
102
+
103
+ // 5. GateFailure: declared gate failed
104
+ if (/\bdeclared gate\b/i.test(errorMsg) && /\bfailed\b/i.test(errorMsg)) {
105
+ return { failureClass: FailureClass.GateFailure, dispatchAction: DispatchAction.AutoSendBack };
106
+ }
107
+
108
+ // 6. UnverifiableClaims: test claims that cannot be verified
109
+ if (/test(s?\s+)?passed/i.test(errorMsg) && /cannot\s+verify|unverifiable|proof\s+(not\s+)?found|stale\s+proof/i.test(errorMsg)) {
110
+ return { failureClass: FailureClass.UnverifiableClaims, dispatchAction: DispatchAction.AutoSendBack };
111
+ }
112
+
113
+ // 7. MalformedGates: malformed or non-runnable declared gates
114
+ 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) ||
115
+ (/gate/i.test(errorMsg) && /syntax\s+error|not\s+found|missing\s+(file|command)/i.test(errorMsg))) {
116
+ return { failureClass: FailureClass.MalformedGates, dispatchAction: DispatchAction.AutoRepair };
117
+ }
118
+
119
+ // 8. MissingArtifacts: mandatory mission artifacts missing
120
+ 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) ||
121
+ (/gatekeeper/i.test(errorMsg) && /missing\s+(artifact|file|document)/i.test(errorMsg))) {
122
+ return { failureClass: FailureClass.MissingArtifacts, dispatchAction: DispatchAction.AutoSendBack };
123
+ }
124
+
125
+ // 9. StateMachineViolation: task state machine violations
126
+ 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) ||
127
+ (/task\s+state/i.test(errorMsg) && /invalid|violation|incorrect/i.test(errorMsg))) {
128
+ return { failureClass: FailureClass.StateMachineViolation, dispatchAction: DispatchAction.HumanOnly };
129
+ }
130
+
131
+ // 10. InfraBlocker: forgejo/infrastructure blockers
132
+ 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)) {
133
+ return { failureClass: FailureClass.InfraBlocker, dispatchAction: DispatchAction.HumanOnly };
134
+ }
135
+
136
+ // Default: human-only for unrecognized errors
137
+ return { failureClass: FailureClass.InfraBlocker, dispatchAction: DispatchAction.HumanOnly };
138
+ }
139
+
6
140
  /**
7
141
  * Check if an error message indicates a relaunchable content error (missing/empty goal-check table).
142
+ * Delegates to classifyError for backward-compatible classification.
8
143
  *
9
- * @param {string} errorMsg - The error message to check
10
- * @returns {boolean} True if the error is relaunchable
144
+ * @param errorMsg - The error message to check
145
+ * @returns True if the error is relaunchable (IncompleteEvidence or GateFailure)
11
146
  */
12
- function isRelaunchableError(errorMsg: string) {
147
+ function isRelaunchableError(errorMsg: string): boolean {
13
148
  if (!errorMsg || typeof errorMsg !== 'string') {
14
149
  return false;
15
150
  }
16
- // Match the exact error message from handoff.js when final checkpoint has no evidence rows
17
- return errorMsg.includes('has a "## Goal Check" section but no evidence rows') &&
18
- errorMsg.includes('A goal-check table with real evidence is required before handoff');
151
+ const { failureClass } = classifyError(errorMsg);
152
+ // IncompleteEvidence and GateFailure are the only classes that were relaunchable under the old logic
153
+ return failureClass === FailureClass.IncompleteEvidence
154
+ || failureClass === FailureClass.GateFailure;
19
155
  }
20
156
 
21
157
  /**
@@ -26,45 +162,57 @@ function isRelaunchableError(errorMsg: string) {
26
162
  * @param {string} worktree - Path to the mission worktree
27
163
  * @returns {string} The relaunch prompt
28
164
  */
29
- function buildRelaunchPrompt(errorMsg: string, slug: string, worktree: string) {
165
+ function buildRelaunchPrompt(errorMsg: string, slug: string, worktree: string, gateOutput?: { stdout: string; stderr: string }) {
30
166
  const year = missionUtils.getMissionYear(slug, worktree);
31
167
  const missionDir = missionUtils.findMissionDir(slug, worktree) || missionUtils.missionDirForSlug(worktree, slug);
32
168
 
33
- return `Automated handoff failed for mission ${slug} with a repairable error: ${errorMsg}
169
+ let prompt = `Automated handoff failed for mission ${slug} with a repairable error: ${errorMsg}
34
170
 
35
171
  ` +
36
- `Please fix the final checkpoint document in ${missionDir} by adding a Goal Check table ` +
37
- `with real evidence rows (file:line references, test names). The Goal Check table must have ` +
38
- `a header row and at least one evidence row using pipe syntax (|).
172
+ `Please fix the final checkpoint document in ${missionDir} by adding a Goal Check table ` +
173
+ `with real evidence rows (file:line references, test names). The Goal Check table must have ` +
174
+ `a header row and at least one evidence row using pipe syntax (|).
39
175
 
40
176
  ` +
41
- `Steps:
177
+ `Steps:
42
178
  ` +
43
- `1. Open the final checkpoint document (CP-N.md) in ${missionDir}
179
+ `1. Open the final checkpoint document (CP-N.md) in ${missionDir}
44
180
  ` +
45
- `2. Add or update the "## Goal Check" section
181
+ `2. Add or update the "## Goal Check" section
46
182
  ` +
47
- `3. Create a markdown table with columns for: Goal Check description | Evidence | Status
183
+ `3. Create a markdown table with columns for: Goal Check description | Evidence | Status
48
184
  ` +
49
- `4. Add at least one evidence row with real file:line or test name references
185
+ `4. Add at least one evidence row with real file:line or test name references
50
186
  ` +
51
- `5. Commit the updated checkpoint with a descriptive commit message
187
+ `5. Commit the updated checkpoint with a descriptive commit message
52
188
  ` +
53
- `6. Re-run: node parallix review ${slug} --submit
189
+ `6. Re-run: node parallix review ${slug} --submit
54
190
 
55
191
  ` +
56
- `Example Goal Check table:
192
+ `Example Goal Check table:
57
193
  ` +
58
- `| Goal Check | Evidence | Status |
194
+ `| Goal Check | Evidence | Status |
59
195
  ` +
60
- `|---|---|---|
196
+ `|---|---|---|
61
197
  ` +
62
- `| Final checkpoint has Goal Check section | docs/missions/${year}/${slug}/CP-1.md:15 | PASS |
198
+ `| Final checkpoint has Goal Check section | docs/missions/${year}/${slug}/CP-1.md:15 | PASS |
63
199
  ` +
64
- `| Tests pass | npm test -- parallix/test/repair-handoff.test.js | PASS |
200
+ `| Tests pass | npm test -- parallix/test/repair-handoff.test.js | PASS |
65
201
 
66
202
  ` +
67
- `Do NOT add placeholder or generic evidence. Each row must cite real, verifiable artifacts.`;
203
+ `Do NOT add placeholder or generic evidence. Each row must cite real, verifiable artifacts.`;
204
+
205
+ // Append captured gate output if available (task-1387)
206
+ if (gateOutput && (gateOutput.stdout || gateOutput.stderr)) {
207
+ const totalOutput = (gateOutput.stdout || '') + (gateOutput.stderr || '');
208
+ // Truncate if total output exceeds 16000 chars; keep last 8000 chars
209
+ const truncated = totalOutput.length > 16000
210
+ ? `[truncated — total ${totalOutput.length} chars, showing last 8000]\n` + totalOutput.slice(-8000)
211
+ : totalOutput;
212
+ prompt += `\n\n--- Captured Gate Output ---\n${truncated}`;
213
+ }
214
+
215
+ return prompt;
68
216
  }
69
217
 
70
218
  /**
@@ -100,32 +248,19 @@ async function repairHandoff(slug: string, worktree: string, errorMsg: string, o
100
248
  return { xy, file: cleanPath };
101
249
  }
102
250
 
103
- // 0. Check if error is repairable
104
- const isDirtyError = errorMsg && (
105
- errorMsg.includes('is modified but uncommitted') ||
106
- errorMsg.includes('Commit the mission contract before handoff') ||
107
- errorMsg.includes('Commit the implementation evidence before handoff')
108
- );
109
-
110
- const isBehind = errorMsg && (
111
- errorMsg.includes('Updates were rejected') ||
112
- errorMsg.includes('fetch first') ||
113
- errorMsg.includes('non-fast-forward') ||
114
- errorMsg.includes('behind its remote') ||
115
- // Ensure we don't match generic git push failed unless it has non-fast-forward hints
116
- (errorMsg.includes('git push failed') && (
117
- errorMsg.includes('rejected') ||
118
- errorMsg.includes('remote contains work')
119
- ))
120
- );
121
-
122
- if (!isDirtyError && !isBehind) {
251
+ // 0. Check if error is repairable via classifyError
252
+ const classification = classifyError(errorMsg);
253
+ const isGitBlocker = classification.failureClass === FailureClass.GitBlockers;
254
+ // Derive isBehind from classification result (avoids duplicating classifyError's behind-branch patterns)
255
+ const isBehind = classification.reason === 'behind';
256
+
257
+ if (!isGitBlocker) {
123
258
  log(`Handoff error is not automatically repairable: ${errorMsg}`);
124
259
  return { repaired: false, blocker: null };
125
260
  }
126
261
 
127
262
  // 1. Auto-commit mission artifacts if uncommitted
128
- if (isDirtyError || isBehind) {
263
+ if (isGitBlocker) {
129
264
  const statusResult = gitFn(['-C', rootDir, 'status', '--porcelain']);
130
265
  if (statusResult.status === 0 && statusResult.stdout) {
131
266
  const dirtyLines = statusResult.stdout.split('\n')
@@ -224,9 +359,14 @@ async function repairHandoff(slug: string, worktree: string, errorMsg: string, o
224
359
 
225
360
  (repairHandoff as any).isRelaunchableError = isRelaunchableError;
226
361
  (repairHandoff as any).buildRelaunchPrompt = buildRelaunchPrompt;
362
+ (repairHandoff as any).classifyError = classifyError;
363
+ (repairHandoff as any).getDispatchAction = getDispatchAction;
364
+ (repairHandoff as any).FailureClass = FailureClass;
365
+ (repairHandoff as any).DispatchAction = DispatchAction;
227
366
 
228
367
  export default repairHandoff;
229
368
  export { repairHandoff, isRelaunchableError, buildRelaunchPrompt };
369
+ export { FailureClass, DispatchAction };
230
370
 
231
371
  // CJS compat: ensure require() returns the function directly
232
372
  declare const module: { exports: any } | undefined;
@@ -80,7 +80,7 @@ function buildAgentResolutionPrompt({ slug, area, worktreePath, missionSpecificF
80
80
  ].join('\n');
81
81
  }
82
82
  /** @param {string[]} args @param {{resolveConflictsFn?: Function, startAgentFn?: Function, exitFn?: Function}} opts */
83
- async function resolveConflict(args, { resolveConflictsFn = integrate_js_1.default.resolveConflictsForMission, startAgentFn = agents_js_1.startAgent, exitFn = ((code) => process.exit(code)), } = {}) {
83
+ async function resolveConflict(args, { resolveConflictsFn = integrate_js_1.default.resolveConflictsForMission, startAgentFn = agents_js_1.startAgent, exitFn = ((_code) => process.exit(_code)), } = {}) {
84
84
  const explicitSlug = args[0];
85
85
  const slug = (0, mission_utils_js_1.inferSlug)(explicitSlug);
86
86
  if (!slug) {
@@ -47,8 +47,8 @@ function buildAgentResolutionPrompt({ slug, area, worktreePath, missionSpecificF
47
47
  async function resolveConflict(args: string[], {
48
48
  resolveConflictsFn = (integrate as any).resolveConflictsForMission,
49
49
  startAgentFn = startAgent,
50
- exitFn = ((code: number) => process.exit(code)) as (code: number) => void,
51
- }: {resolveConflictsFn?: Function, startAgentFn?: Function, exitFn?: (code: number) => void} = {}) {
50
+ exitFn = ((_code: number) => process.exit(_code)) as (_code: number) => void,
51
+ }: {resolveConflictsFn?: Function, startAgentFn?: Function, exitFn?: (_code: number) => void} = {}) {
52
52
  const explicitSlug = args[0];
53
53
  const slug = inferSlug(explicitSlug);
54
54
  if (!slug) {