@magnusekdahl/parallix 1.2.1 → 1.3.1

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 (81) hide show
  1. package/index.js +190 -185
  2. package/lib/agents/agents.js +34 -4
  3. package/lib/agents/agents.ts +40 -5
  4. package/lib/commands/active.js +502 -579
  5. package/lib/commands/active.ts +665 -0
  6. package/lib/commands/checkpoint.js +98 -74
  7. package/lib/commands/checkpoint.ts +79 -0
  8. package/lib/commands/config.js +61 -31
  9. package/lib/commands/config.ts +47 -0
  10. package/lib/commands/coverage-gate.js +271 -246
  11. package/lib/commands/coverage-gate.ts +362 -0
  12. package/lib/commands/diff.js +140 -119
  13. package/lib/commands/diff.ts +125 -0
  14. package/lib/commands/draft.js +770 -923
  15. package/lib/commands/draft.ts +1021 -0
  16. package/lib/commands/handoff.js +588 -608
  17. package/lib/commands/handoff.ts +694 -0
  18. package/lib/commands/integrate.js +1331 -1468
  19. package/lib/commands/integrate.ts +1688 -0
  20. package/lib/commands/mission-start.js +279 -237
  21. package/lib/commands/mission-start.ts +263 -0
  22. package/lib/commands/rebase.js +606 -582
  23. package/lib/commands/rebase.ts +635 -0
  24. package/lib/commands/repair-handoff.js +181 -179
  25. package/lib/commands/repair-handoff.ts +228 -0
  26. package/lib/commands/resolve-conflict.js +121 -102
  27. package/lib/commands/resolve-conflict.ts +112 -0
  28. package/lib/commands/review.js +3 -4
  29. package/lib/commands/review.ts +14 -0
  30. package/lib/commands/setup-review.js +4 -5
  31. package/lib/commands/setup-review.ts +14 -0
  32. package/lib/commands/setup.js +3 -3
  33. package/lib/commands/setup.ts +2 -0
  34. package/lib/commands/stats-backfill.js +394 -359
  35. package/lib/commands/stats-backfill.ts +418 -0
  36. package/lib/commands/stats.js +1424 -1665
  37. package/lib/commands/stats.ts +2186 -0
  38. package/lib/commands/status.js +219 -200
  39. package/lib/commands/status.ts +219 -0
  40. package/lib/commands/verify.js +3 -1
  41. package/lib/commands/verify.ts +2 -0
  42. package/lib/core/nels.js +112 -127
  43. package/lib/core/nels.ts +189 -0
  44. package/lib/core/subagent-limit.js +15 -16
  45. package/lib/core/subagent-limit.ts +25 -0
  46. package/lib/index.js +142 -59
  47. package/lib/index.ts +134 -0
  48. package/lib/review/rebase.js +142 -145
  49. package/lib/review/rebase.ts +188 -0
  50. package/lib/review/review-adapter.js +99 -87
  51. package/lib/review/review-adapter.ts +198 -0
  52. package/lib/review/review-artifacts.js +486 -629
  53. package/lib/review/review-artifacts.ts +622 -0
  54. package/lib/review/review-commands.js +1220 -1373
  55. package/lib/review/review-commands.ts +1579 -0
  56. package/lib/review/review-events.js +544 -886
  57. package/lib/review/review-events.ts +954 -0
  58. package/lib/review/review-loop.js +945 -1020
  59. package/lib/review/review-loop.ts +1174 -0
  60. package/lib/review/review-polling.js +86 -115
  61. package/lib/review/review-polling.ts +194 -0
  62. package/lib/review/review-prompts.js +154 -189
  63. package/lib/review/review-prompts.ts +182 -0
  64. package/lib/review/review-state.js +246 -225
  65. package/lib/review/review-state.ts +353 -0
  66. package/lib/review/review.js +63 -84
  67. package/lib/review/review.ts +138 -0
  68. package/lib/tools/backlog.js +694 -680
  69. package/lib/tools/backlog.ts +835 -0
  70. package/lib/tools/forgejo.js +1270 -1405
  71. package/lib/tools/forgejo.ts +1793 -0
  72. package/lib/tools/gatekeeper.js +106 -96
  73. package/lib/tools/gatekeeper.ts +124 -0
  74. package/lib/tools/redgreen.js +200 -181
  75. package/lib/tools/redgreen.ts +221 -0
  76. package/lib/tools/sessions.js +58 -52
  77. package/lib/tools/sessions.ts +81 -0
  78. package/lib/tools/setup-review.js +902 -975
  79. package/lib/tools/setup-review.ts +1152 -0
  80. package/package.json +4 -4
  81. package/px.js +226 -208
@@ -1,33 +1,85 @@
1
+ "use strict";
1
2
  /**
2
3
  * Review Loop Module
3
4
  * Extracted from parallix/lib/review.js for task-1201
4
5
  * Handles autonomous review loop orchestration.
5
6
  */
6
-
7
- const fs = require('fs');
8
- const path = require('path');
9
- const fmt = require('../core/fmt');
10
- const { git, run } = require('../core/git');
11
- const { findMissionDir, resolveWorktree, missionBranchName } = require('../core/mission-utils');
12
- const { resolveTaskFile, getTaskImplementer, getTaskStatus, enforceTaskAssignee, transitionTask, reportTaskResolution } = require('../tools/backlog');
13
- const { toVirtual, transitionVirtual } = require('../core/state-map');
14
- const { getPrStatus, readToken, getLatestReviewForPr, getLatestDispositionForPr, providerAvailable, getComments, postComment, postReview, resolveReviewUser, isProviderEnabled } = require('./review-adapter');
15
- const { buildAutonomousReviewMatrix, formatMatrixSummary } = require('../core/runtime-matrix');
16
- const { buildReviewPrompt, buildActOnReviewPrompt, buildCompactReviewPrompt, buildCompactActOnReviewPrompt } = require('./review-prompts');
17
- const { ReviewState, readReviewState, writeReviewState, resetReviewState, VALID_PHASES } = require('./review-state');
18
-
19
- const { workflowLauncherStatus, startAgent, eligibleAgentsForStep, selectAgent } = require('../agents/agents');
20
- const { performHandoff } = require('../commands/handoff');
21
- const { commitSafeMissionArtifacts, rebaseBeforeReviewRound } = require('./rebase');
22
- const { resolveAgentModel } = require('../core/product-config');
23
-
24
- // Import from review-polling module
25
- const { delay, resolvePollIntervalMs, resolvePollTimeoutMs, formatElapsed, isPollTimeout, pollForReview, pollForDisposition } = require('./review-polling');
26
- // Import from review-artifacts module
27
- const { buildMetadataFooter, resolveArtifactDir, consumeReviewerArtifacts, consumeImplementerArtifacts } = require('./review-artifacts');
28
- const stats = require('../commands/stats');
29
- const { resolveStageTelemetry } = require('../agents/stage-telemetry');
30
-
7
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
8
+ if (k2 === undefined) k2 = k;
9
+ var desc = Object.getOwnPropertyDescriptor(m, k);
10
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
11
+ desc = { enumerable: true, get: function() { return m[k]; } };
12
+ }
13
+ Object.defineProperty(o, k2, desc);
14
+ }) : (function(o, m, k, k2) {
15
+ if (k2 === undefined) k2 = k;
16
+ o[k2] = m[k];
17
+ }));
18
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
19
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
20
+ }) : function(o, v) {
21
+ o["default"] = v;
22
+ });
23
+ var __importStar = (this && this.__importStar) || (function () {
24
+ var ownKeys = function(o) {
25
+ ownKeys = Object.getOwnPropertyNames || function (o) {
26
+ var ar = [];
27
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
28
+ return ar;
29
+ };
30
+ return ownKeys(o);
31
+ };
32
+ return function (mod) {
33
+ if (mod && mod.__esModule) return mod;
34
+ var result = {};
35
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
36
+ __setModuleDefault(result, mod);
37
+ return result;
38
+ };
39
+ })();
40
+ Object.defineProperty(exports, "__esModule", { value: true });
41
+ exports.rebaseBeforeReviewRound = exports.commitSafeMissionArtifacts = void 0;
42
+ exports.recordStageStatsSafe = recordStageStatsSafe;
43
+ exports.maybeUpdateGraphifyBeforeReview = maybeUpdateGraphifyBeforeReview;
44
+ exports.applyAgentFallback = applyAgentFallback;
45
+ exports.persistNormalizedPhaseRepair = persistNormalizedPhaseRepair;
46
+ exports.stageLaunchSinceMs = stageLaunchSinceMs;
47
+ exports.startReviewLoop = startReviewLoop;
48
+ const fs = __importStar(require("fs"));
49
+ const path = __importStar(require("path"));
50
+ const fmt = __importStar(require("../core/fmt.js"));
51
+ const git_js_1 = require("../core/git.js");
52
+ const mission_utils_js_1 = require("../core/mission-utils.js");
53
+ const backlog_js_1 = require("../tools/backlog.js");
54
+ const state_map_js_1 = require("../core/state-map.js");
55
+ const review_adapter_js_1 = require("./review-adapter.js");
56
+ const runtime_matrix_js_1 = require("../core/runtime-matrix.js");
57
+ const review_prompts_js_1 = require("./review-prompts.js");
58
+ const review_state_js_1 = require("./review-state.js");
59
+ const agents_js_1 = require("../agents/agents.js");
60
+ const rebase_js_1 = require("./rebase.js");
61
+ Object.defineProperty(exports, "commitSafeMissionArtifacts", { enumerable: true, get: function () { return rebase_js_1.commitSafeMissionArtifacts; } });
62
+ Object.defineProperty(exports, "rebaseBeforeReviewRound", { enumerable: true, get: function () { return rebase_js_1.rebaseBeforeReviewRound; } });
63
+ const product_config_js_1 = require("../core/product-config.js");
64
+ const review_polling_js_1 = require("./review-polling.js");
65
+ const review_artifacts_js_1 = require("./review-artifacts.js");
66
+ const stage_telemetry_js_1 = require("../agents/stage-telemetry.js");
67
+ /** Lazily loaded stats module — loaded on first use to avoid circular dependency. */
68
+ let _stats = null;
69
+ async function getStats() {
70
+ if (!_stats) {
71
+ _stats = await Promise.resolve().then(() => __importStar(require('../commands/stats.js')));
72
+ }
73
+ return _stats;
74
+ }
75
+ /** Lazily loaded handoff module. */
76
+ let _handoff = null;
77
+ async function getHandoff() {
78
+ if (!_handoff) {
79
+ _handoff = await Promise.resolve().then(() => __importStar(require('../commands/handoff.js')));
80
+ }
81
+ return _handoff;
82
+ }
31
83
  // Stage telemetry recording is best-effort: a failure must never break the
32
84
  // review loop. Token columns are populated only for the codex role (the C2 rule
33
85
  // guarantees at most one of implementer/reviewer is codex); other families
@@ -39,172 +91,129 @@ const { resolveStageTelemetry } = require('../agents/stage-telemetry');
39
91
  // one launch at a time, de-duped via review-state metadata, so Claude/custom rows
40
92
  // are cumulative too. A family switch creates a separate row instead of mixing
41
93
  // agents in one record.
42
- /**
43
- * @param {string} agentFamily
44
- * @param {{sessionId?: string, startedAt?: string, endedAt?: string, status?: number}} result
45
- * @returns {string}
46
- */
47
94
  function stageLaunchFingerprint(agentFamily, result) {
48
- return [
49
- String(agentFamily || '').trim().toLowerCase(),
50
- result && result.sessionId ? String(result.sessionId) : '',
51
- result && result.startedAt ? String(result.startedAt) : '',
52
- result && result.endedAt ? String(result.endedAt) : '',
53
- result && result.status !== undefined && result.status !== null ? String(result.status) : '',
54
- ].join('|');
95
+ return [
96
+ String(agentFamily || '').trim().toLowerCase(),
97
+ result && result.sessionId ? String(result.sessionId) : '',
98
+ result && result.startedAt ? String(result.startedAt) : '',
99
+ result && result.endedAt ? String(result.endedAt) : '',
100
+ result && result.status !== undefined && result.status !== null ? String(result.status) : '',
101
+ ].join('|');
55
102
  }
