@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
@@ -1,4 +1,14 @@
1
1
  import { spawnAndTee } from '../core/spawn-tee.js';
2
+ import { parseMistralMeta, getMistralProviderModel, DEFAULT_MISTRAL_LOG_DIR } from './mistral-telemetry.js';
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+
6
+ /**
7
+ * Maximum acceptable age (in minutes) for a mistral session's start_time
8
+ * relative to the invocation start. Sessions older than this window are
9
+ * rejected as potentially misattributed across concurrent missions.
10
+ */
11
+ const MAX_SESSION_AGE_MINUTES = 120;
2
12
 
3
13
  interface MistralInvocationOptions {
4
14
  prompt: string;
@@ -22,10 +32,130 @@ interface StartMistralAgentOptions extends MistralInvocationOptions {
22
32
  // Current session ID format in meta.json: UUID like "a3dd3d4d-f97d-d57d-4942-a1f694e3a922"
23
33
  // Directory naming uses first 8 chars: session_20260521_162703_a3dd3d4d
24
34
  // No stdout marker detected in testing, so we leave this as null.
25
- // Telemetry: mistral/vibe does not expose token-usage data. See mistral-telemetry.js
26
- // for the honest-zero stub. Stats hooks in active.js and review-loop.js call
27
- // recordStageStats which defaults to '0' for tokens when telemetry is null.
35
+ // Telemetry: mistral/vibe writes structured token-usage data to meta.json files
36
+ // in ~/.vibe/logs/session/. This module's processResult function scans session
37
+ // directories and correlates by start_time window to prevent cross-mission
38
+ // telemetry misattribution. The mapped telemetry is consumed by
39
+ // telemetryToStatsFields in lib/commands/stats.ts.
40
+
41
+
42
+ interface ProcessedResult {
43
+ sessionId: string | null;
44
+ telemetry: {
45
+ provider: string;
46
+ model: string;
47
+ inputTokens: number;
48
+ outputTokens: number;
49
+ cachedTokens: number;
50
+ totalTokens: number;
51
+ toolCalls: number;
52
+ usagePercent: null;
53
+ cost_usd: number;
54
+ } | null;
55
+ [key: string]: unknown;
56
+ }
57
+
58
+ function processResult(result: any, basePath?: string, invocationStart?: string): ProcessedResult {
59
+ if (!result || typeof result !== 'object') {
60
+ return { sessionId: null, telemetry: null };
61
+ }
62
+
63
+ const scanDir = basePath || DEFAULT_MISTRAL_LOG_DIR;
64
+
65
+ // Determine the invocation window for session correlation.
66
+ // When invocationStart is provided, only consider sessions whose
67
+ // start_time falls within MAX_SESSION_AGE_MINUTES of the invocation.
68
+ // This prevents cross-mission telemetry misattribution when multiple
69
+ // mistral phases run concurrently against a shared session directory.
70
+ let invokeTime = NaN;
71
+ let invokeWindow: { start: number; end: number } | null = null;
72
+ if (invocationStart) {
73
+ invokeTime = Date.parse(invocationStart);
74
+ if (!Number.isNaN(invokeTime)) {
75
+ const deltaMs = MAX_SESSION_AGE_MINUTES * 60000;
76
+ invokeWindow = { start: invokeTime - deltaMs, end: invokeTime + deltaMs };
77
+ }
78
+ }
79
+
80
+ // Scan session directories chronologically (sorted by basename).
81
+ // For each session, check if its start_time falls within the invocation
82
+ // window, then pick the session closest to the invocation start time.
83
+ // This replaces the previous approach of calling extractMistralTelemetry
84
+ // which always returned the globally newest session regardless of which
85
+ // invocation it belonged to.
86
+ let bestTelemetry: ReturnType<typeof parseMistralMeta> | null = null;
87
+ let bestDistance = Infinity;
88
+
89
+ try {
90
+ const entries = fs.readdirSync(scanDir);
91
+ const dirs = entries.filter((d: string) => d.startsWith('session_')).sort();
92
+
93
+ for (const dir of dirs) {
94
+ const metaPath = path.join(scanDir, dir, 'meta.json');
95
+ if (!fs.existsSync(metaPath)) { continue; }
96
+
97
+ let content: string;
98
+ try { content = fs.readFileSync(metaPath, 'utf8'); } catch (_) { continue; }
28
99
 
100
+ let meta: Record<string, unknown>;
101
+ try { meta = JSON.parse(content); } catch (_) { continue; }
102
+
103
+ const startTime = meta.start_time;
104
+ if (typeof startTime !== 'string') { continue; }
105
+ const sessionTime = Date.parse(startTime);
106
+ if (Number.isNaN(sessionTime)) { continue; }
107
+
108
+ // Check invocation window if applicable
109
+ if (invokeWindow && (sessionTime < invokeWindow.start || sessionTime > invokeWindow.end)) {
110
+ continue;
111
+ }
112
+
113
+ const telemetry = parseMistralMeta(meta);
114
+ if (!telemetry) { continue; }
115
+
116
+ // When no invocationStart is provided, pick the first valid session.
117
+ // When invocationStart is provided, pick the session closest in time.
118
+ if (Number.isNaN(invokeTime)) {
119
+ bestTelemetry = telemetry;
120
+ break;
121
+ }
122
+ const distance = Math.abs(sessionTime - invokeTime);
123
+ if (distance < bestDistance) {
124
+ bestTelemetry = telemetry;
125
+ bestDistance = distance;
126
+ }
127
+ }
128
+ } catch (_) {
129
+ // Directory unreadable — fall through to null telemetry
130
+ }
131
+
132
+ if (!bestTelemetry) {
133
+ return { ...result, sessionId: result.sessionId || null, telemetry: null } as ProcessedResult;
134
+ }
135
+
136
+ const allToolCalls =
137
+ (bestTelemetry.toolCallsAgreed || 0) +
138
+ (bestTelemetry.toolCallsRejected || 0) +
139
+ (bestTelemetry.toolCallsFailed || 0) +
140
+ (bestTelemetry.toolCallsSucceeded || 0);
141
+
142
+ const pm = getMistralProviderModel();
143
+ const model = bestTelemetry.contextTokens > 0 || bestTelemetry.inputTokens > 0 ? 'mistral' : pm.model;
144
+
145
+ result.telemetry = {
146
+ provider: pm.provider,
147
+ model,
148
+ inputTokens: bestTelemetry.inputTokens,
149
+ outputTokens: bestTelemetry.outputTokens,
150
+ cachedTokens: bestTelemetry.contextTokens,
151
+ totalTokens: bestTelemetry.totalTokens,
152
+ toolCalls: allToolCalls,
153
+ usagePercent: null,
154
+ cost_usd: bestTelemetry.sessionCost,
155
+ };
156
+
157
+ return { ...result, sessionId: result.sessionId || null } as ProcessedResult;
158
+ }
29
159
 
30
160
  function extractMistralSessionId(stdout: string) {
31
161
  void stdout;
@@ -41,7 +171,14 @@ function resolveMistralCommand() {
41
171
  function buildMistralInvocation({ prompt, worktree, env, resume, sessionId, model = null }: MistralInvocationOptions) {
42
172
  void resume;
43
173
  void sessionId;
44
- const args = ['--prompt', prompt, '--trust', '--output', 'text'];
174
+ // --trust only bypasses the working-directory trust prompt; tool-call
175
+ // approval is a separate gate that vibe --help documents as controlled by
176
+ // --auto-approve/--yolo. Without it, any prompt that needs a tool call
177
+ // blocks on interactive approval outside a TTY and fails with a generic
178
+ // error, which then gets misread as a real launch failure and persisted
179
+ // to the blocklist (claude/opencode/codex all pass their own equivalent
180
+ // non-interactive bypass already).
181
+ const args = ['--prompt', prompt, '--trust', '--yolo', '--output', 'text'];
45
182
 
46
183
  // Vibe programmatic mode does not support --resume flag in the same way
47
184
  // as other agents. The --resume flag exists but requires interactive selection
@@ -66,11 +203,12 @@ function buildMistralInvocation({ prompt, worktree, env, resume, sessionId, mode
66
203
 
67
204
  function startMistralAgent({ prompt, worktree, env, resume = false, sessionId = null, model = null, teeOptions = {} }: StartMistralAgentOptions) {
68
205
  const invocation = buildMistralInvocation({ prompt, worktree, env, resume, sessionId, model });
206
+ const invocationStart = new Date().toISOString();
69
207
  const resultPromise = spawnAndTee(invocation.command, invocation.args, { ...invocation.options, ...teeOptions } as any).then((result: any) => {
70
208
  if (result && result.stdout) {
71
209
  result.sessionId = extractMistralSessionId(result.stdout);
72
210
  }
73
- return result;
211
+ return processResult(result, undefined, invocationStart);
74
212
  });
75
213
 
76
214
  return { invocation, resultPromise };
@@ -79,6 +217,8 @@ function startMistralAgent({ prompt, worktree, env, resume = false, sessionId =
79
217
  export {
80
218
  buildMistralInvocation,
81
219
  extractMistralSessionId,
220
+ getMistralProviderModel,
221
+ processResult,
82
222
  resolveMistralCommand,
83
223
  startMistralAgent
84
224
  };
@@ -297,14 +297,14 @@ function applyExecuteFallback(opts) {
297
297
  * @returns {Promise<{relaunched: boolean, error?: string}>} Result of relaunch attempt
298
298
  */
299
299
  /**
300
- * @param {string} slug
301
- * @param {string} worktree
302
- * @param {string} errorMsg
303
- * @param {string} agent
304
- * @param {{isRelaunchableErrorFn?: Function, buildRelaunchPromptFn?: Function, workflowLauncherStatusFn?: Function, startAgentFn?: Function, log?: Function, error?: Function}} [options]
305
- */
300
+ * @param {string} slug
301
+ * @param {string} worktree
302
+ * @param {string} errorMsg
303
+ * @param {string} agent
304
+ * @param {{isRelaunchableErrorFn?: Function, buildRelaunchPromptFn?: Function, workflowLauncherStatusFn?: Function, startAgentFn?: Function, log?: Function, error?: Function, gateOutput?: {stdout: string, stderr: string}}} [options]
305
+ */
306
306
  async function attemptAgentRelaunch(slug, worktree, errorMsg, agent, options = {}) {
307
- const { isRelaunchableErrorFn = repairHandoff.isRelaunchableError, buildRelaunchPromptFn = repairHandoff.buildRelaunchPrompt, workflowLauncherStatusFn = agents.workflowLauncherStatus, startAgentFn = agents.startAgent, log = fmt.log.plain, error = fmt.log.plainError } = options;
307
+ const { isRelaunchableErrorFn = repairHandoff.isRelaunchableError, buildRelaunchPromptFn = repairHandoff.buildRelaunchPrompt, workflowLauncherStatusFn = agents.workflowLauncherStatus, startAgentFn = agents.startAgent, log = fmt.log.plain, error = fmt.log.plainError, gateOutput } = options;
308
308
  // Check if this is a relaunchable error
309
309
  if (!isRelaunchableErrorFn(errorMsg)) {
310
310
  log(`Error is not relaunchable: ${errorMsg}`);
@@ -316,8 +316,8 @@ async function attemptAgentRelaunch(slug, worktree, errorMsg, agent, options = {
316
316
  error(`Agent ${fmt.agent(agent)} is not available for relaunch: ${status.detail || status.reason || 'unknown'}`);
317
317
  return { relaunched: false, error: `Agent ${agent} launcher is not available` };
318
318
  }
319
- // Build the relaunch prompt
320
- const prompt = buildRelaunchPromptFn(errorMsg, slug, worktree);
319
+ // Build the relaunch prompt, passing captured gate output if available (task-1387)
320
+ const prompt = buildRelaunchPromptFn(errorMsg, slug, worktree, gateOutput);
321
321
  log(`Attempting to relaunch ${fmt.agent(agent)} to fix repairable handoff error...`);
322
322
  // startAgent handles resume flags internally for resume-capable agents (codex, claude, gemini, custom)
323
323
  try {
@@ -394,7 +394,7 @@ function validateCheckpointsBeforeHandoff(slug, worktree, options = {}) {
394
394
  * @param {{taskFile?: string | null, validateCheckpointsBeforeHandoffFn?: Function, performHandoff?: Function, startReviewLoop?: Function, repairHandoffFn?: {isRelaunchableError: Function, buildRelaunchPrompt: Function}, attemptAgentRelaunchFn?: Function, log?: Function, error?: Function}} [options]
395
395
  */
396
396
  async function runHandoffAndReview(slug, worktree, agent, options = {}) {
397
- const { taskFile = null, validateCheckpointsBeforeHandoffFn = validateCheckpointsBeforeHandoff, performHandoff: _performHandoff = (/** @type{string} */ s, /** @type{object} */ o) => handoff.performHandoff(s, o), startReviewLoop: _startReviewLoop = (/** @type{string} */ s, /** @type{object} */ o) => review.startReviewLoop(s, o), repairHandoffFn = /** @type{(s: string, w: string, e: string, o: object) => Promise<{repaired: boolean, blocker?: string}>} */ (repairHandoff), attemptAgentRelaunchFn = attemptAgentRelaunch, log = fmt.log.plain, error = fmt.log.plainError } = options;
397
+ const { taskFile = null, validateCheckpointsBeforeHandoffFn = validateCheckpointsBeforeHandoff, performHandoff: _performHandoff = (/** @type{string} */ s, /** @type{object} */ o) => handoff.performHandoff(s, o), startReviewLoop: _startReviewLoop = (/** @type{string} */ s, /** @type{object} */ o) => review.startReviewLoop(s, o), repairHandoffFn = /** @type{(s: string, w: string, e: string, o: object) => Promise<{repaired: boolean, blocker?: string}>} */ (repairHandoff.default), attemptAgentRelaunchFn = attemptAgentRelaunch, log = fmt.log.plain, error = fmt.log.plainError } = options;
398
398
  // Pre-handoff checkpoint enforcement: validate checkpoints exist before calling performHandoff()
399
399
  // This catches missing checkpoints immediately after the execute agent exits,
400
400
  // before the repair flow runs, and provides an explicit instruction to create them.
@@ -407,38 +407,68 @@ async function runHandoffAndReview(slug, worktree, agent, options = {}) {
407
407
  }
408
408
  let handoffResult = await _performHandoff(slug, { forgejoUser: agent, worktree });
409
409
  if (!handoffResult.ok) {
410
- // Attempt single repair for routine hygiene issues (dirty artifacts, rebase needed)
411
- log(`\nAutomated handoff failed: ${handoffResult.error}`);
412
- log(`Attempting post-execute repair...`);
413
- const { repaired, blocker } = await /** @type{Function} */ (repairHandoffFn)(slug, worktree, /** @type{string} */ (handoffResult.error), { taskFile, log, error });
414
- if (repaired) {
415
- log(`Repair successful. Retrying automated handoff...`);
416
- handoffResult = await _performHandoff(slug, { forgejoUser: agent, worktree, force: true });
417
- }
418
- else if (blocker) {
419
- // If repair failed but provided a specific blocker (e.g. rebase failure),
420
- // report that blocker as the final error instead of the original handoff error.
421
- handoffResult.error = blocker;
410
+ // Check for genuine gate failure (task-1387): automatic relaunch with captured output
411
+ const isGenuineGateFailure = handoffResult.gateOutput ||
412
+ (handoffResult.error && (/verification gate failed/i.test(handoffResult.error) ||
413
+ (/\bdeclared gate\b/i.test(handoffResult.error) && /\bfailed\b/i.test(handoffResult.error))));
414
+ if (isGenuineGateFailure) {
415
+ // Automatic relaunch with captured gate output, bounded to max 2 attempts
416
+ let relaunchCount = 0;
417
+ const maxRelaunches = 2;
418
+ while (relaunchCount < maxRelaunches) {
419
+ relaunchCount++;
420
+ log(`\nGenuine gate failure detected. Relaunch attempt ${relaunchCount}/${maxRelaunches}...`);
421
+ const { relaunched, error: relaunchError } = await attemptAgentRelaunchFn(slug, worktree, /** @type{string} */ (handoffResult.error), agent, { log, error, gateOutput: handoffResult.gateOutput });
422
+ if (relaunched) {
423
+ handoffResult = await _performHandoff(slug, { forgejoUser: agent, worktree, force: true });
424
+ if (handoffResult.ok) {
425
+ break; // Success — proceed to review loop
426
+ }
427
+ // Handoff still failed; continue loop for another relaunch attempt
428
+ }
429
+ else {
430
+ log(`Agent relaunch failed: ${relaunchError || 'unknown error'}`);
431
+ break; // Relaunch itself failed; stop
432
+ }
433
+ }
434
+ if (!handoffResult.ok && relaunchCount >= maxRelaunches) {
435
+ handoffResult.error = `Gate failure persisting after ${maxRelaunches} relaunch attempts. Manual intervention required.`;
436
+ }
422
437
  }
423
- else if (!repaired && repairHandoff.isRelaunchableError(handoffResult.error)) {
424
- // Attempt agent relaunch for repairable content errors (missing goal-check table)
425
- log(`Content error detected. Attempting agent relaunch to fix...`);
426
- const { relaunched, error: relaunchError } = await attemptAgentRelaunchFn(slug, worktree, /** @type{string} */ (handoffResult.error), agent, { log, error });
427
- if (relaunched) {
428
- // Agent was relaunched successfully; re-invoke performHandoff to verify
429
- // the handoff-to-review transition actually completed, matching the
430
- // contract of the repair-success path above.
431
- log(`Agent relaunched. It will fix the checkpoint and retry handoff.`);
438
+ else {
439
+ // Original logic: attempt single repair for routine hygiene issues (dirty artifacts, rebase needed)
440
+ log(`\nAutomated handoff failed: ${handoffResult.error}`);
441
+ log(`Attempting post-execute repair...`);
442
+ const { repaired, blocker } = await /** @type{Function} */ (repairHandoffFn)(slug, worktree, /** @type{string} */ (handoffResult.error), { taskFile, log, error });
443
+ if (repaired) {
444
+ log(`Repair successful. Retrying automated handoff...`);
432
445
  handoffResult = await _performHandoff(slug, { forgejoUser: agent, worktree, force: true });
433
- if (!handoffResult.ok) {
434
- handoffResult.error = `Post-relaunch handoff failed: ${handoffResult.error || 'unknown'}`;
435
- }
436
- // Fall through to gatekeeper pushback / review loop / failure handling below.
437
446
  }
438
- else {
439
- // Relaunch failed or was not possible
440
- log(`Agent relaunch failed: ${relaunchError || 'unknown error'}`);
441
- // Fall through to manual handoff message
447
+ else if (blocker) {
448
+ // If repair failed but provided a specific blocker (e.g. rebase failure),
449
+ // report that blocker as the final error instead of the original handoff error.
450
+ handoffResult.error = blocker;
451
+ }
452
+ else if (!repaired && repairHandoff.isRelaunchableError(handoffResult.error)) {
453
+ // Attempt agent relaunch for repairable content errors (missing goal-check table)
454
+ log(`Content error detected. Attempting agent relaunch to fix...`);
455
+ const { relaunched, error: relaunchError } = await attemptAgentRelaunchFn(slug, worktree, /** @type{string} */ (handoffResult.error), agent, { log, error });
456
+ if (relaunched) {
457
+ // Agent was relaunched successfully; re-invoke performHandoff to verify
458
+ // the handoff-to-review transition actually completed, matching the
459
+ // contract of the repair-success path above.
460
+ log(`Agent relaunched. It will fix the checkpoint and retry handoff.`);
461
+ handoffResult = await _performHandoff(slug, { forgejoUser: agent, worktree, force: true });
462
+ if (!handoffResult.ok) {
463
+ handoffResult.error = `Post-relaunch handoff failed: ${handoffResult.error || 'unknown'}`;
464
+ }
465
+ // Fall through to gatekeeper pushback / review loop / failure handling below.
466
+ }
467
+ else {
468
+ // Relaunch failed or was not possible
469
+ log(`Agent relaunch failed: ${relaunchError || 'unknown error'}`);
470
+ // Fall through to manual handoff message
471
+ }
442
472
  }
443
473
  }
444
474
  }
@@ -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
  }