56
-
57
- /**
58
- * @param {Object|null} state
59
- * @param {{stage: string, agentFamily: string, result?: {[key: string]: any}, slug: string, worktree?: string, writeReviewStateFn?: Function}} opts
60
- * @returns {boolean}
61
- */
62
- function markStageLaunchRecorded(state, /** @type {{stage: string, agentFamily: string, result?: {[key: string]: any}, slug: string, worktree?: string, writeReviewStateFn?: Function}} */ opts) {
63
- const { stage, agentFamily, result, slug, worktree, writeReviewStateFn = writeReviewState } = opts || {};
64
- if (!state || !agentFamily) {return true;}
65
- const key = stageWindowKey(stage, agentFamily);
66
- const fingerprint = stageLaunchFingerprint(agentFamily, /** @type {{sessionId?: string, startedAt?: string, endedAt?: string, status?: number}} */ (result));
67
- /** @type {{[key: string]: any}} */
68
- const stateObj = /** @type {{[key: string]: any}} */ (state);
69
- const recordedStageLaunches = stateObj.metadata && typeof stateObj.metadata === 'object'
70
- ? (stateObj.metadata.recordedStageLaunches || {})
71
- : {};
72
- const recorded = Array.isArray(recordedStageLaunches[key]) ? recordedStageLaunches[key] : [];
73
- if (recorded.includes(fingerprint)) {
74
- return false;
75
- }
76
- if (!stateObj.metadata || typeof stateObj.metadata !== 'object') {
77
- stateObj.metadata = {};
78
- }
79
- stateObj.metadata.recordedStageLaunches = {
80
- ...(stateObj.metadata.recordedStageLaunches || {}),
81
- [key]: [...recorded, fingerprint].slice(-20),
82
- };
83
- writeReviewStateFn(slug, state, worktree);
84
- return true;
103
+ function markStageLaunchRecorded(state, opts) {
104
+ const { stage, agentFamily, result, slug, worktree, writeReviewStateFn = review_state_js_1.writeReviewState } = opts || {};
105
+ if (!state || !agentFamily) {
106
+ return true;
107
+ }
108
+ const key = stageWindowKey(stage, agentFamily);
109
+ const fingerprint = stageLaunchFingerprint(agentFamily, result || null);
110
+ const recordedStageLaunches = state.metadata && typeof state.metadata === 'object'
111
+ ? (state.metadata.recordedStageLaunches || {})
112
+ : {};
113
+ const recorded = Array.isArray(recordedStageLaunches[key]) ? recordedStageLaunches[key] : [];
114
+ if (recorded.includes(fingerprint)) {
115
+ return false;
116
+ }
117
+ if (!state.metadata || typeof state.metadata !== 'object') {
118
+ state.metadata = {};
119
+ }
120
+ state.metadata.recordedStageLaunches = {
121
+ ...(state.metadata.recordedStageLaunches || {}),
122
+ [key]: [...recorded, fingerprint].slice(-20),
123
+ };
124
+ writeReviewStateFn(slug, state, worktree || process.cwd());
125
+ return true;
85
126
  }
86
-
87
- /**
88
- * @param {'review'|'active'} kind
89
- * @param {{stage: string, slug: string, rootDir?: string, worktree?: string, implementer?: string, reviewer?: string, result?: {[key: string]: any}, sinceMs?: number, log?: Function, state?: {[key: string]: any}, writeReviewStateFn?: Function, model?: string|null}} opts
90
- * @returns {void}
91
- */
92
- function recordStageStatsSafe(kind, { stage, slug, rootDir, worktree, implementer, reviewer, result, sinceMs, log, state, writeReviewStateFn = writeReviewState, model = null }) {
93
- /** @type {number} */
94
- let durationMinutes = 0;
95
- if (result && result.startedAt && result.endedAt) {
96
- durationMinutes = (Date.parse(result.endedAt) - Date.parse(result.startedAt)) / 60000;
97
- }
98
- // Shared with the execute hook (commands/active.js) via stage-telemetry.js:
99
- // prefer the windowed Codex rollout, else the launcher-attached telemetry so
100
- // Claude active/review stages record real tokens instead of all-zeros
101
- // (task-1274), bounded to this stage by sinceMs (task-1285 F6).
102
- const telemetry = resolveStageTelemetry({ worktree: /** @type {string} */ (worktree || ''), result: /** @type {{[key: string]: any}} */ (result || {}), sinceMs: (/** @type {number | undefined} */ (sinceMs)) || 0 });
103
- try {
104
- const actorFamily = kind === 'review' ? reviewer : implementer;
105
- if (state && actorFamily && !markStageLaunchRecorded(state, {
106
- stage,
107
- agentFamily: actorFamily,
108
- result,
109
- slug,
110
- worktree,
111
- writeReviewStateFn
112
- })) {
113
- return;
127
+ function recordStageStatsSafe(kind, opts) {
128
+ const { stage, slug, rootDir, worktree, implementer, reviewer, result, sinceMs, log, state, writeReviewStateFn = review_state_js_1.writeReviewState, model = null } = opts;
129
+ let durationMinutes = 0;
130
+ if (result && result.startedAt && result.endedAt) {
131
+ durationMinutes = (Date.parse(result.endedAt) - Date.parse(result.startedAt)) / 60000;
132
+ }
133
+ const telemetry = (0, stage_telemetry_js_1.resolveStageTelemetry)({ worktree: worktree || '', result: result || {}, sinceMs: sinceMs || 0 });
134
+ try {
135
+ const actorFamily = kind === 'review' ? reviewer : implementer;
136
+ if (state && actorFamily && !markStageLaunchRecorded(state, {
137
+ stage,
138
+ agentFamily: actorFamily,
139
+ result,
140
+ slug,
141
+ worktree,
142
+ writeReviewStateFn
143
+ })) {
144
+ return;
145
+ }
146
+ getStats().then(s => s.accumulateStageStats({ stage, slug, rootDir, implementer, reviewer, telemetry, durationMinutes, model })).catch(() => { });
147
+ }
148
+ catch (err) {
149
+ log?.(fmt.status('WARN', `Could not record ${kind} stats for ${slug}: ${err.message}`));
114
150
  }
115
- /** @type {any} */ (stats).accumulateStageStats({ stage, slug, rootDir, implementer, reviewer, telemetry, durationMinutes, model });
116
- } catch (/** @type {unknown} */ err) {
117
- // Best-effort: never escalate to the fatal `error` channel.
118
- log?.(fmt.status('WARN', `Could not record ${kind} stats for ${slug}: ${/** @type {Error} */(err).message}`));
119
- }
120
151
  }
121
-
122
152
  const DEFAULT_MAX_ATTEMPTS = 5;
123
153
  const CONTINUE_SKIP_CHECK_TIMEOUT_MS = 10_000;
124
-
125
- /** @param {string} earlierIso @param {number} [nowMs] */
126
154
  function strictlyLaterIso(earlierIso, nowMs = Date.now()) {
127
- const earlierMs = Date.parse(earlierIso);
128
- if (!Number.isFinite(earlierMs)) {
129
- return new Date(nowMs).toISOString();
130
- }
131
- return new Date(Math.max(nowMs, earlierMs + 1)).toISOString();
155
+ const earlierMs = Date.parse(earlierIso);
156
+ if (!Number.isFinite(earlierMs)) {
157
+ return new Date(nowMs).toISOString();
158
+ }
159
+ return new Date(Math.max(nowMs, earlierMs + 1)).toISOString();
132
160
  }
133
-
134
161
  // ============================================================================
135
162
  // Pre-review Setup Functions
136
163
  // ============================================================================
137
-
138
- /** @param {string} rootDir @param {{commandRunner?: Function, log?: Function}} [options] */
139
- function maybeUpdateGraphifyBeforeReview(rootDir, { commandRunner = run, log = fmt.log.plain } = {}) {
140
- const updateGraphifyKnowledgeGraph = require('../core/mission-utils').updateGraphifyKnowledgeGraph;
141
- return updateGraphifyKnowledgeGraph({
142
- rootDir,
143
- commandRunner,
144
- log,
145
- startMessage: 'Updating graphify knowledge graph...',
146
- failureHint: 'Continuing without blocking review start.'
147
- });
164
+ function maybeUpdateGraphifyBeforeReview(rootDir, { commandRunner = git_js_1.run, log = fmt.log.plain } = {}) {
165
+ // Lazy require to break circular dependency with core/mission-utils
166
+ const { updateGraphifyKnowledgeGraph } = require('../core/mission-utils.js');
167
+ return updateGraphifyKnowledgeGraph({
168
+ rootDir,
169
+ commandRunner,
170
+ log,
171
+ startMessage: 'Updating graphify knowledge graph...',
172
+ failureHint: 'Continuing without blocking review start.'
173
+ });
148
174
  }
149
-
150
-
151
175
  // ============================================================================
152
176
  // Agent Fallback Handling
153
177
  // ============================================================================
154
-
155
178
  // If startAgent fell back to a different family after a limit hit, the comments
156
179
  // that gate the loop (review outcome / disposition) will be authored by the
157
180
  // fallback identity selected by the launcher.
158
181
  // This helper detects the fallback, persists the new identity to review state
159
182
  // and the Backlog assignee, and returns the identity that should be polled for.
160
- /**
161
- * @param {{role: string, original: string, launchResult?: {[key: string]: any}, state: {[key: string]: any}, slug: string, worktree?: string, taskResolution?: {[key: string]: any}, log?: Function, writeReviewStateFn?: Function, enforceTaskAssigneeFn?: Function}} opts
162
- * @returns {string}
163
- */
164
- function applyAgentFallback(/** @type {{role: string, original: string, launchResult?: {[key: string]: any}, state: {[key: string]: any}, slug: string, worktree?: string, taskResolution?: {[key: string]: any}, log?: Function, writeReviewStateFn?: Function, enforceTaskAssigneeFn?: Function}} */ opts) {
165
- const { role, original, launchResult, state, slug, worktree, taskResolution, log = fmt.log.plain, writeReviewStateFn = writeReviewState, enforceTaskAssigneeFn } = opts;
166
- if (!launchResult || !launchResult.agent || launchResult.agent === original) {
167
- return original;
168
- }
169
- const fallback = launchResult.agent;
170
- log(fmt.status('INFO', `${role} fell back from ${original} to ${fallback}; updating identity before polling.`));
171
- if (role === 'reviewer') {
172
- state.reviewer = fallback;
173
- } else {
174
- state.implementer = fallback;
175
- }
176
- writeReviewStateFn(slug, state, worktree);
177
- if (role === 'implementer' && taskResolution && taskResolution.ok) {
178
- if (enforceTaskAssigneeFn && !enforceTaskAssigneeFn(taskResolution.taskFile, fallback)) {
179
- log(fmt.status('WARN', `Could not enforce fallback implementer ${fallback} in backlog task.`));
183
+ function applyAgentFallback(opts) {
184
+ const { role, original, launchResult, state, slug, worktree, taskResolution, log = fmt.log.plain, writeReviewStateFn = review_state_js_1.writeReviewState, enforceTaskAssigneeFn } = opts;
185
+ if (!launchResult || !launchResult.agent || launchResult.agent === original) {
186
+ return original;
187
+ }
188
+ const fallback = launchResult.agent;
189
+ log(fmt.status('INFO', `${role} fell back from ${original} to ${fallback}; updating identity before polling.`));
190
+ if (role === 'reviewer') {
191
+ state.reviewer = fallback;
192
+ }
193
+ else {
194
+ state.implementer = fallback;
195
+ }
196
+ writeReviewStateFn(slug, state, worktree || process.cwd());
197
+ if (role === 'implementer' && taskResolution && taskResolution.ok) {
198
+ if (enforceTaskAssigneeFn && !enforceTaskAssigneeFn(taskResolution.taskFile, fallback)) {
199
+ log(fmt.status('WARN', `Could not enforce fallback implementer ${fallback} in backlog task.`));
200
+ }
180
201
  }
181
- }
182
- return fallback;
202
+ return fallback;
183
203
  }
184
-
185
- /**
186
- * @param {string} slug
187
- * @param {{[key: string]: any}} state
188
- * @param {string} worktree
189
- * @param {{log?: Function, writeReviewStateFn?: Function}} [options]
190
- * @returns {void}
191
- */
192
- function persistNormalizedPhaseRepair(slug, state, worktree, { log = fmt.log.plain, writeReviewStateFn = writeReviewState } = {}) {
193
- if (!state || !state.phaseOriginal || VALID_PHASES.includes(state.phaseOriginal)) {
194
- return;
195
- }
196
- log(fmt.status('WARN', `Persisted review phase "${state.phaseOriginal}" is invalid. Repairing to "${state.phase}".`));
197
- writeReviewStateFn(slug, state, worktree);
198
- state.phaseOriginal = null;
204
+ function persistNormalizedPhaseRepair(slug, state, worktree, { log = fmt.log.plain, writeReviewStateFn = review_state_js_1.writeReviewState } = {}) {
205
+ if (!state || !state.phaseOriginal || review_state_js_1.VALID_PHASES.includes(state.phaseOriginal)) {
206
+ return;
207
+ }
208
+ log(fmt.status('WARN', `Persisted review phase "${state.phaseOriginal}" is invalid. Repairing to "${state.phase}".`));
209
+ writeReviewStateFn(slug, state, worktree);
210
+ state.phaseOriginal = null;
199
211
  }
200
-
201
- /** @param {string} stage @param {string} agentFamily */
202
212
  function stageWindowKey(stage, agentFamily) {
203
- const normalizedStage = String(stage || 'default').trim().toLowerCase() || 'default';
204
- const normalizedAgent = String(agentFamily || '').trim().toLowerCase();
205
- return `${normalizedStage}:${normalizedAgent}`;
213
+ const normalizedStage = String(stage || 'default').trim().toLowerCase() || 'default';
214
+ const normalizedAgent = String(agentFamily || '').trim().toLowerCase();
215
+ return `${normalizedStage}:${normalizedAgent}`;
206
216
  }
207
-
208
217
  // Window the Codex telemetry read to the CURRENT launch's start so each round
209
218
  // contributes only its own rollouts. Earlier rounds' rollouts have an mtime
210
219
  // strictly before this launch's startedAt and are excluded, so the per-round
@@ -212,901 +221,817 @@ function stageWindowKey(stage, agentFamily) {
212
221
  // instead of re-summing a cumulative-since-first-launch window every round.
213
222
  // Resume-idempotency is provided separately by the launch fingerprint in
214
223
  // markStageLaunchRecorded, so the window itself does not need to persist.
215
- /** @param {{startedAt?: string}} result */
216
224
  function stageLaunchSinceMs(result) {
217
- const startedAt = result && result.startedAt ? String(result.startedAt) : '';
218
- const startedMs = startedAt ? Date.parse(startedAt) : NaN;
219
- return Number.isFinite(startedMs) ? startedMs : 0;
225
+ const startedAt = result && result.startedAt ? String(result.startedAt) : '';
226
+ const startedMs = startedAt ? Date.parse(startedAt) : NaN;
227
+ return Number.isFinite(startedMs) ? startedMs : 0;
220
228
  }
221
-
222
229
  // ============================================================================
223
230
  // Main Review Loop
224
231
  // ============================================================================
225
-
226
- /**
227
- * @param {string} slug
228
- * @param {{implementer?: string, reviewer?: string, focus?: string, maxAttempts?: number, dryRun?: boolean, reset?: boolean, continue?: boolean, isContinue?: boolean, verbose?: boolean, pollTimeoutSeconds?: number|null, worktree?: string, missionPath?: string, resetReviewStateFn?: Function, maybeUpdateGraphifyBeforeReviewFn?: Function, readReviewStateFn?: Function, resolveTaskFileFn?: Function, getTaskImplementerFn?: Function, getTaskStatusFn?: Function, transitionTaskFn?: Function, toVirtualFn?: Function, transitionVirtualFn?: Function, workflowLauncherStatusFn?: Function, buildAutonomousReviewMatrixFn?: Function, formatMatrixSummaryFn?: Function, selectAgentFn?: Function, providerAvailableFn?: Function, runFn?: Function, getPrStatusFn?: Function, enforceTaskAssigneeFn?: Function, resolveReviewUserFn?: Function, forgejoAvailableFn?: Function, resolveForgejoUserFn?: Function, readTokenFn?: Function, getCommentsFn?: Function, postCommentFn?: Function, postReviewFn?: Function, writeReviewStateFn?: Function, startAgentFn?: Function, pollForReviewFn?: Function, pollForDispositionFn?: Function, applyAgentFallbackFn?: Function, buildReviewPromptFn?: Function, buildActOnReviewPromptFn?: Function, buildCompactReviewPromptFn?: Function, buildCompactActOnReviewPromptFn?: Function, consumeReviewerArtifactsFn?: Function, consumeImplementerArtifactsFn?: Function, rebaseBeforeReviewRoundFn?: Function, eligibleAgentsForStepFn?: Function, performHandoffFn?: Function, log?: Function, error?: Function, getLatestReviewForPrFn?: Function, getLatestDispositionForPrFn?: Function, sleepFn?: Function, exit?: Function, gitFn?: Function, isReviewProviderEnabledFn?: Function, legacyIsForgejoReviewEnabledFn?: Function, isForgejoReviewEnabledFn?: Function, recordStageStatsSafeFn?: Function}} [opts]
229
- * @returns {Promise<void>}
230
- */
231
232
  async function startReviewLoop(slug, opts = {}) {
232
- let {
233
- implementer,
234
- reviewer,
235
- focus = 'all',
236
- maxAttempts = DEFAULT_MAX_ATTEMPTS,
237
- dryRun = false,
238
- reset = false,
239
- continue: continueFlag = false,
240
- isContinue = false,
241
- verbose = false,
242
- pollTimeoutSeconds = null,
243
- worktree: callerWorktree,
244
- missionPath,
245
- resetReviewStateFn = resetReviewState,
246
- maybeUpdateGraphifyBeforeReviewFn = maybeUpdateGraphifyBeforeReview,
247
- readReviewStateFn = readReviewState,
248
- resolveTaskFileFn = resolveTaskFile,
249
- getTaskImplementerFn = getTaskImplementer,
250
- getTaskStatusFn = getTaskStatus,
251
- transitionTaskFn = transitionTask,
252
- toVirtualFn = toVirtual,
253
- transitionVirtualFn = transitionVirtual,
254
- workflowLauncherStatusFn = workflowLauncherStatus,
255
- buildAutonomousReviewMatrixFn = buildAutonomousReviewMatrix,
256
- formatMatrixSummaryFn = formatMatrixSummary,
257
- selectAgentFn = selectAgent,
258
- providerAvailableFn = undefined,
259
- runFn = run,
260
- getPrStatusFn = getPrStatus,
261
- enforceTaskAssigneeFn = enforceTaskAssignee,
262
- resolveReviewUserFn = undefined,
263
- forgejoAvailableFn = null,
264
- resolveForgejoUserFn = null,
265
- readTokenFn = readToken,
266
- getCommentsFn = getComments,
267
- postCommentFn = postComment,
268
- postReviewFn = postReview,
269
- writeReviewStateFn = writeReviewState,
270
- startAgentFn = startAgent,
271
- pollForReviewFn = pollForReview,
272
- pollForDispositionFn = pollForDisposition,
273
- applyAgentFallbackFn = applyAgentFallback,
274
- buildReviewPromptFn = buildReviewPrompt,
275
- buildActOnReviewPromptFn = buildActOnReviewPrompt,
276
- buildCompactReviewPromptFn = buildCompactReviewPrompt,
277
- buildCompactActOnReviewPromptFn = buildCompactActOnReviewPrompt,
278
- consumeReviewerArtifactsFn = consumeReviewerArtifacts,
279
- consumeImplementerArtifactsFn = consumeImplementerArtifacts,
280
- rebaseBeforeReviewRoundFn = rebaseBeforeReviewRound,
281
- eligibleAgentsForStepFn = eligibleAgentsForStep,
282
- performHandoffFn = performHandoff,
283
- log = fmt.log.plain,
284
- error = fmt.log.plainError,
285
- getLatestReviewForPrFn = getLatestReviewForPr,
286
- getLatestDispositionForPrFn = getLatestDispositionForPr,
287
- sleepFn = delay,
288
- exit = process.exit,
289
- gitFn = git,
290
- isReviewProviderEnabledFn = undefined,
291
- legacyIsForgejoReviewEnabledFn = null,
292
- isForgejoReviewEnabledFn = null,
293
- recordStageStatsSafeFn = () => {}
294
- } = opts;
295
- let prNumber = null;
296
- isContinue = Boolean(isContinue || continueFlag);
297
-
298
- const pollIntervalMs = resolvePollIntervalMs();
299
- const pollTimeoutMs = resolvePollTimeoutMs(/** @type {number} */ (pollTimeoutSeconds || 0));
300
-
301
- const worktree = callerWorktree || resolveWorktree(slug) || process.cwd();
302
- const resolvedProviderAvailableFn = providerAvailableFn || forgejoAvailableFn || providerAvailable;
303
- const resolvedReviewUserFn = resolveReviewUserFn || resolveForgejoUserFn || resolveReviewUser;
304
- const branch = missionBranchName(slug, worktree);
305
- // Resolve the artifact directory once so reviewer/implementer artifact reads
306
- // and writes use the adapter-configured location instead of os.tmpdir().
307
- const artifactDir = resolveArtifactDir(worktree);
308
-
309
- // `--mission <path>` override (task-1272 SC7): when the caller points the
310
- // review loop at a mission contract in a non-standard location, resolve the
311
- // mission directory via the override and thread the contract file path into
312
- // the reviewer/implementer prompts so launched agents read it (not the
313
- // slug-derived default). Absent the flag, `effectiveMissionPath` stays
314
- // undefined and the prompts fall back to the standard slug-derived path.
315
- const missionDir = findMissionDir(slug, worktree, { missionPath });
316
- let effectiveMissionPath = /** @type {string|null} */ (null);
317
- if (missionPath && fs.existsSync(missionPath)) {
318
- effectiveMissionPath = fs.statSync(missionPath).isDirectory()
319
- ? path.join(missionPath, 'MISSION.md')
320
- : missionPath;
321
- log(fmt.status('INFO', `Using mission contract from --mission override: ${effectiveMissionPath}`));
322
- } else if (missionPath) {
323
- log(fmt.status('WARN', `--mission path not found: ${missionPath}; falling back to slug-derived mission location${missionDir ? ` (${missionDir})` : ''}.`));
324
- }
325
-
326
- const taskResolution = resolveTaskFileFn(slug, worktree);
327
-
328
- if (!dryRun) {
329
- maybeUpdateGraphifyBeforeReviewFn(worktree, { commandRunner: runFn, log });
330
- }
331
-
332
- // --reset: clear persisted state before starting
333
- if (reset) {
334
- if (resetReviewStateFn(slug, worktree)) {
335
- log(fmt.status('INFO', `Review state reset for ${slug}.`));
233
+ let { implementer, reviewer, focus = 'all', maxAttempts = DEFAULT_MAX_ATTEMPTS, dryRun = false, reset = false, continue: continueFlag = false, isContinue = false, verbose = false, pollTimeoutSeconds = null, worktree: callerWorktree, missionPath, resetReviewStateFn = review_state_js_1.resetReviewState, maybeUpdateGraphifyBeforeReviewFn = maybeUpdateGraphifyBeforeReview, readReviewStateFn = review_state_js_1.readReviewState, resolveTaskFileFn = backlog_js_1.resolveTaskFile, getTaskImplementerFn = backlog_js_1.getTaskImplementer, getTaskStatusFn = backlog_js_1.getTaskStatus, transitionTaskFn = backlog_js_1.transitionTask, toVirtualFn = state_map_js_1.toVirtual, transitionVirtualFn = state_map_js_1.transitionVirtual, workflowLauncherStatusFn = agents_js_1.workflowLauncherStatus, buildAutonomousReviewMatrixFn = runtime_matrix_js_1.buildAutonomousReviewMatrix, formatMatrixSummaryFn = runtime_matrix_js_1.formatMatrixSummary, selectAgentFn = agents_js_1.selectAgent, providerAvailableFn = undefined, runFn = git_js_1.run, getPrStatusFn = review_adapter_js_1.getPrStatus, enforceTaskAssigneeFn = backlog_js_1.enforceTaskAssignee, resolveReviewUserFn = undefined, forgejoAvailableFn = null, resolveForgejoUserFn = null, readTokenFn = review_adapter_js_1.readToken, getCommentsFn = review_adapter_js_1.getComments, postCommentFn = review_adapter_js_1.postComment, postReviewFn = review_adapter_js_1.postReview, writeReviewStateFn = review_state_js_1.writeReviewState, startAgentFn = agents_js_1.startAgent, pollForReviewFn = review_polling_js_1.pollForReview, pollForDispositionFn = review_polling_js_1.pollForDisposition, applyAgentFallbackFn = applyAgentFallback, buildReviewPromptFn = review_prompts_js_1.buildReviewPrompt, buildActOnReviewPromptFn = review_prompts_js_1.buildActOnReviewPrompt, buildCompactReviewPromptFn = review_prompts_js_1.buildCompactReviewPrompt, buildCompactActOnReviewPromptFn = review_prompts_js_1.buildCompactActOnReviewPrompt, consumeReviewerArtifactsFn = review_artifacts_js_1.consumeReviewerArtifacts, consumeImplementerArtifactsFn = review_artifacts_js_1.consumeImplementerArtifacts, rebaseBeforeReviewRoundFn = rebase_js_1.rebaseBeforeReviewRound, eligibleAgentsForStepFn = agents_js_1.eligibleAgentsForStep, log = fmt.log.plain, error = fmt.log.plainError, getLatestReviewForPrFn = review_adapter_js_1.getLatestReviewForPr, getLatestDispositionForPrFn = review_adapter_js_1.getLatestDispositionForPr, sleepFn = review_polling_js_1.delay, exit = process.exit, gitFn = git_js_1.git, isReviewProviderEnabledFn = undefined, legacyIsForgejoReviewEnabledFn = null, isForgejoReviewEnabledFn = null, recordStageStatsSafeFn = () => { } } = opts;
234
+ const performHandoffFn = opts.performHandoffFn || (await getHandoff()).performHandoff;
235
+ let prNumber = null;
236
+ isContinue = Boolean(isContinue || continueFlag);
237
+ const pollIntervalMs = (0, review_polling_js_1.resolvePollIntervalMs)();
238
+ const pollTimeoutMs = (0, review_polling_js_1.resolvePollTimeoutMs)(pollTimeoutSeconds || 0);
239
+ const worktree = callerWorktree || (0, mission_utils_js_1.resolveWorktree)(slug) || process.cwd();
240
+ const resolvedProviderAvailableFn = providerAvailableFn || forgejoAvailableFn || review_adapter_js_1.providerAvailable;
241
+ const resolvedReviewUserFn = resolveReviewUserFn || resolveForgejoUserFn || review_adapter_js_1.resolveReviewUser;
242
+ const branch = (0, mission_utils_js_1.missionBranchName)(slug, worktree);
243
+ // Resolve the artifact directory once so reviewer/implementer artifact reads
244
+ // and writes use the adapter-configured location instead of os.tmpdir().
245
+ const artifactDir = (0, review_artifacts_js_1.resolveArtifactDir)(worktree);
246
+ // `--mission <path>` override (task-1272 SC7): when the caller points the
247
+ // review loop at a mission contract in a non-standard location, resolve the
248
+ // mission directory via the override and thread the contract file path into
249
+ // the reviewer/implementer prompts so launched agents read it (not the
250
+ // slug-derived default). Absent the flag, `effectiveMissionPath` stays
251
+ // undefined and the prompts fall back to the standard slug-derived path.
252
+ const missionDir = (0, mission_utils_js_1.findMissionDir)(slug, worktree, { missionPath });
253
+ let effectiveMissionPath = null;
254
+ if (missionPath && fs.existsSync(missionPath)) {
255
+ effectiveMissionPath = fs.statSync(missionPath).isDirectory()
256
+ ? path.join(missionPath, 'MISSION.md')
257
+ : missionPath;
258
+ log(fmt.status('INFO', `Using mission contract from --mission override: ${effectiveMissionPath}`));
336
259
  }
337
- }
338
-
339
- const agents = eligibleAgentsForStepFn('review');
340
-
341
- const persisted = readReviewStateFn(slug, worktree);
342
-
343
- if (!implementer) {
344
- // Try to resume from persisted state
345
- if (persisted) {
346
- implementer = persisted.implementer;
347
- log(fmt.status('INFO', `Resuming persisted implementer: ${implementer}`));
348
- } else {
349
- // Try to resolve from backlog task
350
- if (taskResolution.ok) {
351
- implementer = getTaskImplementerFn(taskResolution.taskFile);
352
- if (implementer) {
353
- log(fmt.status('INFO', `Auto-derived implementer from backlog task: ${implementer}`));
354
- }
355
- }
260
+ else if (missionPath) {
261
+ log(fmt.status('WARN', `--mission path not found: ${missionPath}; falling back to slug-derived mission location${missionDir ? ` (${missionDir})` : ''}.`));
356
262
  }
357
- }
358
-
359
- if (!implementer) {
360
- implementer = 'autonomous';
361
- log(fmt.status('WARN', `No implementer identity resolved for ${slug}; defaulting to "autonomous"`));
362
- }
363
-
364
- if (!taskResolution.ok) {
365
- reportTaskResolution(taskResolution, slug, error);
366
- exit(1);
367
- return;
368
- }
369
-
370
- // Validate remote review state only when a review provider is enabled.
371
- const forgejoEnabledFn = isReviewProviderEnabledFn
372
- || legacyIsForgejoReviewEnabledFn
373
- || isForgejoReviewEnabledFn
374
- || isProviderEnabled;
375
- const forgejoEnabled = forgejoEnabledFn(worktree);
376
- if (!dryRun && forgejoEnabled) {
377
- const forgejoUrl = process.env.FORGEJO_URL || 'http://localhost:3300';
378
- const bootstrapScript = path.resolve(__dirname, '../../scripts/bootstrap.sh');
379
- log(fmt.status('INFO', `Checking review-provider availability at ${forgejoUrl}...`));
380
- if (!await resolvedProviderAvailableFn(forgejoUrl)) {
381
- error(fmt.status('FAIL', `Review provider not reachable at ${forgejoUrl}`));
382
- error(' Attempting to bootstrap provider containers...');
383
- const bootstrapResult = runFn('bash', [bootstrapScript], { stdio: 'inherit' });
384
- if (bootstrapResult.status === 0) {
385
- log(fmt.status('INFO', 'Bootstrap succeeded. Continuing with review loop.'));
386
- } else {
387
- error(fmt.status('FAIL', 'Bootstrap failed. Please run \'scripts/bootstrap.sh\' manually and retry.'));
388
- exit(1);
389
- return;
390
- }
391
- } else {
392
- log(fmt.status('INFO', `Review provider is running and reachable at ${forgejoUrl}.`));
263
+ const taskResolution = resolveTaskFileFn(slug, worktree);
264
+ if (!dryRun) {
265
+ maybeUpdateGraphifyBeforeReviewFn(worktree, { commandRunner: runFn, log });
393
266
  }
394
- const pr = getPrStatusFn(branch, worktree);
395
- if (!pr.exists || pr.state !== 'open') {
396
- // Check task status to detect pre-review (implementation) phase
397
- const taskStatus = taskResolution.ok ? getTaskStatusFn(taskResolution.taskFile) : null;
398
- const virtualStatus = taskStatus ? toVirtualFn(taskStatus) : null;
399
-
400
- // Implementation phase states: 'active' or any virtual state mapping to it
401
- const isImplementationPhase = taskStatus === 'active' || virtualStatus === 'active';
402
-
403
- if (isImplementationPhase) {
404
- // Task is still in implementation phase - provide guidance, don't hard-fail
405
- log(fmt.status('INFO', `PR not found for ${branch}. Task is in ${taskStatus} — create the PR first: px review ${slug} --push`));
406
- return;
407
- }
408
-
409
- // Task is in a post-implementation state (review/approved/ready-for-integration)
410
- // or the status is ambiguous, yet no open PR exists. Rather than dead-ending
411
- // with manual guidance, self-heal: run the canonical handoff (push branch +
412
- // create PR, reliable since task-1317), re-check, and continue the loop once a
413
- // PR exists. Only fall back to guidance when self-heal cannot yield an open PR.
414
- // Emits `--push` (the command self-heal attempts and the correct manual
415
- // equivalent) — never the old, frequently-wrong `--submit`.
416
- const fallbackGuidance = (/** @type {string|null} */ reason) => {
417
- error(fmt.status('FAIL', `No open review PR found for ${branch}. Create the PR before starting the review loop.`));
418
- if (reason) {error(` Handoff failure: ${reason}`);}
419
- error(` Run: px review ${slug} --push`);
420
- };
421
-
422
- if (dryRun) {
423
- // Dry-run never self-heals (no agents/side effects); emit guidance and exit.
424
- fallbackGuidance(null);
425
- exit(1);
426
- return;
427
- }
428
-
429
- log(fmt.status('INFO', `No open review PR for ${branch} (task in ${taskStatus}) — attempting automatic handoff (px review ${slug} --push)...`));
430
- const handoff = await performHandoffFn(slug, { forgejoUser: implementer, worktree });
431
-
432
- if (handoff && handoff.gatekeeperPushedBack) {
433
- // Mandatory artifacts are missing: don't spin the loop. The gatekeeper
434
- // kept the task in its current state; surface that and stop.
435
- error(fmt.status('FAIL', `Handoff blocked for ${branch}: mandatory mission artifacts are missing. Task stays in ${taskStatus}; supply the required artifacts and retry.`));
436
- exit(1);
437
- return;
438
- }
439
-
440
- if (!handoff || !handoff.ok) {
441
- fallbackGuidance(handoff && handoff.error ? handoff.error : null);
442
- exit(1);
443
- return;
444
- }
445
-
446
- // Handoff succeeded — re-check whether an open PR now exists.
447
- const healedPr = getPrStatusFn(branch, worktree);
448
- if (!healedPr.exists || healedPr.state !== 'open') {
449
- fallbackGuidance(null);
450
- exit(1);
451
- return;
452
- }
453
-
454
- log(fmt.status('INFO', `Self-heal succeeded: review PR #${healedPr.number} confirmed open for ${branch}. Continuing review loop.`));
455
- prNumber = healedPr.number;
456
- // Fall through into the normal loop with the recovered PR number.
457
- } else {
458
- log(fmt.status('INFO', `Review PR #${pr.number} confirmed open for ${branch}.`));
459
- prNumber = pr.number;
267
+ // --reset: clear persisted state before starting
268
+ if (reset) {
269
+ if (resetReviewStateFn(slug, worktree)) {
270
+ log(fmt.status('INFO', `Review state reset for ${slug}.`));
271
+ }
460
272
  }
461
- } else if (!dryRun && !forgejoEnabled) {
462
- log(fmt.status('INFO', 'Forgejo validation skipped (review provider is not forgejo). Using workflow-owned review surfaces.'));
463
- }
464
-
465
- // Note: The implementer may not be in the agents list (eligible for review step)
466
- // if its launcher is unavailable. This is OK - we'll use fallback logic for the reviewer.
467
- // The strict implementer eligibility check was removed to allow fallback reviewer selection (SC 4).
468
-
469
- // Resolve reviewer after the no-PR/self-heal gate so implementation-phase
470
- // guidance does not depend on workstation launcher availability.
471
- let reviewerSource = 'explicit';
472
- /** @type {Error|null} */ let selectErr = null;
473
- if (!reviewer) {
474
- if (persisted && persisted.reviewer) {
475
- reviewer = persisted.reviewer;
476
- reviewerSource = 'persisted';
477
- log(fmt.status('INFO', `Resuming persisted reviewer: ${reviewer} (round ${persisted.round})`));
478
- } else {
479
- try {
480
- reviewer = selectAgentFn('review', { exclude: new Set([implementer]) });
481
- } catch (/** @type {unknown} */ err) {
482
- selectErr = /** @type {Error} */ (err);
483
- reviewer = undefined;
484
- }
485
- reviewerSource = 'auto-derived';
273
+ const agents = eligibleAgentsForStepFn('review');
274
+ const persisted = readReviewStateFn(slug, worktree);
275
+ if (!implementer) {
276
+ // Try to resume from persisted state
277
+ if (persisted) {
278
+ implementer = persisted.implementer;
279
+ log(fmt.status('INFO', `Resuming persisted implementer: ${implementer}`));
280
+ }
281
+ else {
282
+ // Try to resolve from backlog task
283
+ if (taskResolution.ok) {
284
+ implementer = (getTaskImplementerFn(taskResolution.taskFile) || undefined);
285
+ if (implementer) {
286
+ log(fmt.status('INFO', `Auto-derived implementer from backlog task: ${implementer}`));
287
+ }
288
+ }
289
+ }
290
+ }
291
+ if (!implementer) {
292
+ implementer = 'autonomous';
293
+ log(fmt.status('WARN', `No implementer identity resolved for ${slug}; defaulting to "autonomous"`));
486
294
  }
487
- }
488
-
489
- if (!reviewer) {
490
- const anyDifferentFamilyRunnable = agents.some((/** @type {string} */ a) => a !== implementer && workflowLauncherStatusFn(a).supported);
491
- const implementerRunnable = agents.includes(implementer) && workflowLauncherStatusFn(implementer).supported;
492
- if (!anyDifferentFamilyRunnable && implementerRunnable) {
493
- log(fmt.status('WARN', `No supported different-family reviewer found for implementer "${implementer}".`));
494
- log(fmt.status('WARN', `Single-family fallback: reviewer="${implementer}" (same as implementer) — no different-family agent is runnable or unblocked on this workstation.`));
495
- reviewer = implementer;
496
- reviewerSource = 'single-family-fallback';
497
- } else if (!anyDifferentFamilyRunnable) {
498
- if (!forgejoEnabled) {
499
- const detail = selectErr ? `: ${(/** @type {Error} */ (selectErr)).message}` : '';
500
- reviewer = 'autonomous';
501
- reviewerSource = 'fallback';
502
- log(fmt.status('WARN', `No reviewer could be auto-derived${detail}; defaulting to "autonomous"`));
503
- // Provider=none mode can complete the loop with workflow-owned artifacts only.
504
- // Do not hard-fail just because no local reviewer launcher is runnable.
505
- log(fmt.status('INFO', 'Review provider disabled and no runnable reviewer route available; using autonomous workflow-owned review surfaces.'));
506
- } else {
507
- const reason = selectErr ? `: ${selectErr.message}` : '';
508
- error(fmt.status('FAIL', `No reviewer could be auto-derived${reason}.`));
509
- error('\n' + fmt.status('INFO', 'Full runtime matrix:'));
510
- formatMatrixSummaryFn(buildAutonomousReviewMatrixFn()).forEach((/** @type {string} */ line) => error(` ${line}`));
511
- error('\n' + fmt.status('FAIL', `No runnable reviewer route for implementer "${implementer}".`));
295
+ if (!taskResolution.ok) {
296
+ (0, backlog_js_1.reportTaskResolution)(taskResolution, slug, error);
512
297
  exit(1);
513
298
  return;
514
- }
515
- } else {
516
- const detail = selectErr ? `: ${selectErr.message}` : '';
517
- reviewer = 'autonomous';
518
- reviewerSource = 'fallback';
519
- log(fmt.status('WARN', `No reviewer could be auto-derived${detail}; defaulting to "autonomous"`));
520
299
  }
521
- }
522
-
523
- const willLaunchRounds = maxAttempts >= ((persisted && persisted.round) || 1);
524
- const resumesInFixingPhase = persisted && persisted.phase === 'fixing' && !dryRun;
525
-
526
- // Dry-run validates the reviewer identity but only explicit reviewers bypass
527
- // launcher support checks; auto-derived/persisted reviewers still exercise
528
- // fallback routing so dry-run logs reflect the real selection path.
529
- if (reviewerSource === 'fallback') {
530
- log(fmt.status('INFO', `Reviewer identity defaulted to autonomous; skipping launcher availability check.`));
531
- } else if (resumesInFixingPhase) {
532
- log(fmt.status('INFO', `Resuming in fixing phase with reviewer "${reviewer}"; skipping launcher availability check until a new review launch is needed.`));
533
- } else if (dryRun && reviewerSource === 'explicit') {
534
- const reviewerStatus = workflowLauncherStatusFn(reviewer);
535
- const hasInjectedLauncherStatus = workflowLauncherStatusFn !== workflowLauncherStatus;
536
- if (!agents.includes(reviewer) || (hasInjectedLauncherStatus && !reviewerStatus.supported)) {
537
- const reason = !agents.includes(reviewer) ? 'blocked or unsupported' : 'launcher is not available';
538
- error(fmt.status('FAIL', `Unsupported reviewer: "${reviewer}" (${reason}).`));
539
- if (reason === 'launcher is not available' && reviewerStatus.detail) {
540
- error(` Looked for: ${reviewerStatus.detail}`);
541
- }
542
- error('\n' + fmt.status('INFO', 'Full runtime matrix:'));
543
- formatMatrixSummaryFn(buildAutonomousReviewMatrixFn()).forEach((/** @type {string} */ line) => error(` ${line}`));
544
- error('\n' + fmt.status('FAIL', `No runnable reviewer route for implementer "${implementer}".`));
545
- exit(1);
546
- return;
300
+ // Validate remote review state only when a review provider is enabled.
301
+ const forgejoEnabledFn = isReviewProviderEnabledFn
302
+ || legacyIsForgejoReviewEnabledFn
303
+ || isForgejoReviewEnabledFn
304
+ || review_adapter_js_1.isProviderEnabled;
305
+ const forgejoEnabled = forgejoEnabledFn(worktree);
306
+ if (!dryRun && forgejoEnabled) {
307
+ const forgejoUrl = process.env.FORGEJO_URL || 'http://localhost:3300';
308
+ const bootstrapScript = path.resolve(__dirname, '../../scripts/bootstrap.sh');
309
+ log(fmt.status('INFO', `Checking review-provider availability at ${forgejoUrl}...`));
310
+ if (!await resolvedProviderAvailableFn(forgejoUrl)) {
311
+ error(fmt.status('FAIL', `Review provider not reachable at ${forgejoUrl}`));
312
+ error(' Attempting to bootstrap provider containers...');
313
+ const bootstrapResult = runFn('bash', [bootstrapScript], { stdio: 'inherit' });
314
+ if (bootstrapResult.status === 0) {
315
+ log(fmt.status('INFO', 'Bootstrap succeeded. Continuing with review loop.'));
316
+ }
317
+ else {
318
+ error(fmt.status('FAIL', 'Bootstrap failed. Please run \'scripts/bootstrap.sh\' manually and retry.'));
319
+ exit(1);
320
+ return;
321
+ }
322
+ }
323
+ else {
324
+ log(fmt.status('INFO', `Review provider is running and reachable at ${forgejoUrl}.`));
325
+ }
326
+ const pr = getPrStatusFn(branch, worktree);
327
+ if (!pr.exists || pr.state !== 'open') {
328
+ // Check task status to detect pre-review (implementation) phase
329
+ const taskStatus = taskResolution.ok ? getTaskStatusFn(taskResolution.taskFile) : null;
330
+ const virtualStatus = taskStatus ? toVirtualFn(taskStatus) : null;
331
+ // Implementation phase states: 'active' or any virtual state mapping to it
332
+ const isImplementationPhase = taskStatus === 'active' || virtualStatus === 'active';
333
+ if (isImplementationPhase) {
334
+ // Task is still in implementation phase - provide guidance, don't hard-fail
335
+ log(fmt.status('INFO', `PR not found for ${branch}. Task is in ${taskStatus} — create the PR first: px review ${slug} --push`));
336
+ return;
337
+ }
338
+ // Task is in a post-implementation state (review/approved/ready-for-integration)
339
+ // or the status is ambiguous, yet no open PR exists. Rather than dead-ending
340
+ // with manual guidance, self-heal: run the canonical handoff (push branch +
341
+ // create PR, reliable since task-1317), re-check, and continue the loop once a
342
+ // PR exists. Only fall back to guidance when self-heal cannot yield an open PR.
343
+ // Emits `--push` (the command self-heal attempts and the correct manual
344
+ // equivalent) — never the old, frequently-wrong `--submit`.
345
+ const fallbackGuidance = (reason) => {
346
+ error(fmt.status('FAIL', `No open review PR found for ${branch}. Create the PR before starting the review loop.`));
347
+ if (reason) {
348
+ error(` Handoff failure: ${reason}`);
349
+ }
350
+ error(` Run: px review ${slug} --push`);
351
+ };
352
+ if (dryRun) {
353
+ // Dry-run never self-heals (no agents/side effects); emit guidance and exit.
354
+ fallbackGuidance(null);
355
+ exit(1);
356
+ return;
357
+ }
358
+ log(fmt.status('INFO', `No open review PR for ${branch} (task in ${taskStatus}) — attempting automatic handoff (px review ${slug} --push)...`));
359
+ const handoff = await performHandoffFn(slug, { forgejoUser: implementer, worktree });
360
+ if (handoff && handoff.gatekeeperPushedBack) {
361
+ // Mandatory artifacts are missing: don't spin the loop. The gatekeeper
362
+ // kept the task in its current state; surface that and stop.
363
+ error(fmt.status('FAIL', `Handoff blocked for ${branch}: mandatory mission artifacts are missing. Task stays in ${taskStatus}; supply the required artifacts and retry.`));
364
+ exit(1);
365
+ return;
366
+ }
367
+ if (!handoff || !handoff.ok) {
368
+ fallbackGuidance(handoff && handoff.error ? String(handoff.error) : null);
369
+ exit(1);
370
+ return;
371
+ }
372
+ // Handoff succeeded — re-check whether an open PR now exists.
373
+ const healedPr = getPrStatusFn(branch, worktree);
374
+ if (!healedPr.exists || healedPr.state !== 'open') {
375
+ fallbackGuidance(null);
376
+ exit(1);
377
+ return;
378
+ }
379
+ log(fmt.status('INFO', `Self-heal succeeded: review PR #${healedPr.number} confirmed open for ${branch}. Continuing review loop.`));
380
+ prNumber = healedPr.number;
381
+ // Fall through into the normal loop with the recovered PR number.
382
+ }
383
+ else {
384
+ log(fmt.status('INFO', `Review PR #${pr.number} confirmed open for ${branch}.`));
385
+ prNumber = pr.number;
386
+ }
547
387
  }
548
- } else if (!willLaunchRounds) {
549
- if (!agents.includes(reviewer)) {
550
- error(fmt.status('FAIL', `Unsupported reviewer: "${reviewer}" (blocked or unsupported).`));
551
- error('\n' + fmt.status('INFO', 'Full runtime matrix:'));
552
- formatMatrixSummaryFn(buildAutonomousReviewMatrixFn()).forEach((/** @type {string} */ line) => error(` ${line}`));
553
- error('\n' + fmt.status('FAIL', `No runnable reviewer route for implementer "${implementer}".`));
554
- exit(1);
555
- return;
388
+ else if (!dryRun && !forgejoEnabled) {
389
+ log(fmt.status('INFO', 'Forgejo validation skipped (review provider is not forgejo). Using workflow-owned review surfaces.'));
556
390
  }
557
- } else {
558
- let reviewerStatus = workflowLauncherStatusFn(reviewer);
559
- const triedReviewers = new Set();
560
- while (!agents.includes(reviewer) || !reviewerStatus.supported) {
561
- triedReviewers.add(reviewer);
562
- if (reviewerSource === 'explicit') {
563
- const reason = !agents.includes(reviewer) ? 'blocked or unsupported' : 'launcher is not available';
564
- error(fmt.status('FAIL', `Unsupported reviewer: "${reviewer}" (${reason}).`));
565
- if (reviewerStatus.detail) {error(` Looked for: ${reviewerStatus.detail}`);}
566
- error('\n' + fmt.status('INFO', 'Full runtime matrix:'));
567
- formatMatrixSummaryFn(buildAutonomousReviewMatrixFn()).forEach((/** @type {string} */ line) => error(` ${line}`));
568
- error('\n' + fmt.status('FAIL', `No runnable reviewer route for implementer "${implementer}".`));
569
- exit(1);
570
- return;
571
- }
572
-
573
- const excludeSet = new Set([...triedReviewers, implementer]);
574
- let fallback;
575
- let fallbackStatus;
576
- try {
577
- fallback = selectAgentFn('review', { exclude: excludeSet });
578
- if (excludeSet.has(fallback)) {
579
- fallback = null;
580
- fallbackStatus = null;
581
- } else {
582
- fallbackStatus = workflowLauncherStatusFn(fallback);
391
+ // Note: The implementer may not be in the agents list (eligible for review step)
392
+ // if its launcher is unavailable. This is OK - we'll use fallback logic for the reviewer.
393
+ // The strict implementer eligibility check was removed to allow fallback reviewer selection (SC 4).
394
+ // Resolve reviewer after the no-PR/self-heal gate so implementation-phase
395
+ // guidance does not depend on workstation launcher availability.
396
+ let reviewerSource = 'explicit';
397
+ let selectErr = null;
398
+ if (!reviewer) {
399
+ if (persisted && persisted.reviewer) {
400
+ reviewer = persisted.reviewer;
401
+ reviewerSource = 'persisted';
402
+ log(fmt.status('INFO', `Resuming persisted reviewer: ${reviewer} (round ${persisted.round})`));
583
403
  }
584
- } catch (/** @type {unknown} */ err) {
585
- fallback = null;
586
- fallbackStatus = null;
587
- }
588
-
589
- if (!fallback) {
590
- const anyDifferentFamilyRunnable = agents.some((/** @type {string} */ a) => a !== implementer && workflowLauncherStatusFn(a).supported);
404
+ else {
405
+ try {
406
+ reviewer = selectAgentFn('review', { exclude: new Set([implementer]) });
407
+ }
408
+ catch (err) {
409
+ selectErr = err;
410
+ reviewer = undefined;
411
+ }
412
+ reviewerSource = 'auto-derived';
413
+ }
414
+ }
415
+ if (!reviewer) {
416
+ const anyDifferentFamilyRunnable = agents.some((a) => a !== implementer && workflowLauncherStatusFn(a).supported);
591
417
  const implementerRunnable = agents.includes(implementer) && workflowLauncherStatusFn(implementer).supported;
592
418
  if (!anyDifferentFamilyRunnable && implementerRunnable) {
593
- log(fmt.status('WARN', `No supported different-family reviewer found for implementer "${implementer}".`));
594
- log(fmt.status('WARN', `Single-family fallback: reviewer="${implementer}" (same as implementer) — no different-family agent is runnable or unblocked on this workstation.`));
595
- reviewer = implementer;
596
- reviewerSource = 'single-family-fallback';
597
- break;
419
+ log(fmt.status('WARN', `No supported different-family reviewer found for implementer "${implementer}".`));
420
+ log(fmt.status('WARN', `Single-family fallback: reviewer="${implementer}" (same as implementer) — no different-family agent is runnable or unblocked on this workstation.`));
421
+ reviewer = implementer;
422
+ reviewerSource = 'single-family-fallback';
598
423
  }
599
- if (dryRun && implementer === 'autonomous') {
600
- reviewer = 'autonomous';
601
- reviewerSource = 'fallback';
602
- log(fmt.status('WARN', `No runnable reviewer launcher available in dry-run; defaulting reviewer identity to "autonomous".`));
603
- break;
424
+ else if (!anyDifferentFamilyRunnable) {
425
+ if (!forgejoEnabled) {
426
+ const detail = selectErr ? `: ${selectErr.message}` : '';
427
+ reviewer = 'autonomous';
428
+ reviewerSource = 'fallback';
429
+ log(fmt.status('WARN', `No reviewer could be auto-derived${detail}; defaulting to "autonomous"`));
430
+ // Provider=none mode can complete the loop with workflow-owned artifacts only.
431
+ // Do not hard-fail just because no local reviewer launcher is runnable.
432
+ log(fmt.status('INFO', 'Review provider disabled and no runnable reviewer route available; using autonomous workflow-owned review surfaces.'));
433
+ }
434
+ else {
435
+ const reason = selectErr ? `: ${selectErr.message}` : '';
436
+ error(fmt.status('FAIL', `No reviewer could be auto-derived${reason}.`));
437
+ error('\n' + fmt.status('INFO', 'Full runtime matrix:'));
438
+ formatMatrixSummaryFn(buildAutonomousReviewMatrixFn()).forEach((line) => error(` ${line}`));
439
+ error('\n' + fmt.status('FAIL', `No runnable reviewer route for implementer "${implementer}".`));
440
+ exit(1);
441
+ return;
442
+ }
443
+ }
444
+ else {
445
+ const detail = selectErr ? `: ${selectErr.message}` : '';
446
+ reviewer = 'autonomous';
447
+ reviewerSource = 'fallback';
448
+ log(fmt.status('WARN', `No reviewer could be auto-derived${detail}; defaulting to "autonomous"`));
604
449
  }
605
- const reason = !agents.includes(reviewer) ? 'blocked or unsupported' : 'launcher is not available';
606
- error(fmt.status('FAIL', `Unsupported reviewer: "${reviewer}" (${reason}) and no unblocked different-family fallback is available.`));
607
- if (reviewerStatus.detail) {error(` Looked for: ${reviewerStatus.detail}`);}
608
- error('\n' + fmt.status('INFO', 'Full runtime matrix:'));
609
- formatMatrixSummaryFn(buildAutonomousReviewMatrixFn()).forEach((/** @type {string} */ line) => error(` ${line}`));
610
- error('\n' + fmt.status('FAIL', `No runnable reviewer route for implementer "${implementer}".`));
611
- exit(1);
612
- return;
613
- }
614
-
615
- const reason = !agents.includes(reviewer) ? 'blocked or unsupported' : 'launcher is not available';
616
- log(fmt.status('WARN', `Unsupported reviewer: "${reviewer}" (${reason}); trying fallback "${fallback}".`));
617
- reviewer = fallback;
618
- reviewerStatus = fallbackStatus;
619
- reviewerSource = reviewerSource === 'persisted' ? 'persisted-fallback' : 'auto-derived-fallback';
620
450
  }
621
- }
622
-
623
- log(fmt.status('INFO', `Selected reviewer: ${reviewer} (${reviewerSource})`));
624
-
625
- // Build the canonical ReviewState instance. When persisted state exists we
626
- // resume from it — preserving round, startedAt, phase, disposition, retry
627
- // counts, and metadata — and only overwrite the identities with the ones
628
- // selected for this launch (which may differ after a reviewer/implementer
629
- // fallback). Constructing fresh would silently reset all persisted progress.
630
- let state;
631
- if (persisted) {
632
- state = ReviewState.from(slug, persisted);
633
- state.reviewer = reviewer;
634
- state.implementer = implementer;
635
- } else {
636
- state = new ReviewState(slug, { reviewer, implementer });
637
- }
638
- persistNormalizedPhaseRepair(slug, state, worktree, { log, writeReviewStateFn });
639
-
640
- if (persisted && state.round > 1 && !dryRun) {
641
- log(fmt.status('INFO', `Resuming review loop from round ${state.round} (${state.phase}).`));
642
- }
643
-
644
- log(fmt.status('INFO', `Starting autonomous review loop for mission: ${slug}`));
645
- log(fmt.status('INFO', `Branch: ${branch}`));
646
- log(fmt.status('INFO', `Implementer: ${implementer} | Reviewer: ${reviewer} (${reviewerSource})`));
647
- log(fmt.status('INFO', `Focus: ${focus} | Max attempts: ${maxAttempts}`));
648
- log(fmt.status('INFO', `Poll interval: ${Math.round(pollIntervalMs / 1000)}s | Poll timeout: ${Math.round(pollTimeoutMs / 1000)}s${verbose ? ' | Verbose: on' : ''}`));
649
-
650
- if (dryRun) {
651
- log(fmt.status('DRY-RUN', 'No agents will be launched.'));
652
- }
653
-
654
- // Only resolve a provider identity and read a token when a provider is enabled.
655
- const pollingUser = forgejoEnabled ? resolvedReviewUserFn() : null;
656
- const token = dryRun || !forgejoEnabled ? null : readTokenFn(pollingUser, { rootDir: worktree });
657
- const initialRound = state.round;
658
-
659
- for (let attempt = initialRound; attempt <= maxAttempts; attempt++) {
660
- log('\n' + fmt.status('INFO', `========== Round ${attempt} / ${maxAttempts} ==========`));
661
-
662
- // On rounds after the first, advance the state machine
663
- if (attempt > state.round) {
664
- state.advanceRound();
451
+ const willLaunchRounds = maxAttempts >= ((persisted && persisted.round) || 1);
452
+ const resumesInFixingPhase = persisted && persisted.phase === 'fixing' && !dryRun;
453
+ // Dry-run validates the reviewer identity but only explicit reviewers bypass
454
+ // launcher support checks; auto-derived/persisted reviewers still exercise
455
+ // fallback routing so dry-run logs reflect the real selection path.
456
+ if (reviewerSource === 'fallback') {
457
+ log(fmt.status('INFO', `Reviewer identity defaulted to autonomous; skipping launcher availability check.`));
665
458
  }
666
-
667
- let reviewState;
668
-
669
- if (state.phase === 'reviewing') {
670
- // Check if we can skip reviewer launch
671
- if (isContinue && attempt === initialRound) {
672
- if (!dryRun) {transitionTaskFn(slug, 'review', { rootDir: worktree, log });}
673
- if (forgejoEnabled) {
674
- log(fmt.status('INFO', `Round ${attempt}: checking for existing review by ${reviewer} since ${state.startedAt}...`));
675
- reviewState = await pollForReviewFn(prNumber, reviewer, state.startedAt, token, {
676
- getLatestReviewForPrFn, sleepFn, intervalMs: 1000, timeoutMs: 2000, retryCount: state.reviewerRetryCount, verbose, label: `round ${attempt} skip-check`, log
677
- });
678
- // Skip-check timeout means no existing review - treat as null to proceed with initial launch
679
- if (isPollTimeout(reviewState)) {
680
- reviewState = null;
681
- }
682
- } else {
683
- // When the provider is disabled, use local review state instead of polling.
684
- log(fmt.status('INFO', `Round ${attempt}: review provider disabled; using workflow-owned review state.`));
685
- reviewState = null;
459
+ else if (resumesInFixingPhase) {
460
+ log(fmt.status('INFO', `Resuming in fixing phase with reviewer "${reviewer}"; skipping launcher availability check until a new review launch is needed.`));
461
+ }
462
+ else if (dryRun && reviewerSource === 'explicit') {
463
+ const reviewerStatus = workflowLauncherStatusFn(reviewer);
464
+ const hasInjectedLauncherStatus = workflowLauncherStatusFn !== agents_js_1.workflowLauncherStatus;
465
+ if (!agents.includes(reviewer) || (hasInjectedLauncherStatus && !reviewerStatus.supported)) {
466
+ const reason = !agents.includes(reviewer) ? 'blocked or unsupported' : 'launcher is not available';
467
+ error(fmt.status('FAIL', `Unsupported reviewer: "${reviewer}" (${reason}).`));
468
+ if (reason === 'launcher is not available' && reviewerStatus.detail) {
469
+ error(` Looked for: ${reviewerStatus.detail}`);
470
+ }
471
+ error('\n' + fmt.status('INFO', 'Full runtime matrix:'));
472
+ formatMatrixSummaryFn(buildAutonomousReviewMatrixFn()).forEach((line) => error(` ${line}`));
473
+ error('\n' + fmt.status('FAIL', `No runnable reviewer route for implementer "${implementer}".`));
474
+ exit(1);
475
+ return;
686
476
  }
687
- }
688
-
689
- // Handle reviewState: could be a valid state, null (no existing review or hard failure), or undefined (first time)
690
- if (dryRun) {
691
- if (!reviewState) {
692
- if (reviewer === 'autonomous') {
693
- log(fmt.status('INFO', `Round ${attempt}: reviewer identity is autonomous; skipping dry-run reviewer prompt and using local review artifacts only.`));
694
- } else {
695
- log(`\n--- DRY-RUN: reviewer (${reviewer}) prompt ---`);
696
- log(buildReviewPromptFn({ reviewer, branch, implementer, focus, attempt, repoRoot: worktree, missionPath: effectiveMissionPath, actualReviewer: '{{AGENT_NAME}}' }));
697
- }
477
+ }
478
+ else if (!willLaunchRounds) {
479
+ if (!agents.includes(reviewer)) {
480
+ error(fmt.status('FAIL', `Unsupported reviewer: "${reviewer}" (blocked or unsupported).`));
481
+ error('\n' + fmt.status('INFO', 'Full runtime matrix:'));
482
+ formatMatrixSummaryFn(buildAutonomousReviewMatrixFn()).forEach((line) => error(` ${line}`));
483
+ error('\n' + fmt.status('FAIL', `No runnable reviewer route for implementer "${implementer}".`));
484
+ exit(1);
485
+ return;
698
486
  }
699
- } else {
700
- if (!reviewState) {
701
- // First launch or hard failure (null)
702
- const rebaseResult = await rebaseBeforeReviewRoundFn(slug, {
703
- worktree, runFn, log, error,
704
- taskFile: taskResolution.taskFile,
705
- gitFn,
706
- isReviewProviderEnabledFn: forgejoEnabledFn
707
- });
708
- if (!rebaseResult.ok) { exit(1); return; }
709
-
710
- if (!dryRun) {transitionTaskFn(slug, 'review', { rootDir: worktree, log });}
711
- state.phase = 'reviewing';
712
- writeReviewStateFn(slug, state, worktree);
713
- if (reviewer === 'autonomous' && !forgejoEnabled) {
714
- log(fmt.status('INFO', `Round ${attempt}: reviewer identity is autonomous; skipping reviewer launch and using local review artifacts only.`));
715
- } else {
716
- log(fmt.status('INFO', `Round ${attempt}: launching reviewer (${reviewer})...`));
717
-
718
- let reviewerLaunchResult;
487
+ }
488
+ else {
489
+ let reviewerStatus = workflowLauncherStatusFn(reviewer);
490
+ const triedReviewers = new Set();
491
+ while (!agents.includes(reviewer) || !reviewerStatus.supported) {
492
+ triedReviewers.add(reviewer);
493
+ if (reviewerSource === 'explicit') {
494
+ const reason = !agents.includes(reviewer) ? 'blocked or unsupported' : 'launcher is not available';
495
+ error(fmt.status('FAIL', `Unsupported reviewer: "${reviewer}" (${reason}).`));
496
+ if (reviewerStatus.detail) {
497
+ error(` Looked for: ${reviewerStatus.detail}`);
498
+ }
499
+ error('\n' + fmt.status('INFO', 'Full runtime matrix:'));
500
+ formatMatrixSummaryFn(buildAutonomousReviewMatrixFn()).forEach((line) => error(` ${line}`));
501
+ error('\n' + fmt.status('FAIL', `No runnable reviewer route for implementer "${implementer}".`));
502
+ exit(1);
503
+ return;
504
+ }
505
+ const excludeSet = new Set([...triedReviewers, implementer]);
506
+ let fallback;
507
+ let fallbackStatus;
719
508
  try {
720
- reviewerLaunchResult = await startAgentFn('review', {
721
- agent: reviewer,
722
- prompt: (/** @type {string} */ actualReviewer) => buildCompactReviewPromptFn({ reviewer, branch, implementer, focus, attempt, repoRoot: worktree, missionPath: effectiveMissionPath, actualReviewer }),
723
- worktree, slug, role: 'reviewer', exclude: [implementer]
724
- });
725
- } catch (/** @type {unknown} */ err) {
726
- error(fmt.status('FAIL', `Could not launch reviewer agent (${reviewer}): ${/** @type {Error} */(err).message}`));
727
- exit(1); return;
509
+ fallback = selectAgentFn('review', { exclude: excludeSet });
510
+ if (excludeSet.has(fallback)) {
511
+ fallback = undefined;
512
+ fallbackStatus = null;
513
+ }
514
+ else {
515
+ fallbackStatus = workflowLauncherStatusFn(fallback);
516
+ }
728
517
  }
729
-
730
- reviewer = applyAgentFallbackFn({
731
- role: 'reviewer', original: reviewer, launchResult: reviewerLaunchResult,
732
- state, slug, worktree, taskResolution, log, writeReviewStateFn, enforceTaskAssigneeFn
733
- });
734
-
735
- // Record review-stage telemetry immediately after the reviewer runs,
736
- // so the worktree's newest codex rollout is this reviewer's session.
737
- // The window is bounded to this round's launch so each round adds
738
- // only its own usage; accumulateStageStats sums the rounds into one
739
- // cumulative row per reviewer family.
740
- const reviewSinceMs = stageLaunchSinceMs((/** @type {{[key: string]: any}} */ (reviewerLaunchResult))?.result);
741
- recordStageStatsSafeFn('review', {
742
- stage: 'review', slug, rootDir: worktree, worktree, reviewer: /** @type {string} */ (reviewer), implementer,
743
- result: (/** @type {{[key: string]: any}} */ (reviewerLaunchResult))?.result,
744
- sinceMs: /** @type {number} */ (reviewSinceMs || 0), log, error, state, writeReviewStateFn,
745
- model: resolveAgentModel(/** @type {string} */ (reviewer), /** @type {string} */ (worktree)),
746
- });
747
- }
748
-
749
- const reviewerArtifacts = await consumeReviewerArtifactsFn(slug, reviewer, {
750
- worktree,
751
- tmpDir: artifactDir,
752
- fallbackToTmp: true,
753
- readTokenFn,
754
- getCommentsFn,
755
- postCommentFn,
756
- postReviewFn,
757
- buildMetadataFooterFn: buildMetadataFooter,
758
- forgejoEnabled,
759
- log,
760
- error
761
- });
762
- if (reviewerArtifacts.consumed) {
763
- if (!reviewerArtifacts.ok) { exit(1); return; }
764
- reviewState = reviewerArtifacts.reviewState;
765
- }
766
- if (!reviewState && forgejoEnabled) {
767
- reviewState = await pollForReviewFn(prNumber, reviewer, state.startedAt, token, {
768
- getLatestReviewForPrFn, sleepFn, intervalMs: pollIntervalMs, timeoutMs: pollTimeoutMs, retryCount: 0, verbose, label: `round ${attempt} review`, log
769
- });
770
- }
518
+ catch {
519
+ fallback = undefined;
520
+ fallbackStatus = null;
521
+ }
522
+ if (!fallback) {
523
+ const anyDifferentFamilyRunnable = agents.some((a) => a !== implementer && workflowLauncherStatusFn(a).supported);
524
+ const implementerRunnable = agents.includes(implementer) && workflowLauncherStatusFn(implementer).supported;
525
+ if (!anyDifferentFamilyRunnable && implementerRunnable) {
526
+ log(fmt.status('WARN', `No supported different-family reviewer found for implementer "${implementer}".`));
527
+ log(fmt.status('WARN', `Single-family fallback: reviewer="${implementer}" (same as implementer) — no different-family agent is runnable or unblocked on this workstation.`));
528
+ reviewer = implementer;
529
+ reviewerSource = 'single-family-fallback';
530
+ break;
531
+ }
532
+ if (dryRun && implementer === 'autonomous') {
533
+ reviewer = 'autonomous';
534
+ reviewerSource = 'fallback';
535
+ log(fmt.status('WARN', `No runnable reviewer launcher available in dry-run; defaulting reviewer identity to "autonomous".`));
536
+ break;
537
+ }
538
+ const reason = !agents.includes(reviewer) ? 'blocked or unsupported' : 'launcher is not available';
539
+ error(fmt.status('FAIL', `Unsupported reviewer: "${reviewer}" (${reason}) and no unblocked different-family fallback is available.`));
540
+ if (reviewerStatus.detail) {
541
+ error(` Looked for: ${reviewerStatus.detail}`);
542
+ }
543
+ error('\n' + fmt.status('INFO', 'Full runtime matrix:'));
544
+ formatMatrixSummaryFn(buildAutonomousReviewMatrixFn()).forEach((line) => error(` ${line}`));
545
+ error('\n' + fmt.status('FAIL', `No runnable reviewer route for implementer "${implementer}".`));
546
+ exit(1);
547
+ return;
548
+ }
549
+ const reason = !agents.includes(reviewer) ? 'blocked or unsupported' : 'launcher is not available';
550
+ log(fmt.status('WARN', `Unsupported reviewer: "${reviewer}" (${reason}); trying fallback "${fallback}".`));
551
+ reviewer = fallback;
552
+ reviewerStatus = fallbackStatus;
553
+ reviewerSource = reviewerSource === 'persisted' ? 'persisted-fallback' : 'auto-derived-fallback';
554
+ }
555
+ }
556
+ log(fmt.status('INFO', `Selected reviewer: ${reviewer} (${reviewerSource})`));
557
+ // Build the canonical ReviewState instance. When persisted state exists we
558
+ // resume from it — preserving round, startedAt, phase, disposition, retry
559
+ // counts, and metadata — and only overwrite the identities with the ones
560
+ // selected for this launch (which may differ after a reviewer/implementer
561
+ // fallback). Constructing fresh would silently reset all persisted progress.
562
+ let state;
563
+ if (persisted) {
564
+ state = review_state_js_1.ReviewState.from(slug, persisted);
565
+ state.reviewer = reviewer;
566
+ state.implementer = implementer;
567
+ }
568
+ else {
569
+ state = new review_state_js_1.ReviewState(slug, { reviewer, implementer });
570
+ }
571
+ persistNormalizedPhaseRepair(slug, state, worktree, { log, writeReviewStateFn });
572
+ if (persisted && state.round > 1 && !dryRun) {
573
+ log(fmt.status('INFO', `Resuming review loop from round ${state.round} (${state.phase}).`));
574
+ }
575
+ log(fmt.status('INFO', `Starting autonomous review loop for mission: ${slug}`));
576
+ log(fmt.status('INFO', `Branch: ${branch}`));
577
+ log(fmt.status('INFO', `Implementer: ${implementer} | Reviewer: ${reviewer} (${reviewerSource})`));
578
+ log(fmt.status('INFO', `Focus: ${focus} | Max attempts: ${maxAttempts}`));
579
+ log(fmt.status('INFO', `Poll interval: ${Math.round(pollIntervalMs / 1000)}s | Poll timeout: ${Math.round(pollTimeoutMs / 1000)}s${verbose ? ' | Verbose: on' : ''}`));
580
+ if (dryRun) {
581
+ log(fmt.status('DRY-RUN', 'No agents will be launched.'));
582
+ }
583
+ // Only resolve a provider identity and read a token when a provider is enabled.
584
+ const pollingUser = forgejoEnabled ? resolvedReviewUserFn() : null;
585
+ const token = dryRun || !forgejoEnabled ? null : readTokenFn(pollingUser, { rootDir: worktree });
586
+ const initialRound = state.round;
587
+ for (let attempt = initialRound; attempt <= maxAttempts; attempt++) {
588
+ log('\n' + fmt.status('INFO', `========== Round ${attempt} / ${maxAttempts} ==========`));
589
+ // On rounds after the first, advance the state machine
590
+ if (attempt > state.round) {
591
+ state.advanceRound();
592
+ }
593
+ let reviewState;
594
+ if (state.phase === 'reviewing') {
595
+ // Check if we can skip reviewer launch
596
+ if (isContinue && attempt === initialRound) {
597
+ if (!dryRun) {
598
+ transitionTaskFn(slug, 'review', { rootDir: worktree, log });
599
+ }
600
+ if (forgejoEnabled) {
601
+ log(fmt.status('INFO', `Round ${attempt}: checking for existing review by ${reviewer} since ${state.startedAt}...`));
602
+ reviewState = await pollForReviewFn(prNumber, reviewer, state.startedAt, token, {
603
+ getLatestReviewForPrFn, sleepFn, intervalMs: 1000, timeoutMs: 2000, retryCount: state.reviewerRetryCount, verbose, label: `round ${attempt} skip-check`, log
604
+ });
605
+ // Skip-check timeout means no existing review - treat as null to proceed with initial launch
606
+ if ((0, review_polling_js_1.isPollTimeout)(reviewState)) {
607
+ reviewState = null;
608
+ }
609
+ }
610
+ else {
611
+ // When the provider is disabled, use local review state instead of polling.
612
+ log(fmt.status('INFO', `Round ${attempt}: review provider disabled; using workflow-owned review state.`));
613
+ reviewState = null;
614
+ }
615
+ }
616
+ // Handle reviewState: could be a valid state, null (no existing review or hard failure), or undefined (first time)
617
+ if (dryRun) {
618
+ if (!reviewState) {
619
+ if (reviewer === 'autonomous') {
620
+ log(fmt.status('INFO', `Round ${attempt}: reviewer identity is autonomous; skipping dry-run reviewer prompt and using local review artifacts only.`));
621
+ }
622
+ else {
623
+ log(`\n--- DRY-RUN: reviewer (${reviewer}) prompt ---`);
624
+ log(buildReviewPromptFn({ reviewer: reviewer, branch, implementer: implementer, focus, attempt, repoRoot: worktree, missionPath: effectiveMissionPath || undefined, actualReviewer: '{{AGENT_NAME}}' }));
625
+ }
626
+ }
627
+ }
628
+ else {
629
+ if (!reviewState) {
630
+ // First launch or hard failure (null)
631
+ const rebaseResult = await rebaseBeforeReviewRoundFn(slug, {
632
+ worktree, runFn: runFn, log, error,
633
+ taskFile: taskResolution.taskFile,
634
+ gitFn,
635
+ isReviewProviderEnabledFn: forgejoEnabledFn
636
+ });
637
+ if (!rebaseResult.ok) {
638
+ exit(1);
639
+ return;
640
+ }
641
+ if (!dryRun) {
642
+ transitionTaskFn(slug, 'review', { rootDir: worktree, log });
643
+ }
644
+ state.phase = 'reviewing';
645
+ writeReviewStateFn(slug, state, worktree);
646
+ if (reviewer === 'autonomous' && !forgejoEnabled) {
647
+ log(fmt.status('INFO', `Round ${attempt}: reviewer identity is autonomous; skipping reviewer launch and using local review artifacts only.`));
648
+ }
649
+ else {
650
+ log(fmt.status('INFO', `Round ${attempt}: launching reviewer (${reviewer})...`));
651
+ let reviewerLaunchResult;
652
+ try {
653
+ reviewerLaunchResult = await startAgentFn('review', {
654
+ agent: reviewer,
655
+ prompt: (actualReviewer) => buildCompactReviewPromptFn({ reviewer: reviewer, branch, implementer: implementer, focus, attempt, repoRoot: worktree, missionPath: effectiveMissionPath || undefined, actualReviewer }),
656
+ worktree, slug, role: 'reviewer', exclude: [implementer]
657
+ });
658
+ }
659
+ catch (err) {
660
+ error(fmt.status('FAIL', `Could not launch reviewer agent (${reviewer}): ${err.message}`));
661
+ exit(1);
662
+ return;
663
+ }
664
+ reviewer = applyAgentFallbackFn({
665
+ role: 'reviewer', original: reviewer, launchResult: reviewerLaunchResult,
666
+ state: state, slug, worktree, taskResolution, log, writeReviewStateFn, enforceTaskAssigneeFn
667
+ });
668
+ // Record review-stage telemetry immediately after the reviewer runs,
669
+ // so the worktree's newest codex rollout is this reviewer's session.
670
+ // The window is bounded to this round's launch so each round adds
671
+ // only its own usage; accumulateStageStats sums the rounds into one
672
+ // cumulative row per reviewer family.
673
+ const reviewSinceMs = stageLaunchSinceMs(reviewerLaunchResult?.result);
674
+ recordStageStatsSafeFn('review', {
675
+ stage: 'review', slug, rootDir: worktree, worktree, reviewer, implementer,
676
+ result: reviewerLaunchResult?.result,
677
+ sinceMs: reviewSinceMs || 0, log, error, state, writeReviewStateFn,
678
+ model: (0, product_config_js_1.resolveAgentModel)(reviewer, worktree),
679
+ });
680
+ }
681
+ const reviewerArtifacts = await consumeReviewerArtifactsFn(slug, reviewer, {
682
+ worktree,
683
+ tmpDir: artifactDir,
684
+ fallbackToTmp: true,
685
+ readTokenFn,
686
+ getCommentsFn: getCommentsFn,
687
+ postCommentFn,
688
+ postReviewFn,
689
+ buildMetadataFooterFn: review_artifacts_js_1.buildMetadataFooter,
690
+ forgejoEnabled,
691
+ log,
692
+ error
693
+ });
694
+ if (reviewerArtifacts.consumed) {
695
+ if (!reviewerArtifacts.ok) {
696
+ exit(1);
697
+ return;
698
+ }
699
+ reviewState = reviewerArtifacts.reviewState;
700
+ }
701
+ if (!reviewState && forgejoEnabled) {
702
+ reviewState = await pollForReviewFn(prNumber, reviewer, state.startedAt, token, {
703
+ getLatestReviewForPrFn, sleepFn, intervalMs: pollIntervalMs, timeoutMs: pollTimeoutMs, retryCount: 0, verbose, label: `round ${attempt} review`, log
704
+ });
705
+ }
706
+ }
707
+ if ((0, review_polling_js_1.isPollTimeout)(reviewState)) {
708
+ // Timeout recovery loop for reviewer
709
+ const stateAny = state;
710
+ while ((stateAny['reviewerRetryCount'] || 0) < 2) {
711
+ stateAny['reviewerRetryCount'] = (stateAny['reviewerRetryCount'] || 0) + 1;
712
+ writeReviewStateFn(slug, state, worktree);
713
+ const elapsedStr = (0, review_polling_js_1.formatElapsed)(Date.now() - Date.parse(stateAny['startedAt']));
714
+ const recoveryPrompt = `RECOVERY: Reviewer timeout after ${elapsedStr}. Please complete the review for ${branch}.`;
715
+ log(fmt.status('INFO', `Round ${attempt}: relaunching reviewer (${reviewer}) with recovery prompt (retry ${stateAny['reviewerRetryCount']}/3)...`));
716
+ let relaunchResult;
717
+ try {
718
+ relaunchResult = await startAgentFn('review', {
719
+ agent: reviewer,
720
+ prompt: (actualReviewer) => buildCompactReviewPromptFn({ reviewer: reviewer, branch, implementer: implementer, focus, attempt, repoRoot: worktree, missionPath: effectiveMissionPath || undefined, actualReviewer }) + '\n\n' + recoveryPrompt,
721
+ worktree, slug, role: 'reviewer', exclude: [implementer]
722
+ });
723
+ }
724
+ catch (err) {
725
+ error(fmt.status('FAIL', `Could not relaunch reviewer agent (${reviewer}): ${err.message}`));
726
+ exit(1);
727
+ return;
728
+ }
729
+ reviewer = applyAgentFallbackFn({
730
+ role: 'reviewer', original: reviewer, launchResult: relaunchResult,
731
+ state: state, slug, worktree, taskResolution, log, writeReviewStateFn, enforceTaskAssigneeFn
732
+ });
733
+ const retryArtifacts = await consumeReviewerArtifactsFn(slug, reviewer, {
734
+ worktree,
735
+ tmpDir: artifactDir,
736
+ fallbackToTmp: true,
737
+ readTokenFn,
738
+ getCommentsFn: getCommentsFn,
739
+ postCommentFn,
740
+ postReviewFn,
741
+ buildMetadataFooterFn: review_artifacts_js_1.buildMetadataFooter,
742
+ forgejoEnabled,
743
+ log,
744
+ error
745
+ });
746
+ if (retryArtifacts.consumed) {
747
+ if (!retryArtifacts.ok) {
748
+ exit(1);
749
+ return;
750
+ }
751
+ reviewState = retryArtifacts.reviewState;
752
+ }
753
+ if (!reviewState && forgejoEnabled) {
754
+ reviewState = await pollForReviewFn(prNumber, reviewer, state.startedAt, token, {
755
+ getLatestReviewForPrFn, sleepFn, intervalMs: pollIntervalMs, timeoutMs: pollTimeoutMs, retryCount: state.reviewerRetryCount, verbose, label: `round ${attempt} review retry ${state.reviewerRetryCount}`, log
756
+ });
757
+ }
758
+ if (!(0, review_polling_js_1.isPollTimeout)(reviewState)) {
759
+ break;
760
+ }
761
+ }
762
+ if ((0, review_polling_js_1.isPollTimeout)(reviewState)) {
763
+ log(fmt.status('INFO', `Autonomous review stopped: excessive reviewer timeout retries`));
764
+ return;
765
+ }
766
+ }
767
+ else if (!reviewState) {
768
+ error(fmt.status('FAIL', `Reviewer ${reviewer} did not submit a formal review outcome for ${branch}.`));
769
+ error(' The reviewer agent may have exited without posting to the review PR.');
770
+ exit(1);
771
+ return;
772
+ }
773
+ }
774
+ if (dryRun) {
775
+ return;
776
+ }
777
+ if (!reviewState) {
778
+ error(fmt.status('FAIL', `Reviewer ${reviewer} did not submit a formal review outcome for ${branch}.`));
779
+ error(' The reviewer agent may have exited without posting to the review PR.');
780
+ exit(1);
781
+ return;
782
+ }
783
+ if ((0, review_polling_js_1.isPollTimeout)(reviewState)) {
784
+ log(fmt.status('INFO', `Autonomous review stopped: excessive reviewer timeout retries`));
785
+ return;
786
+ }
787
+ log(fmt.status('INFO', `Round ${attempt}: reviewer outcome = ${reviewState}`));
788
+ if (reviewState === 'APPROVED') {
789
+ state.transitionTo('approved');
790
+ state.disposition = reviewState;
791
+ writeReviewStateFn(slug, state, worktree);
792
+ log(fmt.status('PASS', 'Autonomous review stopped: reviewer approved the PR. Hand off to human review/integration.'));
793
+ transitionVirtualFn(transitionTaskFn, slug, 'approved', { log });
794
+ return;
795
+ }
796
+ state.transitionTo('fixing');
797
+ state.disposition = reviewState;
771
798
  }
772
-
773
- if (isPollTimeout(reviewState)) {
774
- // Timeout recovery loop for reviewer
775
- const stateAny = /** @type {{[key: string]: any}} */ (state);
776
- while ((stateAny['reviewerRetryCount'] || 0) < 2) {
777
- stateAny['reviewerRetryCount'] = (stateAny['reviewerRetryCount'] || 0) + 1;
799
+ else {
800
+ // Resume in fixing phase, need reviewState
801
+ if (forgejoEnabled) {
802
+ const latestReview = await getLatestReviewForPrFn(prNumber, reviewer, state.startedAt, token);
803
+ reviewState = latestReview ? latestReview.state : null;
804
+ if (!reviewState) {
805
+ // Restore retry path: missing review on resume is recoverable
806
+ // (e.g., reviewer identity changed, state is stale, or artifact not yet
807
+ // discoverable). Treat as request-changes so the fixing phase proceeds
808
+ // and can re-trigger the review on the next attempt.
809
+ log(fmt.status('WARN', `No review found for ${reviewer} since ${state.startedAt}; treating as request-changes and proceeding to fixing phase.`));
810
+ reviewState = 'request-changes';
811
+ }
812
+ }
813
+ else {
814
+ // When the provider is disabled, use persisted review state.
815
+ reviewState = state.disposition || null;
816
+ if (!reviewState) {
817
+ // Restore retry path: missing local review state on resume is recoverable.
818
+ // Treat as request-changes so the fixing phase proceeds.
819
+ log(fmt.status('WARN', `No local review state found for ${reviewer}; treating as request-changes and proceeding to fixing phase.`));
820
+ reviewState = 'request-changes';
821
+ }
822
+ }
823
+ log(fmt.status('INFO', `Round ${attempt}: resuming in fixing phase with review outcome = ${reviewState}`));
824
+ }
825
+ // Fixing phase
826
+ let disposition;
827
+ let reLaunch;
828
+ let sinceIso = state.startedAt;
829
+ if (isContinue && attempt === state.round) {
830
+ if (forgejoEnabled) {
831
+ log(fmt.status('INFO', `Round ${attempt}: checking for existing disposition by ${implementer} since ${state.startedAt}...`));
832
+ disposition = await pollForDispositionFn(prNumber, implementer, state.startedAt, token, {
833
+ getLatestDispositionForPrFn, sleepFn, intervalMs: 1000, timeoutMs: CONTINUE_SKIP_CHECK_TIMEOUT_MS, verbose, label: `round ${attempt} skip-check`, log
834
+ });
835
+ // Skip-check timeout means no existing disposition - treat as null to proceed with initial launch
836
+ if ((0, review_polling_js_1.isPollTimeout)(disposition)) {
837
+ disposition = null;
838
+ }
839
+ else if (isContinue && (disposition === 'BLOCKED' || disposition === 'PARKED')) {
840
+ // Stale BLOCKED/PARKED from a previous round: re-launch the implementer
841
+ // so they can post a fresh disposition reflecting the current state of the branch.
842
+ log(fmt.status('INFO', `Round ${attempt}: implementer disposition found (${disposition}). Re-launching implementer to assess whether blocker is resolved...`));
843
+ reLaunch = true;
844
+ // Capture a fresh sinceIso so the post-relaunch poll doesn't
845
+ // immediately return the same stale disposition. Use a strictly
846
+ // newer timestamp than the persisted round start to avoid same-ms
847
+ // collisions during fast local test runs.
848
+ sinceIso = strictlyLaterIso(state.startedAt);
849
+ disposition = null;
850
+ }
851
+ }
852
+ else {
853
+ // When the provider is disabled, use local review state.
854
+ log(fmt.status('INFO', `Round ${attempt}: review provider disabled; using workflow-owned disposition state.`));
855
+ disposition = null;
856
+ }
857
+ }
858
+ if (!disposition) {
859
+ // First launch or re-launch after stale BLOCKED/PARKED
860
+ if (dryRun) {
861
+ log(`\n--- DRY-RUN: implementer (${implementer}) act-on-review prompt ---`);
862
+ log(buildActOnReviewPromptFn({ implementer: implementer, branch, attempt, repoRoot: worktree, missionPath: effectiveMissionPath || undefined, actualImplementer: '{{AGENT_NAME}}' }));
863
+ if (reLaunch) {
864
+ log(fmt.status('INFO', `Round ${attempt}: stale BLOCKED/PARKED disposition replaced by fresh implementer action.`));
865
+ }
866
+ return;
867
+ }
778
868
  writeReviewStateFn(slug, state, worktree);
779
- const elapsedStr = formatElapsed(Date.now() - Date.parse(/** @type {string} */ (stateAny['startedAt'])));
780
- const recoveryPrompt = `RECOVERY: Reviewer timeout after ${elapsedStr}. Please complete the review for ${branch}.`;
781
- log(fmt.status('INFO', `Round ${attempt}: relaunching reviewer (${reviewer}) with recovery prompt (retry ${stateAny['reviewerRetryCount']}/3)...`));
782
-
783
- let relaunchResult;
784
- try {
785
- relaunchResult = await startAgentFn('review', {
786
- agent: reviewer,
787
- prompt: (/** @type {string} */ actualReviewer) => buildCompactReviewPromptFn({ reviewer, branch, implementer, focus, attempt, repoRoot: worktree, missionPath: effectiveMissionPath, actualReviewer }) + '\n\n' + recoveryPrompt,
788
- worktree, slug, role: 'reviewer', exclude: [implementer]
789
- });
790
- } catch (/** @type {unknown} */ err) {
791
- error(fmt.status('FAIL', `Could not relaunch reviewer agent (${reviewer}): ${/** @type {Error} */(err).message}`));
792
- exit(1); return;
869
+ transitionTaskFn(slug, 'active', { implementer, rootDir: worktree, log });
870
+ if (implementer === 'autonomous' && !forgejoEnabled) {
871
+ log(fmt.status('INFO', `Round ${attempt}: implementer identity is autonomous; skipping implementer launch and using local review artifacts only.`));
793
872
  }
794
-
795
- reviewer = applyAgentFallbackFn({
796
- role: 'reviewer', original: reviewer, launchResult: relaunchResult,
797
- state, slug, worktree, taskResolution, log, writeReviewStateFn, enforceTaskAssigneeFn
798
- });
799
-
800
- const retryArtifacts = await consumeReviewerArtifactsFn(slug, reviewer, {
801
- worktree,
802
- tmpDir: artifactDir,
803
- fallbackToTmp: true,
804
- readTokenFn,
805
- getCommentsFn,
806
- postCommentFn,
807
- postReviewFn,
808
- buildMetadataFooterFn: buildMetadataFooter,
809
- forgejoEnabled,
810
- log,
811
- error
873
+ else {
874
+ log(fmt.status('INFO', `Round ${attempt}: launching implementer (${implementer}) for act-on-review...`));
875
+ let implementerLaunchResult;
876
+ try {
877
+ implementerLaunchResult = await startAgentFn('act-on-review', {
878
+ agent: implementer,
879
+ prompt: (actualImplementer) => buildCompactActOnReviewPromptFn({ implementer: implementer, branch, attempt, reviewOutcome: reviewState, repoRoot: worktree, missionPath: effectiveMissionPath || undefined, actualImplementer }),
880
+ worktree, slug, role: 'implementer', exclude: [reviewer]
881
+ });
882
+ }
883
+ catch (err) {
884
+ error(fmt.status('FAIL', `Could not launch implementer agent (${implementer}): ${err.message}`));
885
+ exit(1);
886
+ return;
887
+ }
888
+ implementer = applyAgentFallbackFn({
889
+ role: 'implementer', original: implementer, launchResult: implementerLaunchResult,
890
+ state: state, slug, worktree, taskResolution, log, writeReviewStateFn, enforceTaskAssigneeFn
891
+ });
892
+ // Record follow-up telemetry immediately after the implementer runs.
893
+ // This is intentionally a separate stored phase from the initial
894
+ // execute/active launch. The window is bounded to this round's launch
895
+ // so each act-on-review round adds only its own usage.
896
+ const followUpSinceMs = stageLaunchSinceMs(implementerLaunchResult?.result);
897
+ recordStageStatsSafeFn('active', {
898
+ stage: 'follow-up', slug, rootDir: worktree, worktree, implementer, reviewer,
899
+ result: implementerLaunchResult?.result,
900
+ sinceMs: followUpSinceMs || 0, log, error, state, writeReviewStateFn,
901
+ model: (0, product_config_js_1.resolveAgentModel)(implementer, worktree),
902
+ });
903
+ }
904
+ const implementerArtifacts = await consumeImplementerArtifactsFn(slug, implementer, {
905
+ worktree,
906
+ tmpDir: artifactDir,
907
+ fallbackToTmp: true,
908
+ readTokenFn,
909
+ getCommentsFn: getCommentsFn,
910
+ postCommentFn,
911
+ buildMetadataFooterFn: review_artifacts_js_1.buildMetadataFooter,
912
+ forgejoEnabled,
913
+ log,
914
+ error
812
915
  });
813
- if (retryArtifacts.consumed) {
814
- if (!retryArtifacts.ok) { exit(1); return; }
815
- reviewState = retryArtifacts.reviewState;
916
+ if (implementerArtifacts.consumed) {
917
+ if (!implementerArtifacts.ok) {
918
+ exit(1);
919
+ return;
920
+ }
921
+ disposition = implementerArtifacts.disposition;
816
922
  }
817
- if (!reviewState && forgejoEnabled) {
818
- reviewState = await pollForReviewFn(prNumber, reviewer, state.startedAt, token, {
819
- getLatestReviewForPrFn, sleepFn, intervalMs: pollIntervalMs, timeoutMs: pollTimeoutMs, retryCount: state.reviewerRetryCount, verbose, label: `round ${attempt} review retry ${state.reviewerRetryCount}`, log
820
- });
923
+ if (!disposition && forgejoEnabled) {
924
+ disposition = await pollForDispositionFn(prNumber, implementer, sinceIso, token, {
925
+ getLatestDispositionForPrFn, sleepFn, intervalMs: pollIntervalMs, timeoutMs: pollTimeoutMs, retryCount: 0, verbose, label: `round ${attempt} disposition`, log
926
+ });
821
927
  }
822
-
823
- if (!isPollTimeout(reviewState)) {break;}
824
- }
825
-
826
- if (isPollTimeout(reviewState)) {
827
- log(fmt.status('INFO', `Autonomous review stopped: excessive reviewer timeout retries`));
828
- return;
829
- }
830
- } else if (!reviewState) {
831
- error(fmt.status('FAIL', `Reviewer ${reviewer} did not submit a formal review outcome for ${branch}.`));
832
- error(' The reviewer agent may have exited without posting to the review PR.');
833
- exit(1); return;
928
+ if ((0, review_polling_js_1.isPollTimeout)(disposition)) {
929
+ // Timeout recovery loop for implementer
930
+ const stateAny = state;
931
+ while ((stateAny['implementerRetryCount'] || 0) < 2) {
932
+ stateAny['implementerRetryCount'] = (stateAny['implementerRetryCount'] || 0) + 1;
933
+ writeReviewStateFn(slug, state, worktree);
934
+ const elapsedStr = (0, review_polling_js_1.formatElapsed)(Date.now() - Date.parse(stateAny['startedAt']));
935
+ const recoveryPrompt = `RECOVERY: Implementer disposition timeout after ${elapsedStr}. Please provide a disposition (PUSHBACK_ALL, BLOCKED, PARKED, or continue with fixes) for ${branch}.`;
936
+ log(fmt.status('INFO', `Round ${attempt}: relaunching implementer (${implementer}) with recovery prompt (retry ${stateAny['implementerRetryCount']}/3)...`));
937
+ let relaunchResult;
938
+ try {
939
+ relaunchResult = await startAgentFn('act-on-review', {
940
+ agent: implementer,
941
+ prompt: (actualImplementer) => buildCompactActOnReviewPromptFn({ implementer: implementer, branch, attempt, reviewOutcome: reviewState, repoRoot: worktree, missionPath: effectiveMissionPath || undefined, actualImplementer }) + '\n\n' + recoveryPrompt,
942
+ worktree, slug, role: 'implementer', exclude: [reviewer]
943
+ });
944
+ }
945
+ catch (err) {
946
+ error(fmt.status('FAIL', `Could not relaunch implementer agent (${implementer}): ${err.message}`));
947
+ exit(1);
948
+ return;
949
+ }
950
+ implementer = applyAgentFallbackFn({
951
+ role: 'implementer', original: implementer, launchResult: relaunchResult,
952
+ state: state, slug, worktree, taskResolution, log, writeReviewStateFn, enforceTaskAssigneeFn
953
+ });
954
+ const retryArtifacts = await consumeImplementerArtifactsFn(slug, implementer, {
955
+ worktree,
956
+ tmpDir: artifactDir,
957
+ fallbackToTmp: true,
958
+ readTokenFn,
959
+ getCommentsFn: getCommentsFn,
960
+ postCommentFn,
961
+ buildMetadataFooterFn: review_artifacts_js_1.buildMetadataFooter,
962
+ forgejoEnabled,
963
+ log,
964
+ error
965
+ });
966
+ if (retryArtifacts.consumed) {
967
+ if (!retryArtifacts.ok) {
968
+ exit(1);
969
+ return;
970
+ }
971
+ disposition = retryArtifacts.disposition;
972
+ }
973
+ if (!disposition && forgejoEnabled) {
974
+ disposition = await pollForDispositionFn(prNumber, implementer, sinceIso, token, {
975
+ getLatestDispositionForPrFn, sleepFn, intervalMs: pollIntervalMs, timeoutMs: pollTimeoutMs, retryCount: state.implementerRetryCount, verbose, label: `round ${attempt} disposition retry ${state.implementerRetryCount}`, log
976
+ });
977
+ }
978
+ if (!(0, review_polling_js_1.isPollTimeout)(disposition)) {
979
+ break;
980
+ }
981
+ }
982
+ if ((0, review_polling_js_1.isPollTimeout)(disposition)) {
983
+ writeReviewStateFn(slug, state, worktree);
984
+ log(fmt.status('INFO', `Autonomous review stopped: excessive implementer timeout retries`));
985
+ return;
986
+ }
987
+ }
988
+ else if (!disposition) {
989
+ error(fmt.status('FAIL', `Implementer ${implementer} did not post an autonomous review disposition comment.`));
990
+ exit(1);
991
+ return;
992
+ }
993
+ // Suppress the duplicate message after re-launch
994
+ reLaunch = null;
834
995
  }
835
- }
836
-
837
- if (dryRun) {return;}
838
-
839
- if (!reviewState) {
840
- error(fmt.status('FAIL', `Reviewer ${reviewer} did not submit a formal review outcome for ${branch}.`));
841
- error(' The reviewer agent may have exited without posting to the review PR.');
842
- exit(1); return;
843
- }
844
-
845
- if (isPollTimeout(reviewState)) {
846
- log(fmt.status('INFO', `Autonomous review stopped: excessive reviewer timeout retries`));
847
- return;
848
- }
849
-
850
- log(fmt.status('INFO', `Round ${attempt}: reviewer outcome = ${reviewState}`));
851
-
852
- if (reviewState === 'APPROVED') {
853
- state.transitionTo('approved');
854
- state.disposition = reviewState;
855
- writeReviewStateFn(slug, state, worktree);
856
- log(fmt.status('PASS', 'Autonomous review stopped: reviewer approved the PR. Hand off to human review/integration.'));
857
- transitionVirtualFn(transitionTaskFn, slug, 'approved', { rootDir: worktree, log });
858
- return;
859
- }
860
- state.transitionTo('fixing');
861
- state.disposition = reviewState;
862
- } else {
863
- // Resume in fixing phase, need reviewState
864
- if (forgejoEnabled) {
865
- const latestReview = await getLatestReviewForPrFn(prNumber, reviewer, state.startedAt, token);
866
- reviewState = latestReview ? latestReview.state : null;
867
- if (!reviewState) {
868
- // Restore retry path: missing review on resume is recoverable
869
- // (e.g., reviewer identity changed, state is stale, or artifact not yet
870
- // discoverable). Treat as request-changes so the fixing phase proceeds
871
- // and can re-trigger the review on the next attempt.
872
- log(fmt.status('WARN', `No review found for ${reviewer} since ${state.startedAt}; treating as request-changes and proceeding to fixing phase.`));
873
- reviewState = 'request-changes';
996
+ else if (reLaunch) {
997
+ // Re-launch path: stale BLOCKED/PARKED was replaced by fresh implementation.
998
+ // The disposition was set by the re-launch above (lines 767-785).
999
+ // Fall through to disposition handling below.
874
1000
  }
875
- } else {
876
- // When the provider is disabled, use persisted review state.
877
- reviewState = state.disposition || null;
878
- if (!reviewState) {
879
- // Restore retry path: missing local review state on resume is recoverable.
880
- // Treat as request-changes so the fixing phase proceeds.
881
- log(fmt.status('WARN', `No local review state found for ${reviewer}; treating as request-changes and proceeding to fixing phase.`));
882
- reviewState = 'request-changes';
1001
+ else {
1002
+ // Skip-check found an existing disposition that is not BLOCKED/PARKED (or reLaunch flag).
1003
+ log(fmt.status('INFO', `Round ${attempt}: implementer disposition found (${disposition}). Skipping implementer launch.`));
883
1004
  }
884
- }
885
- log(fmt.status('INFO', `Round ${attempt}: resuming in fixing phase with review outcome = ${reviewState}`));
886
- }
887
-
888
- // Fixing phase
889
- let disposition;
890
- let reLaunch;
891
- let sinceIso = state.startedAt;
892
- if (isContinue && attempt === state.round) {
893
- if (forgejoEnabled) {
894
- log(fmt.status('INFO', `Round ${attempt}: checking for existing disposition by ${implementer} since ${state.startedAt}...`));
895
- disposition = await pollForDispositionFn(prNumber, implementer, state.startedAt, token, {
896
- getLatestDispositionForPrFn, sleepFn, intervalMs: 1000, timeoutMs: CONTINUE_SKIP_CHECK_TIMEOUT_MS, verbose, label: `round ${attempt} skip-check`, log
897
- });
898
- // Skip-check timeout means no existing disposition - treat as null to proceed with initial launch
899
- if (isPollTimeout(disposition)) {
900
- disposition = null;
901
- } else if (isContinue && (disposition === 'BLOCKED' || disposition === 'PARKED')) {
902
- // Stale BLOCKED/PARKED from a previous round: re-launch the implementer
903
- // so they can post a fresh disposition reflecting the current state of the branch.
904
- log(fmt.status('INFO', `Round ${attempt}: implementer disposition found (${disposition}). Re-launching implementer to assess whether blocker is resolved...`));
905
- reLaunch = true;
906
- // Capture a fresh sinceIso so the post-relaunch poll doesn't
907
- // immediately return the same stale disposition. Use a strictly
908
- // newer timestamp than the persisted round start to avoid same-ms
909
- // collisions during fast local test runs.
910
- sinceIso = strictlyLaterIso(state.startedAt);
911
- disposition = null;
1005
+ if (!disposition) {
1006
+ error(fmt.status('FAIL', `Implementer ${implementer} did not post an autonomous review disposition comment.`));
1007
+ exit(1);
1008
+ return;
912
1009
  }
913
- } else {
914
- // When the provider is disabled, use local review state.
915
- log(fmt.status('INFO', `Round ${attempt}: review provider disabled; using workflow-owned disposition state.`));
916
- disposition = null;
917
- }
918
- }
919
-
920
- if (!disposition) {
921
- // First launch or re-launch after stale BLOCKED/PARKED
922
- if (dryRun) {
923
- log(`\n--- DRY-RUN: implementer (${implementer}) act-on-review prompt ---`);
924
- log(buildActOnReviewPromptFn({ implementer, branch, attempt, repoRoot: worktree, missionPath: effectiveMissionPath, actualImplementer: '{{AGENT_NAME}}' }));
925
- if (reLaunch) {
926
- log(fmt.status('INFO', `Round ${attempt}: stale BLOCKED/PARKED disposition replaced by fresh implementer action.`));
1010
+ if ((0, review_polling_js_1.isPollTimeout)(disposition)) {
1011
+ writeReviewStateFn(slug, state, worktree);
1012
+ log(fmt.status('INFO', `Autonomous review stopped: excessive implementer timeout retries`));
1013
+ return;
927
1014
  }
928
- return;
929
- }
930
-
931
- writeReviewStateFn(slug, state, worktree);
932
- transitionTaskFn(slug, 'active', { implementer, rootDir: worktree, log });
933
- if (implementer === 'autonomous' && !forgejoEnabled) {
934
- log(fmt.status('INFO', `Round ${attempt}: implementer identity is autonomous; skipping implementer launch and using local review artifacts only.`));
935
- } else {
936
- log(fmt.status('INFO', `Round ${attempt}: launching implementer (${implementer}) for act-on-review...`));
937
-
938
- let implementerLaunchResult;
939
- try {
940
- implementerLaunchResult = await startAgentFn('act-on-review', {
941
- agent: implementer,
942
- prompt: (/** @type {string} */ actualImplementer) => buildCompactActOnReviewPromptFn({ implementer, branch, attempt, reviewOutcome: reviewState, repoRoot: worktree, missionPath: effectiveMissionPath, actualImplementer }),
943
- worktree, slug, role: 'implementer', exclude: [reviewer]
944
- });
945
- } catch (/** @type {unknown} */ err) {
946
- error(fmt.status('FAIL', `Could not launch implementer agent (${implementer}): ${/** @type {Error} */(err).message}`));
947
- exit(1); return;
1015
+ log(fmt.status('INFO', `Round ${attempt}: implementer disposition = ${disposition}`));
1016
+ if (disposition === 'PUSHBACK_ALL') {
1017
+ state.disposition = disposition;
1018
+ writeReviewStateFn(slug, state, worktree);
1019
+ log(fmt.status('INFO', 'Autonomous review stopped: implementer pushed back on all remaining comments. Hand off to human review.'));
1020
+ return;
948
1021
  }
949
-
950
- implementer = applyAgentFallbackFn({
951
- role: 'implementer', original: implementer, launchResult: implementerLaunchResult,
952
- state, slug, worktree, taskResolution, log, writeReviewStateFn, enforceTaskAssigneeFn
953
- });
954
-
955
- // Record follow-up telemetry immediately after the implementer runs.
956
- // This is intentionally a separate stored phase from the initial
957
- // execute/active launch. The window is bounded to this round's launch
958
- // so each act-on-review round adds only its own usage.
959
- const followUpSinceMs = stageLaunchSinceMs((/** @type {{[key: string]: any}} */ (implementerLaunchResult))?.result);
960
- recordStageStatsSafeFn('active', {
961
- stage: 'follow-up', slug, rootDir: worktree, worktree, implementer, reviewer,
962
- result: (/** @type {{[key: string]: any}} */ (implementerLaunchResult))?.result,
963
- sinceMs: /** @type {number} */ (followUpSinceMs || 0), log, error, state, writeReviewStateFn,
964
- model: resolveAgentModel(/** @type {string} */ (implementer), /** @type {string} */ (worktree)),
965
- });
966
- }
967
-
968
- const implementerArtifacts = await consumeImplementerArtifactsFn(slug, implementer, {
969
- worktree,
970
- tmpDir: artifactDir,
971
- fallbackToTmp: true,
972
- readTokenFn,
973
- getCommentsFn,
974
- postCommentFn,
975
- buildMetadataFooterFn: buildMetadataFooter,
976
- forgejoEnabled,
977
- log,
978
- error
979
- });
980
- if (implementerArtifacts.consumed) {
981
- if (!implementerArtifacts.ok) { exit(1); return; }
982
- disposition = implementerArtifacts.disposition;
983
- }
984
- if (!disposition && forgejoEnabled) {
985
- disposition = await pollForDispositionFn(prNumber, implementer, sinceIso, token, {
986
- getLatestDispositionForPrFn, sleepFn, intervalMs: pollIntervalMs, timeoutMs: pollTimeoutMs, retryCount: 0, verbose, label: `round ${attempt} disposition`, log
987
- });
988
- }
989
-
990
- if (isPollTimeout(disposition)) {
991
- // Timeout recovery loop for implementer
992
- const stateAny = /** @type {{[key: string]: any}} */ (state);
993
- while ((stateAny['implementerRetryCount'] || 0) < 2) {
994
- stateAny['implementerRetryCount'] = (stateAny['implementerRetryCount'] || 0) + 1;
995
- writeReviewStateFn(slug, state, worktree);
996
- const elapsedStr = formatElapsed(Date.now() - Date.parse(/** @type {string} */ (stateAny['startedAt'])));
997
- const recoveryPrompt = `RECOVERY: Implementer disposition timeout after ${elapsedStr}. Please provide a disposition (PUSHBACK_ALL, BLOCKED, PARKED, or continue with fixes) for ${branch}.`;
998
- log(fmt.status('INFO', `Round ${attempt}: relaunching implementer (${implementer}) with recovery prompt (retry ${stateAny['implementerRetryCount']}/3)...`));
999
-
1000
- let relaunchResult;
1001
- try {
1002
- relaunchResult = await startAgentFn('act-on-review', {
1003
- agent: implementer,
1004
- prompt: (/** @type {string} */ actualImplementer) => buildCompactActOnReviewPromptFn({ implementer, branch, attempt, reviewOutcome: reviewState, repoRoot: worktree, missionPath: effectiveMissionPath, actualImplementer }) + '\n\n' + recoveryPrompt,
1005
- worktree, slug, role: 'implementer', exclude: [reviewer]
1006
- });
1007
- } catch (/** @type {unknown} */ err) {
1008
- error(fmt.status('FAIL', `Could not relaunch implementer agent (${implementer}): ${/** @type {Error} */(err).message}`));
1009
- exit(1); return;
1010
- }
1011
-
1012
- implementer = applyAgentFallbackFn({
1013
- role: 'implementer', original: implementer, launchResult: relaunchResult,
1014
- state, slug, worktree, taskResolution, log, writeReviewStateFn, enforceTaskAssigneeFn
1015
- });
1016
-
1017
- const retryArtifacts = await consumeImplementerArtifactsFn(slug, implementer, {
1018
- worktree,
1019
- tmpDir: artifactDir,
1020
- fallbackToTmp: true,
1021
- readTokenFn,
1022
- getCommentsFn,
1023
- postCommentFn,
1024
- buildMetadataFooterFn: buildMetadataFooter,
1025
- forgejoEnabled,
1026
- log,
1027
- error
1028
- });
1029
- if (retryArtifacts.consumed) {
1030
- if (!retryArtifacts.ok) { exit(1); return; }
1031
- disposition = retryArtifacts.disposition;
1032
- }
1033
- if (!disposition && forgejoEnabled) {
1034
- disposition = await pollForDispositionFn(prNumber, implementer, sinceIso, token, {
1035
- getLatestDispositionForPrFn, sleepFn, intervalMs: pollIntervalMs, timeoutMs: pollTimeoutMs, retryCount: state.implementerRetryCount, verbose, label: `round ${attempt} disposition retry ${state.implementerRetryCount}`, log
1036
- });
1037
- }
1038
-
1039
- if (!isPollTimeout(disposition)) {break;}
1022
+ if (disposition === 'BLOCKED' || disposition === 'PARKED') {
1023
+ state.disposition = disposition;
1024
+ writeReviewStateFn(slug, state, worktree);
1025
+ log(fmt.status('INFO', `Autonomous review stopped: implementer reported ${disposition}. Hand off to human review.`));
1026
+ return;
1040
1027
  }
1041
-
1042
- if (isPollTimeout(disposition)) {
1043
- writeReviewStateFn(slug, state, worktree);
1044
- log(fmt.status('INFO', `Autonomous review stopped: excessive implementer timeout retries`));
1045
- return;
1028
+ state.disposition = disposition;
1029
+ try {
1030
+ state.transitionTo('reviewing');
1046
1031
  }
1047
- } else if (!disposition) {
1048
- error(fmt.status('FAIL', `Implementer ${implementer} did not post an autonomous review disposition comment.`));
1049
- exit(1); return;
1050
- }
1051
-
1052
- // Suppress the duplicate message after re-launch
1053
- reLaunch = null;
1054
- } else if (reLaunch) {
1055
- // Re-launch path: stale BLOCKED/PARKED was replaced by fresh implementation.
1056
- // The disposition was set by the re-launch above (lines 767-785).
1057
- // Fall through to disposition handling below.
1058
- } else {
1059
- // Skip-check found an existing disposition that is not BLOCKED/PARKED (or reLaunch flag).
1060
- log(fmt.status('INFO', `Round ${attempt}: implementer disposition found (${disposition}). Skipping implementer launch.`));
1061
- }
1062
-
1063
- if (!disposition) {
1064
- error(fmt.status('FAIL', `Implementer ${implementer} did not post an autonomous review disposition comment.`));
1065
- exit(1); return;
1066
- }
1067
-
1068
- if (isPollTimeout(disposition)) {
1069
- writeReviewStateFn(slug, state, worktree);
1070
- log(fmt.status('INFO', `Autonomous review stopped: excessive implementer timeout retries`));
1071
- return;
1072
- }
1073
-
1074
- log(fmt.status('INFO', `Round ${attempt}: implementer disposition = ${disposition}`));
1075
-
1076
- if (disposition === 'PUSHBACK_ALL') {
1077
- state.disposition = disposition;
1078
- writeReviewStateFn(slug, state, worktree);
1079
- log(fmt.status('INFO', 'Autonomous review stopped: implementer pushed back on all remaining comments. Hand off to human review.'));
1080
- return;
1081
- }
1082
-
1083
- if (disposition === 'BLOCKED' || disposition === 'PARKED') {
1084
- state.disposition = disposition;
1085
- writeReviewStateFn(slug, state, worktree);
1086
- log(fmt.status('INFO', `Autonomous review stopped: implementer reported ${disposition}. Hand off to human review.`));
1087
- return;
1032
+ catch (_) { /* ignore */ }
1033
+ writeReviewStateFn(slug, state, worktree);
1034
+ log(fmt.status('INFO', `Round ${attempt}: implementer made changes. Continuing to round ${attempt + 1}.`));
1088
1035
  }
1089
-
1090
- state.disposition = disposition;
1091
- try { state.transitionTo('reviewing'); } catch (_) {}
1092
- writeReviewStateFn(slug, state, worktree);
1093
- log(fmt.status('INFO', `Round ${attempt}: implementer made changes. Continuing to round ${attempt + 1}.`));
1094
- }
1095
-
1096
- log(fmt.status('INFO', `Autonomous review stopped: reached ${maxAttempts} attempts. Hand off to human review.`));
1036
+ log(fmt.status('INFO', `Autonomous review stopped: reached ${maxAttempts} attempts. Hand off to human review.`));
1097
1037
  }
1098
-
1099
- // ============================================================================
1100
- // Module Exports
1101
- // ============================================================================
1102
-
1103
- module.exports = {
1104
- maybeUpdateGraphifyBeforeReview,
1105
- commitSafeMissionArtifacts,
1106
- rebaseBeforeReviewRound,
1107
- applyAgentFallback,
1108
- persistNormalizedPhaseRepair,
1109
- stageLaunchSinceMs,
1110
- startReviewLoop,
1111
- recordStageStatsSafe
1112
- };