@magnusekdahl/parallix 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/CHANGELOG.md +140 -0
  2. package/LICENSE +661 -0
  3. package/README.md +196 -0
  4. package/config/agents.json +25 -0
  5. package/config/agents.local.json.template +8 -0
  6. package/config/state-map.json +4 -0
  7. package/config/state-map.json.template +31 -0
  8. package/config/workflow.config.schema.json +98 -0
  9. package/data/.gitkeep +0 -0
  10. package/docs/adr/0031-ai-agent-instruction-boundary-and-command-floor.md +114 -0
  11. package/docs/adr/0032-mission-refinement-state-and-usage-budget-signals.md +135 -0
  12. package/docs/adr/0034-module-and-skill-invocation-model.md +202 -0
  13. package/docs/adr/0036-mission-sizing-and-dependency-wave-heuristics.md +79 -0
  14. package/docs/adr/0037-ai-workflow-coordination-architecture.md +162 -0
  15. package/docs/adr/0041-integration-pipeline-gates.md +165 -0
  16. package/docs/adr/0042-workflow-cli-color-rendering-approach.md +106 -0
  17. package/docs/adr/0043-git-target-resolution-strategy.md +185 -0
  18. package/docs/adr/0044-workflow-distribution-model.md +277 -0
  19. package/docs/adr/0045-parallax-branch-model.md +182 -0
  20. package/docs/adr/0046-npm-publish-process-and-security.md +138 -0
  21. package/docs/adr/index.md +20 -0
  22. package/docs/agents.md +212 -0
  23. package/docs/authority-reference.md +298 -0
  24. package/docs/forgejo-setup.md +31 -0
  25. package/docs/migration/extraction.md +61 -0
  26. package/docs/migration/task-classification.md +36 -0
  27. package/docs/operator-setup.md +76 -0
  28. package/docs/readme-rewrite-benchmark.md +188 -0
  29. package/docs/use-cases.md +105 -0
  30. package/examples/README.md +62 -0
  31. package/examples/run-enterprise-tarball-workflow-smoke.sh +257 -0
  32. package/examples/run-verify-env-smoke.sh +40 -0
  33. package/index.js +250 -0
  34. package/lib/README.md +13 -0
  35. package/lib/agents/agents.js +867 -0
  36. package/lib/agents/claude-telemetry.js +233 -0
  37. package/lib/agents/claude.js +139 -0
  38. package/lib/agents/codex-telemetry.js +202 -0
  39. package/lib/agents/codex.js +219 -0
  40. package/lib/agents/limit-hit.js +252 -0
  41. package/lib/agents/mistral-telemetry.js +44 -0
  42. package/lib/agents/mistral.js +68 -0
  43. package/lib/agents/opencode-export.js +110 -0
  44. package/lib/agents/opencode-telemetry.js +356 -0
  45. package/lib/agents/opencode.js +218 -0
  46. package/lib/agents/stage-telemetry.js +37 -0
  47. package/lib/commands/active.js +625 -0
  48. package/lib/commands/checkpoint.js +76 -0
  49. package/lib/commands/config.js +39 -0
  50. package/lib/commands/coverage-gate.js +358 -0
  51. package/lib/commands/diff.js +119 -0
  52. package/lib/commands/draft.js +854 -0
  53. package/lib/commands/handoff.js +501 -0
  54. package/lib/commands/integrate.js +1528 -0
  55. package/lib/commands/mission-start.js +246 -0
  56. package/lib/commands/rebase.js +597 -0
  57. package/lib/commands/repair-handoff.js +227 -0
  58. package/lib/commands/resolve-conflict.js +109 -0
  59. package/lib/commands/review.js +13 -0
  60. package/lib/commands/setup-review.js +13 -0
  61. package/lib/commands/setup.js +3 -0
  62. package/lib/commands/stats-backfill.js +395 -0
  63. package/lib/commands/stats.js +1601 -0
  64. package/lib/commands/status.js +183 -0
  65. package/lib/commands/verify.js +1 -0
  66. package/lib/core/fmt.js +202 -0
  67. package/lib/core/git.js +73 -0
  68. package/lib/core/gitignore.js +110 -0
  69. package/lib/core/mission-utils.js +1017 -0
  70. package/lib/core/persistent-data-migration.js +201 -0
  71. package/lib/core/product-config.js +508 -0
  72. package/lib/core/runtime-matrix.js +82 -0
  73. package/lib/core/spawn-tee.js +173 -0
  74. package/lib/core/state-map.js +89 -0
  75. package/lib/core/storage.js +165 -0
  76. package/lib/core/verification.js +149 -0
  77. package/lib/index.js +77 -0
  78. package/lib/review/rebase.js +163 -0
  79. package/lib/review/review-adapter.js +135 -0
  80. package/lib/review/review-artifacts.js +619 -0
  81. package/lib/review/review-commands.js +1375 -0
  82. package/lib/review/review-events.js +1007 -0
  83. package/lib/review/review-loop.js +1004 -0
  84. package/lib/review/review-polling.js +141 -0
  85. package/lib/review/review-prompts.js +212 -0
  86. package/lib/review/review-state.js +280 -0
  87. package/lib/review/review.js +96 -0
  88. package/lib/tools/backlog.js +680 -0
  89. package/lib/tools/forgejo.js +1585 -0
  90. package/lib/tools/gatekeeper.js +106 -0
  91. package/lib/tools/sessions.js +74 -0
  92. package/lib/tools/setup-review.js +1053 -0
  93. package/package.json +56 -0
  94. package/prompts/act-on-review-verbose.md +20 -0
  95. package/prompts/act-on-review.md +22 -0
  96. package/prompts/draft.md +20 -0
  97. package/prompts/execute.md +24 -0
  98. package/prompts/portfolio.md +30 -0
  99. package/prompts/review-verbose.md +20 -0
  100. package/prompts/review.md +17 -0
  101. package/px.js +236 -0
  102. package/templates/AGENTS-snippet.md +14 -0
  103. package/templates/AGENTS.md.template +34 -0
  104. package/templates/CLAUDE.md.template +27 -0
  105. package/templates/CODEX.md.template +38 -0
  106. package/templates/MISTRAL.md.template +24 -0
  107. package/templates/claude-commands/act-on-review.md +3 -0
  108. package/templates/claude-commands/area-review.md +3 -0
  109. package/templates/claude-commands/draft.md +6 -0
  110. package/templates/claude-commands/execute.md +6 -0
  111. package/templates/claude-commands/integrate.md +4 -0
  112. package/templates/claude-commands/portfolio.md +5 -0
  113. package/templates/claude-commands/review.md +4 -0
  114. package/templates/codex/config.toml +6 -0
  115. package/templates/mission-scaffold.md +39 -0
  116. package/templates/vibe/skills/act-on-review/SKILL.md +16 -0
  117. package/templates/vibe/skills/area-review/SKILL.md +16 -0
  118. package/templates/vibe/skills/draft/SKILL.md +16 -0
  119. package/templates/vibe/skills/execute/SKILL.md +16 -0
  120. package/templates/vibe/skills/integrate/SKILL.md +16 -0
  121. package/templates/vibe/skills/portfolio/SKILL.md +21 -0
  122. package/templates/vibe/skills/review/SKILL.md +16 -0
  123. package/tools/setup-forgejo-docker.sh +84 -0
@@ -0,0 +1,1375 @@
1
+ /**
2
+ * Review Commands Module
3
+ * Extracted from parallix/lib/review.js for task-1201
4
+ * Handles command dispatching and CLI command implementations.
5
+ */
6
+
7
+ const fs = require('fs');
8
+ const path = require('path');
9
+ const fmt = require('../core/fmt');
10
+ const { git, run, getCurrentBranch } = require('../core/git');
11
+ const { findMissionDir, findMissionArea, resolveWorktree, inferSlug, updateGraphifyKnowledgeGraph, findCheckpoints, isMissionArtifact, isWorkflowGeneratedArtifact, missionBranchName, missionBaseDir } = require('../core/mission-utils');
12
+ const { resolveTaskFile, getTaskStatus, getAcceptanceCriteria, setTaskStatus, setTaskAssignee, enforceTaskAssignee, getTaskAssignee, getTaskImplementer, reportTaskResolution, transitionTask } = require('../tools/backlog');
13
+ const { toVirtual } = require('../core/state-map');
14
+ const { getPrStatus, readToken, getLatestReviewForPr, getLatestDispositionForPr, postComment, postReview, createPr, providerAvailable, getComments, closePr, resolveReviewUser, isProviderEnabled } = require('./review-adapter');
15
+ const { buildAutonomousReviewMatrix, formatMatrixSummary } = require('../core/runtime-matrix');
16
+ const { buildCompactReviewPrompt, buildCompactActOnReviewPrompt, buildReviewPrompt, buildActOnReviewPrompt } = require('./review-prompts');
17
+ const { ReviewState, readReviewState, writeReviewState, resetReviewState, VALID_PHASES, resolveReviewIdentity } = require('./review-state');
18
+ const { createEvent, importLegacyArtifact, importAllLegacyArtifacts, consumeHumanNotes,
19
+ VALID_EVENT_TYPES, ALL_EVENT_TYPES, LEGACY_ARTIFACT_TO_EVENT_TYPE,
20
+ isValidEventType, shouldMirrorToProvider, readAllEvents } = require('./review-events');
21
+ const { assertAgentSupported, workflowLauncherStatus, startAgent, eligibleAgentsForStep, selectAgent } = require('../agents/agents');
22
+ const { performHandoff } = require('../commands/handoff');
23
+ const { formatVerificationCommand, runVerificationGate } = require('../core/verification');
24
+ const { bootstrapReviewSurface } = require('../tools/setup-review');
25
+ const { resolveReviewAdapter } = require('../core/product-config');
26
+
27
+ // Import from review-polling module
28
+ const { POLL_TIMEOUT, delay, resolvePollIntervalMs, resolvePollTimeoutMs, formatElapsed, isPollTimeout, pollForReview, pollForDisposition } = require('./review-polling');
29
+ // Import from review-artifacts module
30
+ const { buildMetadataFooter, reviewArtifactPath, readArtifactFile, deleteArtifactFile, normalizeReviewVerdict, normalizeDisposition, postWorkflowComment, postWorkflowReview, consumeReviewerArtifacts, consumeImplementerArtifacts } = require('./review-artifacts');
31
+
32
+ const DEFAULT_MAX_ATTEMPTS = 5;
33
+ const CONTINUE_SKIP_CHECK_TIMEOUT_MS = 10_000;
34
+ const SHARED_FILE_REBASE_CONFLICT_RE = /shared file(?:\(s\))? require agent-assisted resolution|shared-file-conflicts/i;
35
+
36
+ // ============================================================================
37
+ // Internal Helpers
38
+ // ============================================================================
39
+
40
+ function flagValue(args, flag) {
41
+ const idx = args.indexOf(flag);
42
+ if (idx === -1) return null;
43
+ const val = args[idx + 1];
44
+ if (!val || val.startsWith('--')) return null;
45
+ return val;
46
+ }
47
+
48
+ function readTextFlag(args, inlineFlag, fileFlag, label, options = {}) {
49
+ const readFileSync = options.readFileSync || fs.readFileSync;
50
+ const error = options.error || fmt.log.plainError;
51
+ const exit = options.exit || process.exit;
52
+ const filePath = flagValue(args, fileFlag);
53
+ if (filePath) {
54
+ try {
55
+ return readFileSync(filePath, 'utf8').trimEnd();
56
+ } catch (err) {
57
+ error(`Could not read ${label} from ${fmt.path(filePath)}: ${err.message}`);
58
+ exit(1);
59
+ return null;
60
+ }
61
+ }
62
+
63
+ return flagValue(args, inlineFlag);
64
+ }
65
+
66
+ function formatStaticReviewFindings(findings) {
67
+ const lines = [
68
+ 'Static review found the following issue(s) before autonomous review:',
69
+ ''
70
+ ];
71
+ findings.forEach((finding, index) => {
72
+ lines.push(`${index + 1}. ${finding}`);
73
+ });
74
+ lines.push('', 'Auto-launching the act-on-review loop for follow-up.');
75
+ return lines.join('\n');
76
+ }
77
+
78
+ function formatStaticReviewSuccess(slug) {
79
+ return [
80
+ `Static review for ${slug} found zero issues.`,
81
+ '',
82
+ 'Checked:',
83
+ '- mission diff against the primary branch',
84
+ '- checkpoint presence',
85
+ '- final checkpoint Goal Check evidence',
86
+ '',
87
+ 'Mission remains in `review` status awaiting an actual autonomous or peer review verdict.'
88
+ ].join('\n');
89
+ }
90
+
91
+ function repairStaleActiveTaskAfterReview(slug, options = {}) {
92
+ const log = options.log || fmt.log.plain;
93
+ const error = options.error || fmt.log.plainError;
94
+ const getTaskStatusFn = options.getTaskStatusFn || getTaskStatus;
95
+ const resolveTaskFileFn = options.resolveTaskFileFn || resolveTaskFile;
96
+ const transitionTaskFn = options.transitionTaskFn || transitionTask;
97
+ const rootDir = options.rootDir || process.cwd();
98
+
99
+ const taskResolution = resolveTaskFileFn(slug, rootDir);
100
+ if (!taskResolution.ok) {
101
+ return { repaired: false, skipped: true };
102
+ }
103
+
104
+ const currentStatus = getTaskStatusFn(taskResolution.taskFile);
105
+ if (toVirtual(currentStatus) !== 'active') {
106
+ return { repaired: false, skipped: true, currentStatus };
107
+ }
108
+
109
+ if (!transitionTaskFn(slug, 'review', { rootDir, log })) {
110
+ error(fmt.status('WARN', `Could not transition backlog task ${slug} to review after recording the review outcome.`));
111
+ return { repaired: false, skipped: false, currentStatus };
112
+ }
113
+
114
+ return { repaired: true, currentStatus };
115
+ }
116
+
117
+ async function commitPersistedReviewOutputs(slug, options = {}) {
118
+ const { commitSafeMissionArtifacts } = require('./review-loop');
119
+ return commitSafeMissionArtifacts(slug, options.worktree || process.cwd(), {
120
+ taskFile: options.taskFile || null,
121
+ log: options.log || fmt.log.plain,
122
+ error: options.error || fmt.log.plainError,
123
+ });
124
+ }
125
+
126
+ function postStaticReviewComment(slug, message, options = {}) {
127
+ const log = options.log || fmt.log.plain;
128
+ const error = options.error || fmt.log.plainError;
129
+ const resolveTaskFileFn = options.resolveTaskFileFn || resolveTaskFile;
130
+ const getTaskImplementerFn = options.getTaskImplementerFn || getTaskImplementer;
131
+ const resolveWorktreeFn = options.resolveWorktreeFn || resolveWorktree;
132
+ const readTokenFn = options.readTokenFn || readToken;
133
+ const postCommentFn = options.postCommentFn || postComment;
134
+ const resolveReviewUserFn = options.resolveReviewUserFn || options.resolveForgejoUserFn || resolveReviewUser;
135
+ const readReviewStateFn = options.readReviewStateFn || readReviewState;
136
+ const buildMetadataFooterFn = options.buildMetadataFooterFn || buildMetadataFooter;
137
+
138
+ const rootDir = options.rootDir || resolveWorktreeFn(slug) || process.cwd();
139
+ const branch = missionBranchName(slug, rootDir);
140
+ const { identityUser } = resolveReviewIdentity(slug, rootDir, {
141
+ readReviewStateFn,
142
+ });
143
+ let resolvedUser = identityUser;
144
+
145
+ if (!resolvedUser) {
146
+ const taskResolution = resolveTaskFileFn(slug, rootDir);
147
+ if (taskResolution.ok) {
148
+ resolvedUser = getTaskImplementerFn(taskResolution.taskFile);
149
+ }
150
+ }
151
+ if (!resolvedUser) {
152
+ error('Cannot determine review identity. Set FORGEJO_USER, persist review-state.json, or assign the task implementer.');
153
+ return { ok: false, error: 'missing-user' };
154
+ }
155
+ resolvedUser = resolveReviewUserFn(resolvedUser);
156
+ const token = readTokenFn(resolvedUser, { rootDir });
157
+ if (!token) {
158
+ error(`No Forgejo token found for user "${resolvedUser}". Cannot post static review comment.`);
159
+ return { ok: false, error: 'missing-token' };
160
+ }
161
+
162
+ const taggedMessage = message + buildMetadataFooterFn(slug, rootDir);
163
+ log(`Posting static review comment on ${fmt.branch(branch)} as ${resolvedUser}...`);
164
+ const result = postCommentFn(branch, token, taggedMessage, { reviewIdentity: resolvedUser, forgejoUser: resolvedUser });
165
+
166
+ if (!result.ok) {
167
+ error(`Could not post static review comment: ${result.error || 'API error'}`);
168
+ return result;
169
+ }
170
+
171
+ log(fmt.status('PASS', `Static review comment posted on PR for ${fmt.branch(branch)}.`));
172
+ return result;
173
+ }
174
+
175
+ // ============================================================================
176
+ // Command: performStaticReview
177
+ // ============================================================================
178
+
179
+ /**
180
+ * Perform a static review of the mission branch when no PR exists.
181
+ * Inspects git diff for changed files, checks the final checkpoint for
182
+ * a Goal Check table, and returns findings.
183
+ */
184
+ function performStaticReview(slug, options = {}) {
185
+ const log = options.log || fmt.log.plain;
186
+ const error = options.error || fmt.log.plainError;
187
+ const findMissionDirFn = options.findMissionDir || findMissionDir;
188
+ const findCheckpointsFn = options.findCheckpoints || findCheckpoints;
189
+ const readFileSyncFn = options.readFileSync || fs.readFileSync;
190
+ const runFn = options.run || run;
191
+ const resolveWorktreeFn = options.resolveWorktree || resolveWorktree;
192
+ const findings = [];
193
+
194
+ log(`Performing static review for mission: ${fmt.slug(slug)}`);
195
+
196
+ // Resolve the worktree root early so mission dir and checkpoint lookups use the same base
197
+ const worktreeRoot = resolveWorktreeFn(slug);
198
+ const rootDir = worktreeRoot || process.cwd();
199
+
200
+ // Check if mission directory exists (with optional --mission path override)
201
+ const missionDir = findMissionDirFn(slug, rootDir, { missionPath: options.missionPath });
202
+ if (!missionDir) {
203
+ findings.push(`Mission directory not found for slug: ${slug}`);
204
+ return { ok: false, findings };
205
+ }
206
+ log(fmt.status('PASS', `Mission directory found: ${fmt.path(missionDir)}`));
207
+
208
+ // Check if checkpoint documents exist
209
+ const checkpoints = findCheckpointsFn(missionDir);
210
+ if (checkpoints.length === 0) {
211
+ findings.push('No checkpoint documents found. Implementation evidence is required.');
212
+ return { ok: false, findings };
213
+ }
214
+ log(fmt.status('PASS', `Found ${checkpoints.length} checkpoint document(s).`));
215
+
216
+ // Check the final checkpoint for a Goal Check table
217
+ const finalCheckpoint = checkpoints[checkpoints.length - 1];
218
+ try {
219
+ const checkpointContent = readFileSyncFn(finalCheckpoint, 'utf8');
220
+ const goalCheckMatch = checkpointContent.match(/^## Goal Check(?: Table)?\s*$/m);
221
+ if (!goalCheckMatch) {
222
+ findings.push(`Final checkpoint ${path.basename(finalCheckpoint)} is missing a "## Goal Check" section.`);
223
+ } else {
224
+ log(fmt.status('PASS', 'Final checkpoint contains "## Goal Check" section.'));
225
+
226
+ // Verify goal-check table has at least one evidence row
227
+ const afterHeader = checkpointContent.slice(goalCheckMatch.index + goalCheckMatch[0].length);
228
+ const separatorPattern = /^\|\s*:?-+:?\s*\|/;
229
+ const headerPattern = /^\| .+\| .+\| .+\|$/;
230
+ const evidenceLinePattern = /^\| .+\| .+\| .+\|$/;
231
+ const linesAfterHeader = afterHeader.split('\n');
232
+ let foundEvidence = false;
233
+ let pastHeader = false;
234
+ for (const line of linesAfterHeader) {
235
+ const trimmed = line.trim();
236
+ if (trimmed === '') continue;
237
+ if (!pastHeader && headerPattern.test(trimmed)) {
238
+ pastHeader = true;
239
+ continue;
240
+ }
241
+ if (separatorPattern.test(trimmed)) continue;
242
+ if (pastHeader && evidenceLinePattern.test(trimmed)) {
243
+ foundEvidence = true;
244
+ break;
245
+ }
246
+ break;
247
+ }
248
+ if (!foundEvidence) {
249
+ findings.push(`Final checkpoint ${path.basename(finalCheckpoint)} has "## Goal Check" section but no evidence rows.`);
250
+ } else {
251
+ log(fmt.status('PASS', 'Goal Check table contains evidence rows.'));
252
+ }
253
+ }
254
+ } catch (err) {
255
+ findings.push(`Could not read final checkpoint ${path.basename(finalCheckpoint)}: ${err.message}`);
256
+ }
257
+
258
+ // Inspect git diff for changed files
259
+ const baseBranch = require('../core/mission-utils').getPrimaryBranch(rootDir);
260
+ const diffResult = runFn('git', ['diff', `${baseBranch}..HEAD`, '--name-only'], { cwd: rootDir });
261
+ if (diffResult.status === 0 && diffResult.stdout) {
262
+ const changedFiles = diffResult.stdout.trim().split('\n').filter(f => f.trim());
263
+ const missionDirPrefix = path.relative(rootDir, missionBaseDir(rootDir)).split(path.sep).join('/') + '/';
264
+ log(`Changed files in branch: ${changedFiles.join(', ')}`);
265
+ // Check for unexpected areas — missions may legitimately touch many surfaces.
266
+ // Derive allowed set from known workflow areas plus common repo structures,
267
+ // plus any file with a recognized extension or inside the mission directory.
268
+ const knownAreas = [
269
+ 'parallix/', 'docs/', 'scripts/', 'config/', 'backlog/', 'forgejo/',
270
+ '.agents/', '.github/', '.vscode/', '.graphifyignore',
271
+ ];
272
+ const knownExtensions = ['.sh', '.csv', '.json', '.yaml', '.yml', '.toml', '.lock', '.cfg', '.ini', '.env', '.txt', '.properties', '.sql', '.css', '.html', '.xml', '.png', '.jpg', '.jpeg', '.gif', '.svg', '.ico', '.pdf', '.tar', '.gz', '.zip'];
273
+ const unexpectedFiles = changedFiles.filter(f =>
274
+ !knownAreas.some(area => f.startsWith(area)) &&
275
+ !f.startsWith(missionDirPrefix) &&
276
+ !f.endsWith('.md') &&
277
+ !knownExtensions.some(ext => f.toLowerCase().endsWith(ext))
278
+ );
279
+ if (unexpectedFiles.length > 0) {
280
+ log(fmt.status('WARN', `Changed files outside known areas (may be intentional): ${unexpectedFiles.join(', ')}`));
281
+ } else {
282
+ log(fmt.status('PASS', 'All changed files are within expected areas.'));
283
+ }
284
+ } else {
285
+ log(fmt.status('WARN', `git diff ${baseBranch}..HEAD returned no output or failed — branch may be up to date with ${baseBranch}.`));
286
+ }
287
+
288
+ return { ok: findings.length === 0, findings };
289
+ }
290
+
291
+ // ============================================================================
292
+ // Command: verifyReview
293
+ // ============================================================================
294
+
295
+ function verifyReview(slug, skipGate, options = {}) {
296
+ const log = options.log || fmt.log.plain;
297
+ const error = options.error || fmt.log.plainError;
298
+ const exit = options.exit || process.exit;
299
+ const resolveWorktreeFn = options.resolveWorktreeFn || resolveWorktree;
300
+ const findMissionDirFn = options.findMissionDirFn || findMissionDir;
301
+ const getCurrentBranchFn = options.getCurrentBranchFn || getCurrentBranch;
302
+ const resolveTaskFileFn = options.resolveTaskFileFn || resolveTaskFile;
303
+ const getPrStatusFn = options.getPrStatusFn || getPrStatus;
304
+ const getTaskStatusFn = options.getTaskStatusFn || getTaskStatus;
305
+ const toVirtualFn = options.toVirtualFn || toVirtual;
306
+ const findMissionAreaFn = options.findMissionAreaFn || findMissionArea;
307
+ const runFn = options.runFn || run;
308
+ const getAcceptanceCriteriaFn = options.getAcceptanceCriteriaFn || getAcceptanceCriteria;
309
+ const formatMatrixSummaryFn = options.formatMatrixSummaryFn || formatMatrixSummary;
310
+ const buildAutonomousReviewMatrixFn = options.buildAutonomousReviewMatrixFn || buildAutonomousReviewMatrix;
311
+ const readReviewStateFn = options.readReviewStateFn || readReviewState;
312
+ const isReviewProviderEnabledFn = options.isReviewProviderEnabledFn || options.isForgejoReviewEnabledFn || isProviderEnabled;
313
+ const cwdFn = options.cwdFn || (() => process.cwd());
314
+
315
+ const worktree = resolveWorktreeFn(slug);
316
+ const rootDir = worktree || cwdFn();
317
+
318
+ const missionDir = findMissionDirFn(slug, rootDir, { missionPath: options.missionPath });
319
+ const branch = missionBranchName(slug, rootDir);
320
+ const current = getCurrentBranchFn(rootDir);
321
+ const taskResolution = resolveTaskFileFn(slug, rootDir);
322
+ const providerEnabled = isReviewProviderEnabledFn(rootDir);
323
+ const pr = providerEnabled ? getPrStatusFn(branch, rootDir) : { exists: false };
324
+ const failures = [];
325
+ const warnings = [];
326
+
327
+ log(`Reviewer verification for mission: ${fmt.slug(slug)}`);
328
+ if (worktree) {
329
+ log(`Found dedicated worktree: ${fmt.path(worktree)}`);
330
+ } else {
331
+ log('Using current directory as mission root.');
332
+ }
333
+
334
+ if (!missionDir) {
335
+ failures.push('mission-dir');
336
+ log(fmt.status('FAIL', `Mission directory not found for slug: ${fmt.slug(slug)}`));
337
+ } else {
338
+ log(fmt.status('PASS', `Mission doc: ${fmt.path(path.join(missionDir, 'MISSION.md'))}`));
339
+ }
340
+
341
+ if (current !== branch) {
342
+ failures.push('branch');
343
+ log(fmt.status('FAIL', `Branch: current branch is ${fmt.branch(current)}, expected ${fmt.branch(branch)}`));
344
+ } else {
345
+ log(fmt.status('PASS', `Branch: ${fmt.branch(current)}`));
346
+ }
347
+
348
+ let taskStatus = null;
349
+ let virtualStatus = null;
350
+ if (!taskResolution.ok) {
351
+ failures.push('task');
352
+ reportTaskResolution(taskResolution, slug, log);
353
+ } else {
354
+ taskStatus = getTaskStatusFn(taskResolution.taskFile);
355
+ virtualStatus = toVirtualFn(taskStatus);
356
+ if (taskStatus === 'review' || virtualStatus === 'approved') {
357
+ log(fmt.status('PASS', `Backlog task: ${path.basename(taskResolution.taskFile)} (${taskStatus})`));
358
+ } else if (taskStatus === 'done') {
359
+ failures.push('task-status');
360
+ log(fmt.status('FAIL', 'Backlog task: task is already done/integrated'));
361
+ } else if (taskStatus === 'active' || virtualStatus === 'active') {
362
+ warnings.push('task-still-active');
363
+ log(fmt.status('WARN', `Backlog task: ${path.basename(taskResolution.taskFile)} is still ${taskStatus}`));
364
+ } else {
365
+ failures.push('task-status');
366
+ log(fmt.status('FAIL', `Backlog task: unexpected status ${taskStatus}`));
367
+ }
368
+ }
369
+
370
+ if (providerEnabled) {
371
+ if (pr.exists && pr.state === 'open' && !pr.merged) {
372
+ log(fmt.status('PASS', `Review PR: PR #${pr.number} is open`));
373
+ } else if (pr.exists) {
374
+ failures.push('pr-state');
375
+ log(fmt.status('FAIL', `Review PR: expected an open PR, got state=${pr.state} merged=${pr.merged}`));
376
+ } else {
377
+ // No PR exists - check if task is in implementation phase
378
+ if (taskResolution.ok) {
379
+ const isImplementationPhase = taskStatus === 'active' || virtualStatus === 'active';
380
+ if (isImplementationPhase) {
381
+ // Task is still in implementation - emit warning instead of failure
382
+ warnings.push('no-pr-yet');
383
+ log(fmt.status('WARN', `Review PR: no PR found for ${branch}. Task is still ${taskStatus} — complete implementation and submit first: px review ${slug} --push`));
384
+ } else {
385
+ // Task is in post-implementation state or ambiguous - hard fail
386
+ failures.push('pr-missing');
387
+ log(fmt.status('FAIL', `Review PR: ${pr.raw || 'no PR found'}`));
388
+ }
389
+ } else {
390
+ // Cannot determine task status - safe default to hard fail
391
+ failures.push('pr-missing');
392
+ log(fmt.status('FAIL', `Review PR: ${pr.raw || 'no PR found'}`));
393
+ }
394
+ }
395
+ } else {
396
+ log(fmt.status('INFO', 'Forgejo PR: skipped (review provider is not forgejo).'));
397
+ }
398
+
399
+ if (missionDir) {
400
+ const area = findMissionAreaFn(missionDir);
401
+ const skipGateFlag = Boolean(skipGate || options.skipGate);
402
+ if (skipGateFlag) {
403
+ log(fmt.status('WARN', `Verification gate skipped (--no-gate) for area ${area}`));
404
+ } else {
405
+ log(`Running reviewer gate: ${fmt.command(formatVerificationCommand(area, process.cwd()))}`);
406
+ const verifyResult = runVerificationGate(area, { rootDir: process.cwd(), stdio: 'inherit', runFn });
407
+ if (verifyResult.status !== 0) {
408
+ failures.push('gate');
409
+ log(fmt.status('FAIL', 'Reviewer gate failed.'));
410
+ } else {
411
+ log(fmt.status('PASS', 'Reviewer gate passed.'));
412
+ }
413
+ }
414
+ }
415
+
416
+ if (taskResolution.ok) {
417
+ const acceptanceCriteria = getAcceptanceCriteriaFn(taskResolution.taskFile);
418
+ log(fmt.status('INFO', 'Acceptance evidence checklist:'));
419
+ if (acceptanceCriteria.length === 0) {
420
+ log(' - No Acceptance Criteria found on the Backlog task.');
421
+ } else {
422
+ acceptanceCriteria.forEach(line => log(` ${line}`));
423
+ }
424
+ }
425
+
426
+ log(fmt.status('INFO', 'Autonomous review runtime matrix:'));
427
+ formatMatrixSummaryFn(buildAutonomousReviewMatrixFn()).forEach(line => log(line));
428
+
429
+ // Show persisted reviewer state if present
430
+ const persisted = readReviewStateFn(slug);
431
+ if (persisted) {
432
+ log(`Persisted reviewer state: reviewer=${fmt.agent(persisted.reviewer)} implementer=${fmt.agent(persisted.implementer)} round=${persisted.round} startedAt=${persisted.startedAt}`);
433
+ }
434
+
435
+ if (warnings.length > 0) {
436
+ log(fmt.status('WARN', `Review verification warnings: ${warnings.join(', ')}`));
437
+ }
438
+
439
+ if (failures.length > 0) {
440
+ error('\n' + fmt.status('INFO', 'Review verification failed. Resolve the blockers above before starting review.'));
441
+ exit(1);
442
+ return;
443
+ }
444
+
445
+ log('\n' + fmt.status('PASS', 'Review verification complete.'));
446
+ }
447
+
448
+ // ============================================================================
449
+ // Command: submitForReview
450
+ // ============================================================================
451
+
452
+ async function submitForReview(slug, skipGate, options = {}) {
453
+ const exit = options.exit || process.exit;
454
+ const resolveTaskFileFn = options.resolveTaskFileFn || resolveTaskFile;
455
+ const getTaskImplementerFn = options.getTaskImplementerFn || getTaskImplementer;
456
+ const resolveWorktreeFn = options.resolveWorktreeFn || resolveWorktree;
457
+ const performHandoffFn = options.performHandoffFn || performHandoff;
458
+ const resolveReviewUserFn = options.resolveReviewUserFn || options.resolveForgejoUserFn || resolveReviewUser;
459
+ const readReviewStateFn = options.readReviewStateFn || readReviewState;
460
+ const isReviewProviderEnabledFn = options.isReviewProviderEnabledFn || options.isForgejoReviewEnabledFn || isProviderEnabled;
461
+ const log = options.log || fmt.log.plain;
462
+
463
+ const worktree = resolveWorktreeFn(slug) || process.cwd();
464
+ const providerEnabled = isReviewProviderEnabledFn(worktree);
465
+
466
+ const { identityUser: reviewStateUser } = resolveReviewIdentity(slug, worktree, {
467
+ readReviewStateFn,
468
+ });
469
+ let reviewIdentity = reviewStateUser;
470
+
471
+ // 2. Check backlog task assignee
472
+ if (!reviewIdentity) {
473
+ const taskResolution = resolveTaskFileFn(slug, worktree);
474
+ if (taskResolution.ok) {
475
+ reviewIdentity = getTaskImplementerFn(taskResolution.taskFile);
476
+ }
477
+ }
478
+
479
+ // 3. Mode-specific final fallback: named identity (provider-backed) vs "autonomous" (provider=none) (SC 6)
480
+ if (!reviewIdentity) {
481
+ if (providerEnabled) {
482
+ log(fmt.status('FAIL', `No review identity resolved for ${slug}. Persist review-state.json or set the task implementer before submitting for review.`));
483
+ exit(1);
484
+ return;
485
+ } else {
486
+ reviewIdentity = 'autonomous';
487
+ log(fmt.status('WARN', `No reviewer/implementer identity resolved for ${slug}; defaulting to "autonomous"`));
488
+ }
489
+ }
490
+
491
+ const result = await performHandoffFn(slug, { skipGate, reviewIdentity, forgejoUser: reviewIdentity, worktree });
492
+ if (!result.ok) {
493
+ exit(1);
494
+ }
495
+ }
496
+
497
+ // ============================================================================
498
+ // Command: readComments
499
+ // ============================================================================
500
+
501
+ async function readComments(slug, options = {}) {
502
+ const log = options.log || fmt.log.plain;
503
+ const error = options.error || fmt.log.plainError;
504
+ const exit = options.exit || process.exit;
505
+ const readTokenFn = options.readTokenFn || readToken;
506
+ const readReviewStateFn = options.readReviewStateFn || readReviewState;
507
+ const resolveReviewUserFn = options.resolveReviewUserFn || options.resolveForgejoUserFn || resolveReviewUser;
508
+ const getCommentsFn = options.getCommentsFn || getComments;
509
+ const readAllEventsFn = options.readAllEventsFn || readAllEvents;
510
+ const isReviewProviderEnabledFn = options.isReviewProviderEnabledFn || options.isForgejoReviewEnabledFn || isProviderEnabled;
511
+ const worktree = options.worktree || resolveWorktree(slug) || process.cwd();
512
+ const branch = missionBranchName(slug, worktree);
513
+
514
+ // Provider-disabled fallback: read persisted local review events instead of polling the PR.
515
+ // Avoids resolving a provider token (which would FAIL/exit) when the provider is off.
516
+ if (!isReviewProviderEnabledFn(worktree)) {
517
+ log(fmt.status('INFO', `Review provider disabled; reading local review events for ${slug}...`));
518
+ const events = readAllEventsFn(slug, { rootDir: worktree, log, error });
519
+ if (!events || events.length === 0) {
520
+ log('no comments');
521
+ return;
522
+ }
523
+ for (const e of events) {
524
+ let label = e.event_type || 'event';
525
+ if (e.round != null) label += ` round ${e.round}`;
526
+ log(`--- ${label} | ${e.actor || 'unknown'} (${e.timestamp || e.fileCreated || ''}) ---`);
527
+ log(e.content || '(no body)');
528
+ log('');
529
+ }
530
+ return;
531
+ }
532
+
533
+ let reviewIdentity = resolveReviewIdentity(slug, worktree, {
534
+ readReviewStateFn,
535
+ }).identityUser;
536
+
537
+ if (!reviewIdentity) {
538
+ error(fmt.status('FAIL', `Cannot determine review identity for ${slug}. Persist review-state.json or set FORGEJO_USER.`));
539
+ exit(1);
540
+ return;
541
+ }
542
+ reviewIdentity = resolveReviewUserFn(reviewIdentity);
543
+
544
+ const token = readTokenFn(reviewIdentity, { rootDir: worktree });
545
+ if (!token) {
546
+ error(fmt.status('FAIL', `No Forgejo token found for user "${reviewIdentity}".`));
547
+ exit(1);
548
+ return;
549
+ }
550
+
551
+ log(fmt.status('INFO', `Reading PR comments on ${branch} as ${reviewIdentity}...`));
552
+ const comments = await getCommentsFn(branch, token);
553
+
554
+ if (comments === null) {
555
+ error(fmt.status('FAIL', `Could not fetch PR comments for ${branch}. The review provider may be unreachable or the PR is missing.`));
556
+ exit(1);
557
+ return;
558
+ }
559
+
560
+ if (comments.length === 0) {
561
+ log('no comments');
562
+ return;
563
+ }
564
+
565
+ for (const c of comments) {
566
+ let label = c.kind;
567
+ if (c.location) label += ` ${c.location}`;
568
+ log(`--- ${label} | ${c.user} (${c.created}) ---`);
569
+ log(c.body || '(no body)');
570
+ log('');
571
+ }
572
+ }
573
+
574
+ // ============================================================================
575
+ // Command: pushRound
576
+ // ============================================================================
577
+
578
+ async function pushRound(slug, options = {}) {
579
+ const log = options.log || fmt.log.plain;
580
+ const error = options.error || fmt.log.plainError;
581
+ const exit = options.exit || process.exit;
582
+ const resolveWorktreeFn = options.resolveWorktreeFn || resolveWorktree;
583
+ const resolveTaskFileFn = options.resolveTaskFileFn || resolveTaskFile;
584
+ const getTaskImplementerFn = options.getTaskImplementerFn || getTaskImplementer;
585
+ const transitionTaskFn = options.transitionTaskFn || transitionTask;
586
+ const resolveReviewUserFn = options.resolveReviewUserFn || options.resolveForgejoUserFn || resolveReviewUser;
587
+ const isReviewProviderEnabledFn = options.isReviewProviderEnabledFn || options.isForgejoReviewEnabledFn || isProviderEnabled;
588
+ const readTokenFn = options.readTokenFn || readToken;
589
+ const readReviewStateFn = options.readReviewStateFn || readReviewState;
590
+ const createPrFn = options.createPrFn || createPr;
591
+ const bootstrapReviewSurfaceFn = options.bootstrapReviewSurfaceFn || bootstrapReviewSurface;
592
+ const resolveReviewAdapterFn = options.resolveReviewAdapterFn || resolveReviewAdapter;
593
+ const cwdFn = options.cwdFn || (() => process.cwd());
594
+ const force = options.force || false;
595
+ const worktree = resolveWorktreeFn(slug);
596
+ const rootDir = worktree || cwdFn();
597
+ const branch = missionBranchName(slug, rootDir);
598
+ const providerEnabled = isReviewProviderEnabledFn(rootDir);
599
+
600
+ const { identityUser: reviewStateUser } = resolveReviewIdentity(slug, rootDir, {
601
+ readReviewStateFn,
602
+ });
603
+ let reviewIdentity = reviewStateUser;
604
+
605
+ if (!reviewIdentity) {
606
+ // 1. Check backlog task assignee
607
+ const taskResolution = resolveTaskFileFn(slug, rootDir);
608
+ if (taskResolution.ok) {
609
+ reviewIdentity = getTaskImplementerFn(taskResolution.taskFile);
610
+ }
611
+ }
612
+
613
+ // 2. Mode-specific final fallback: named identity (provider-backed) vs "autonomous" (provider=none) (SC 6)
614
+ if (!reviewIdentity) {
615
+ if (providerEnabled) {
616
+ error(fmt.status('FAIL', `No review identity resolved for --push on ${slug}. Persist review-state.json or set the task implementer.`));
617
+ exit(1);
618
+ return;
619
+ } else {
620
+ reviewIdentity = 'autonomous';
621
+ log(fmt.status('WARN', `No reviewer/implementer identity resolved for --push; defaulting to "autonomous"`));
622
+ }
623
+ }
624
+
625
+ if (!reviewIdentity || (providerEnabled && reviewIdentity === 'autonomous')) {
626
+ error(fmt.status('FAIL', 'No review identity resolved for push.'));
627
+ exit(1);
628
+ return;
629
+ }
630
+
631
+ reviewIdentity = resolveReviewUserFn(reviewIdentity);
632
+ const token = readTokenFn(reviewIdentity, { rootDir: worktree });
633
+ if (!token) {
634
+ error(fmt.status('FAIL', `No Forgejo token found for user "${reviewIdentity}".`));
635
+ exit(1);
636
+ return;
637
+ }
638
+
639
+ // Transition to review before pushing so the state change is included in the PR update
640
+ transitionTaskFn(slug, 'review', { rootDir, log });
641
+
642
+ log(fmt.status('INFO', `Pushing ${branch} to the review provider as ${reviewIdentity}...${force ? ' (force-with-lease)' : ''}`));
643
+ if (worktree) {
644
+ log(fmt.status('INFO', `Found dedicated worktree: ${worktree}`));
645
+ }
646
+
647
+ let result = createPrFn(branch, reviewIdentity, token, { rootDir, forceWithLease: true });
648
+ if (!result.ok && /Repository not found/i.test(result.error || '')) {
649
+ const reviewAdapter = resolveReviewAdapterFn(rootDir);
650
+ const ownerLogin = (reviewAdapter.repo && reviewAdapter.repo.split('/')[0]) || 'magnus';
651
+ const bootstrap = await bootstrapReviewSurfaceFn(rootDir, {
652
+ baseUrl: reviewAdapter.baseUrl,
653
+ repo: reviewAdapter.repo,
654
+ ownerLogin,
655
+ ownerPassword: '',
656
+ agentPasswords: [],
657
+ }, {
658
+ interactive: false,
659
+ log,
660
+ error,
661
+ });
662
+ if (bootstrap.ok) {
663
+ result = createPrFn(branch, reviewIdentity, token, { rootDir, forceWithLease: true });
664
+ }
665
+ }
666
+
667
+ if (!result.ok) {
668
+ error(fmt.status('FAIL', `Push to review provider failed: ${result.error}`));
669
+ exit(1);
670
+ return;
671
+ }
672
+
673
+ log(fmt.status('PASS', `Branch pushed and PR updated for ${branch}.`));
674
+ }
675
+
676
+ // ============================================================================
677
+ // Command: showReviewStatus
678
+ // ============================================================================
679
+
680
+ function showReviewStatus(slug, options = {}) {
681
+ const log = options.log || fmt.log.plain;
682
+ const readReviewStateFn = options.readReviewStateFn || readReviewState;
683
+ const resolveWorktreeFn = options.resolveWorktreeFn || resolveWorktree;
684
+
685
+ const worktree = resolveWorktreeFn(slug) || process.cwd();
686
+ const state = readReviewStateFn(slug, worktree);
687
+
688
+ log(fmt.status('INFO', `Review status for mission: ${fmt.slug(slug)}`));
689
+
690
+ if (!state) {
691
+ log(fmt.status('INFO', 'No persisted review state found.'));
692
+ return;
693
+ }
694
+
695
+ log(` Round: ${state.round}`);
696
+ log(` Phase: ${state.phase}`);
697
+ log(` Reviewer: ${fmt.agent(state.reviewer)}`);
698
+ log(` Implementer: ${fmt.agent(state.implementer)}`);
699
+ log(` Started at: ${state.startedAt}`);
700
+ if (state.disposition) {
701
+ log(` Disposition: ${state.disposition}`);
702
+ }
703
+ if (state.reviewerRetryCount > 0) {
704
+ log(` Reviewer retries: ${state.reviewerRetryCount}`);
705
+ }
706
+ if (state.implementerRetryCount > 0) {
707
+ log(` Implementer retries: ${state.implementerRetryCount}`);
708
+ }
709
+ }
710
+
711
+ // ============================================================================
712
+ // Command: commentRound
713
+ // ============================================================================
714
+
715
+ function commentRound(slug, message, options = {}) {
716
+ const log = options.log || fmt.log.plain;
717
+ const error = options.error || fmt.log.plainError;
718
+ const exit = options.exit || process.exit;
719
+ const readReviewStateFn = options.readReviewStateFn || readReviewState;
720
+ const writeReviewStateFn = options.writeReviewStateFn || writeReviewState;
721
+ const resolveReviewUserFn = options.resolveReviewUserFn || options.resolveForgejoUserFn || resolveReviewUser;
722
+ const rootDir = options.rootDir || resolveWorktree(slug) || process.cwd();
723
+
724
+ let reviewIdentity = resolveReviewIdentity(slug, rootDir, {
725
+ readReviewStateFn,
726
+ }).identityUser;
727
+
728
+ if (!reviewIdentity) {
729
+ error(fmt.status('FAIL', `Cannot determine review identity for ${slug}. Persist review-state.json or set FORGEJO_USER.`));
730
+ exit(1);
731
+ return;
732
+ }
733
+ reviewIdentity = resolveReviewUserFn(reviewIdentity);
734
+ const result = postWorkflowComment(slug, message, {
735
+ rootDir,
736
+ reviewIdentity,
737
+ readTokenFn: options.readTokenFn,
738
+ postCommentFn: options.postCommentFn,
739
+ buildMetadataFooterFn: options.buildMetadataFooterFn,
740
+ log,
741
+ error
742
+ });
743
+
744
+ if (!result.ok) {
745
+ exit(1);
746
+ return;
747
+ }
748
+
749
+ const currentState = readReviewStateFn(slug, rootDir);
750
+ if (currentState) {
751
+ writeReviewStateFn(slug, currentState, rootDir);
752
+ }
753
+ }
754
+
755
+ // ============================================================================
756
+ // Command: consumeArtifacts
757
+ // ============================================================================
758
+
759
+ async function consumeArtifacts(slug, options = {}) {
760
+ const log = options.log || fmt.log.plain;
761
+ const error = options.error || fmt.log.plainError;
762
+ const exit = options.exit || process.exit;
763
+ const resolveWorktreeFn = options.resolveWorktreeFn || resolveWorktree;
764
+ const transitionTaskFn = options.transitionTaskFn || transitionTask;
765
+ const consumeReviewerArtifactsFn = options.consumeReviewerArtifactsFn || consumeReviewerArtifacts;
766
+ const resolveTaskFileFn = options.resolveTaskFileFn || resolveTaskFile;
767
+ const getTaskAssigneeFn = options.getTaskAssigneeFn || getTaskAssignee;
768
+ const getTaskStatusFn = options.getTaskStatusFn || getTaskStatus;
769
+ const resolveArtifactDirFn = options.resolveArtifactDirFn || require('./review-artifacts').resolveArtifactDir;
770
+
771
+ const worktree = resolveWorktreeFn(slug) || process.cwd();
772
+ const rootDir = worktree;
773
+ const taskResolution = resolveTaskFileFn(slug, rootDir);
774
+
775
+ // Resolve artifact directory
776
+ const artifactDir = resolveArtifactDirFn(rootDir);
777
+ log(fmt.status('INFO', `Consuming reviewer artifacts for ${slug} from ${artifactDir}`));
778
+
779
+ // Determine reviewer identity from review-state first, then task assignee.
780
+ const { identityUser: stateReviewer } = resolveReviewIdentity(slug, worktree, {
781
+ readReviewStateFn: options.readReviewStateFn || readReviewState,
782
+ });
783
+ let reviewer = stateReviewer;
784
+ if (!reviewer && taskResolution.ok) {
785
+ reviewer = getTaskAssigneeFn(taskResolution.taskFile);
786
+ }
787
+ if (!reviewer) {
788
+ reviewer = 'autonomous';
789
+ log(fmt.status('WARN', `No reviewer identity resolved; defaulting to "${reviewer}"`));
790
+ }
791
+
792
+ // Consume artifacts - this will create reviewer_findings and reviewer_outcome events
793
+ const result = await consumeReviewerArtifactsFn(slug, reviewer, {
794
+ rootDir,
795
+ worktree,
796
+ tmpDir: artifactDir,
797
+ log,
798
+ error,
799
+ providerEnabled: false,
800
+ createEventFn: options.createEventFn,
801
+ readArtifactFn: options.readArtifactFn,
802
+ deleteArtifactFn: options.deleteArtifactFn,
803
+ });
804
+
805
+ if (!result.consumed) {
806
+ log(fmt.status('WARN', `No reviewer artifact files found at ${artifactDir} for ${slug}.`));
807
+ return { ok: false, consumed: false };
808
+ }
809
+
810
+ if (!result.ok) {
811
+ error(fmt.status('FAIL', `Failed to consume reviewer artifacts for ${slug}: ${result.ok === false ? 'missing required fields (findings, outcome, verdict)' : 'unknown failure'}`));
812
+ return { ok: false, consumed: true };
813
+ }
814
+
815
+ // Persist the artifact location to review-state.json if it doesn't exist yet
816
+ const persisted = readReviewState(slug, worktree);
817
+ if (!persisted) {
818
+ const { ReviewState } = require('./review-state');
819
+ writeReviewState(slug, new ReviewState(slug, {
820
+ reviewer,
821
+ round: 1,
822
+ phase: 'reviewing',
823
+ }), worktree);
824
+ log(fmt.status('INFO', 'Created review-state.json for artifact consumption.'));
825
+ }
826
+
827
+ // Transition backlog task to review status
828
+ if (taskResolution.ok) {
829
+ const currentStatus = getTaskStatusFn ? getTaskStatusFn(taskResolution.taskFile) : null;
830
+ if (!currentStatus || currentStatus !== 'review') {
831
+ transitionTaskFn(slug, 'review', { rootDir, log });
832
+ } else {
833
+ log(fmt.status('INFO', `Backlog task for ${slug} already at review status.`));
834
+ }
835
+ }
836
+
837
+ const cleanup = await commitPersistedReviewOutputs(slug, {
838
+ worktree,
839
+ taskFile: taskResolution.ok ? taskResolution.taskFile : null,
840
+ log,
841
+ error
842
+ });
843
+ if (!cleanup.ok) {
844
+ error(fmt.status('FAIL', `Consumed reviewer artifacts for ${slug}, but could not commit the persisted mission artifacts.`));
845
+ return { ok: false, consumed: true };
846
+ }
847
+
848
+ log(fmt.status('PASS', `Reviewer artifacts consumed for ${slug}. Backlog task set to review.`));
849
+ return { ok: true, consumed: true, reviewState: result.reviewState };
850
+ }
851
+
852
+ // ============================================================================
853
+ // Command: submitReviewRound
854
+ // ============================================================================
855
+
856
+ function submitReviewRound(slug, outcome, message, options = {}) {
857
+ const log = options.log || fmt.log.plain;
858
+ const error = options.error || fmt.log.plainError;
859
+ const exit = options.exit || process.exit;
860
+ const transitionTaskFn = options.transitionTaskFn || transitionTask;
861
+ const readReviewStateFn = options.readReviewStateFn || readReviewState;
862
+ const writeReviewStateFn = options.writeReviewStateFn || writeReviewState;
863
+ const isReviewProviderEnabledFn = options.isReviewProviderEnabledFn || options.isForgejoReviewEnabledFn || isProviderEnabled;
864
+ const resolveReviewUserFn = options.resolveReviewUserFn || options.resolveForgejoUserFn || resolveReviewUser;
865
+ const resolveTaskFileFn = options.resolveTaskFileFn || resolveTaskFile;
866
+ const getTaskStatusFn = options.getTaskStatusFn || getTaskStatus;
867
+ const VALID_OUTCOMES = ['approve', 'request-changes', 'comment'];
868
+ if (!VALID_OUTCOMES.includes(outcome)) {
869
+ error(fmt.status('FAIL', `Unknown review outcome "${outcome}". Valid: ${VALID_OUTCOMES.join(', ')}.`));
870
+ exit(1);
871
+ return;
872
+ }
873
+
874
+ const worktree = options.worktree || resolveWorktree(slug) || process.cwd();
875
+ const providerEnabled = isReviewProviderEnabledFn(worktree);
876
+
877
+ // For provider=none (standalone), skip provider posting and only update review-state
878
+ if (!providerEnabled) {
879
+ log(fmt.status('INFO', `Review provider is none — skipping provider posting, updating review-state only for ${slug}.`));
880
+ const { ReviewState } = require('./review-state');
881
+ const transitionTaskFn = options.transitionTaskFn || transitionTask;
882
+ const currentState = readReviewStateFn(slug, worktree);
883
+
884
+ let stateToWrite;
885
+ if (currentState) {
886
+ stateToWrite = currentState;
887
+ if (outcome === 'approve') {
888
+ stateToWrite.disposition = 'APPROVED';
889
+ try { stateToWrite.transitionTo('approved'); } catch (_) {}
890
+ } else if (outcome === 'request-changes') {
891
+ stateToWrite.disposition = 'REQUEST_CHANGES';
892
+ try { stateToWrite.transitionTo('fixing'); } catch (_) {}
893
+ }
894
+ } else {
895
+ // No existing state, create minimal state for tracking
896
+ const phaseForOutcome = outcome === 'approve' ? 'approved' : 'fixing';
897
+ stateToWrite = new ReviewState(slug, {
898
+ disposition: outcome === 'approve' ? 'APPROVED' : outcome === 'request-changes' ? 'REQUEST_CHANGES' : undefined,
899
+ reviewer: 'autonomous',
900
+ implementer: process.env.WORKFLOW_AGENT || 'autonomous',
901
+ round: 1,
902
+ phase: phaseForOutcome,
903
+ });
904
+ }
905
+ writeReviewStateFn(slug, stateToWrite, worktree);
906
+
907
+ // Also transition the backlog task for provider=none so integrate preflight passes
908
+ const backlogStatusMap = {
909
+ 'approve': 'approved',
910
+ 'request-changes': 'review',
911
+ 'comment': 'review'
912
+ };
913
+ const backlogStatus = backlogStatusMap[outcome];
914
+ if (backlogStatus && !transitionTaskFn(slug, backlogStatus, { rootDir: worktree, log })) {
915
+ log(fmt.status('WARN', `Could not transition backlog task ${slug} to ${backlogStatus}.`));
916
+ }
917
+
918
+ log(fmt.status('PASS', `Review outcome "${outcome}" recorded locally for ${slug}.`));
919
+ return;
920
+ }
921
+
922
+ // For provider-backed reviews, post through the adapter. Review-state is the normal source.
923
+ let reviewIdentity = resolveReviewIdentity(slug, worktree, {
924
+ readReviewStateFn,
925
+ }).identityUser;
926
+
927
+ if (!reviewIdentity) {
928
+ error(fmt.status('FAIL', `Cannot determine review identity for ${slug}. Persist review-state.json or set FORGEJO_USER.`));
929
+ exit(1);
930
+ return;
931
+ }
932
+ reviewIdentity = resolveReviewUserFn(reviewIdentity);
933
+ const result = postWorkflowReview(slug, outcome, message, {
934
+ worktree,
935
+ reviewIdentity,
936
+ readTokenFn: options.readTokenFn,
937
+ postReviewFn: options.postReviewFn,
938
+ getPrAuthorFn: options.getPrAuthorFn,
939
+ writeReviewStateFn: options.writeReviewStateFn,
940
+ createEventFn: options.createEventFn,
941
+ readReviewStateFn: options.readReviewStateFn,
942
+ buildMetadataFooterFn: options.buildMetadataFooterFn,
943
+ log,
944
+ error
945
+ });
946
+
947
+ // Self-author skip: postWorkflowReview intentionally did not POST to the provider
948
+ // (reviewer == PR author) and already persisted the verdict locally. This is
949
+ // a legitimate same-agent-reviewer fallback, not a failure — do NOT exit(1).
950
+ if (result.ok && result.skipped) {
951
+ repairStaleActiveTaskAfterReview(slug, {
952
+ rootDir: worktree,
953
+ resolveTaskFileFn,
954
+ getTaskStatusFn,
955
+ transitionTaskFn,
956
+ log,
957
+ error
958
+ });
959
+ log(fmt.status('WARN', `Review outcome "${outcome}" recorded locally for ${slug} (self-approval POST skipped). A different agent or a human must post the formal provider approval.`));
960
+ return;
961
+ }
962
+
963
+ if (!result.ok) {
964
+ exit(1);
965
+ return;
966
+ }
967
+
968
+ const currentState = readReviewStateFn(slug, worktree);
969
+ if (currentState) {
970
+ if (outcome === 'approve') {
971
+ currentState.disposition = 'APPROVED';
972
+ try { currentState.transitionTo('approved'); } catch (_) {}
973
+ } else if (outcome === 'request-changes') {
974
+ currentState.disposition = 'REQUEST_CHANGES';
975
+ try { currentState.transitionTo('fixing'); } catch (_) {}
976
+ }
977
+ writeReviewStateFn(slug, currentState, worktree);
978
+ }
979
+
980
+ const taskResolution = resolveTaskFileFn(slug, worktree);
981
+ const currentStatus = taskResolution.ok ? getTaskStatusFn(taskResolution.taskFile) : null;
982
+ let backlogStatus = null;
983
+
984
+ if (outcome === 'approve') {
985
+ backlogStatus = currentStatus === 'active' ? 'review' : 'approved';
986
+ } else if (outcome === 'request-changes' || outcome === 'comment') {
987
+ backlogStatus = 'review';
988
+ }
989
+
990
+ if (backlogStatus && !transitionTaskFn(slug, backlogStatus, { rootDir: worktree, log })) {
991
+ log(fmt.status('WARN', `Could not transition backlog task ${slug} to ${backlogStatus}.`));
992
+ }
993
+ }
994
+
995
+ // ============================================================================
996
+ // Command: closeMissionPr
997
+ // ============================================================================
998
+
999
+ async function closeMissionPr(slug, options = {}) {
1000
+ const log = options.log || fmt.log.plain;
1001
+ const error = options.error || fmt.log.plainError;
1002
+ const exit = options.exit || process.exit;
1003
+ const readTokenFn = options.readTokenFn || readToken;
1004
+ const readReviewStateFn = options.readReviewStateFn || readReviewState;
1005
+ const resolveReviewUserFn = options.resolveReviewUserFn || options.resolveForgejoUserFn || resolveReviewUser;
1006
+ const closePrFn = options.closePrFn || closePr;
1007
+ const worktree = options.worktree || resolveWorktree(slug) || process.cwd();
1008
+ const branch = missionBranchName(slug, worktree);
1009
+
1010
+ let reviewIdentity = resolveReviewIdentity(slug, worktree, {
1011
+ readReviewStateFn,
1012
+ }).identityUser;
1013
+
1014
+ if (!reviewIdentity) {
1015
+ error(fmt.status('FAIL', `Cannot determine review identity for ${slug}. Persist review-state.json or set FORGEJO_USER.`));
1016
+ exit(1);
1017
+ return;
1018
+ }
1019
+ reviewIdentity = resolveReviewUserFn(reviewIdentity);
1020
+
1021
+ const token = readTokenFn(reviewIdentity, { rootDir: worktree });
1022
+ if (!token) {
1023
+ error(fmt.status('FAIL', `No Forgejo token found for user "${reviewIdentity}".`));
1024
+ exit(1);
1025
+ return;
1026
+ }
1027
+
1028
+ log(fmt.status('INFO', `Closing PR for ${branch} as ${reviewIdentity}...`));
1029
+ const result = await closePrFn(branch, token, reviewIdentity);
1030
+
1031
+ if (!result.ok) {
1032
+ exit(1);
1033
+ }
1034
+ }
1035
+
1036
+ // ============================================================================
1037
+ // Event CLI Handlers
1038
+ // ============================================================================
1039
+
1040
+ function createEventHandler(slug, args, options = {}) {
1041
+ const log = options.log || fmt.log.plain;
1042
+ const error = options.error || fmt.log.plainError;
1043
+ const exit = options.exit || process.exit;
1044
+ const resolveWorktreeFn = options.resolveWorktreeFn || resolveWorktree;
1045
+
1046
+ const eventType = flagValue(args, '--type');
1047
+ const inputFile = flagValue(args, '--input-file');
1048
+ const actor = flagValue(args, '--actor');
1049
+ const roundRaw = flagValue(args, '--round');
1050
+ const phase = flagValue(args, '--phase');
1051
+ const disposition = flagValue(args, '--disposition');
1052
+ const verdict = flagValue(args, '--verdict');
1053
+
1054
+ if (!eventType) {
1055
+ error(fmt.status('FAIL', '--create-event requires --type <classification>'));
1056
+ exit(1);
1057
+ return;
1058
+ }
1059
+
1060
+ // Validate event type first
1061
+ if (!isValidEventType(eventType)) {
1062
+ error(fmt.status('FAIL', `Invalid event type "${eventType}". Valid: ${ALL_EVENT_TYPES.join(', ')}`));
1063
+ exit(1);
1064
+ return;
1065
+ }
1066
+
1067
+ // Read content from input file or stdin
1068
+ let content = '';
1069
+ if (inputFile) {
1070
+ try {
1071
+ content = fs.readFileSync(inputFile, 'utf8');
1072
+ log(fmt.status('INFO', `Read event content from: ${inputFile}`));
1073
+ } catch (err) {
1074
+ error(fmt.status('FAIL', `Failed to read input file: ${err.message}`));
1075
+ exit(1);
1076
+ return;
1077
+ }
1078
+ }
1079
+
1080
+ const round = roundRaw ? parseInt(roundRaw, 10) : undefined;
1081
+ if (roundRaw && isNaN(round)) {
1082
+ error(fmt.status('FAIL', `--round must be a number, got "${roundRaw}"`));
1083
+ exit(1);
1084
+ return;
1085
+ }
1086
+
1087
+ const worktree = resolveWorktreeFn(slug) || process.cwd();
1088
+
1089
+ // Build params
1090
+ const params = { content };
1091
+ if (round !== undefined) params.round = round;
1092
+ if (phase) params.phase = phase;
1093
+ if (actor) params.actor = actor;
1094
+ if (disposition) params.disposition = disposition;
1095
+ if (verdict) params.verdict = verdict;
1096
+
1097
+ const readReviewStateFn = options.readReviewStateFn || readReviewState;
1098
+ const { identityUser: stateReviewIdentity } = resolveReviewIdentity(slug, worktree, {
1099
+ readReviewStateFn,
1100
+ });
1101
+ const reviewIdentity = actor || stateReviewIdentity;
1102
+
1103
+ // SC 4: For mirrored event types, a provider identity is required before creating the event.
1104
+ if (shouldMirrorToProvider(eventType) && !reviewIdentity) {
1105
+ error(fmt.status('FAIL', 'Cannot determine review identity for a mirrored event. Validate review-state.json or use --actor.'));
1106
+ exit(1);
1107
+ return;
1108
+ }
1109
+
1110
+ // Extract fields from content if structured
1111
+ if (content.includes('fixed_items:') || content.includes('fixedItems:')) {
1112
+ try {
1113
+ const frontmatterMatch = content.match(/fixed_items:\s*(\[[^\]]*\])/i);
1114
+ if (frontmatterMatch) {
1115
+ params.fixedItems = JSON.parse(frontmatterMatch[1]);
1116
+ }
1117
+ } catch (_) {}
1118
+ try {
1119
+ const frontmatterMatch = content.match(/pushed_back_items:\s*(\[[^\]]*\])/i);
1120
+ if (frontmatterMatch) {
1121
+ params.pushedBackItems = JSON.parse(frontmatterMatch[1]);
1122
+ }
1123
+ } catch (_) {}
1124
+ try {
1125
+ const frontmatterMatch = content.match(/parked_items:\s*(\[[^\]]*\])/i);
1126
+ if (frontmatterMatch) {
1127
+ params.parkedItems = JSON.parse(frontmatterMatch[1]);
1128
+ }
1129
+ } catch (_) {}
1130
+ try {
1131
+ const frontmatterMatch = content.match(/blocked_reason:\s*"([^"]*)"/i);
1132
+ if (frontmatterMatch) {
1133
+ params.blockedReason = frontmatterMatch[1];
1134
+ }
1135
+ } catch (_) {}
1136
+ }
1137
+
1138
+ // Create the event
1139
+ const result = createEvent(slug, eventType, params, {
1140
+ worktree,
1141
+ skipGit: false,
1142
+ log,
1143
+ error
1144
+ });
1145
+
1146
+ if (!result.ok) {
1147
+ error(fmt.status('FAIL', `Could not create event: ${result.error}`));
1148
+ exit(1);
1149
+ return;
1150
+ }
1151
+
1152
+ log(fmt.status('PASS', `Created review event at: ${result.path}`));
1153
+ }
1154
+
1155
+ function importLegacyHandler(slug, args, options = {}) {
1156
+ const log = options.log || fmt.log.plain;
1157
+ const error = options.error || fmt.log.plainError;
1158
+ const exit = options.exit || process.exit;
1159
+ const resolveWorktreeFn = options.resolveWorktreeFn || resolveWorktree;
1160
+
1161
+ const tmpDir = flagValue(args, '--tmp-dir') || process.env.WORKFLOW_TMP_DIR || os.tmpdir();
1162
+ const worktree = resolveWorktreeFn(slug) || process.cwd();
1163
+
1164
+ const result = importAllLegacyArtifacts(slug, tmpDir, { worktree, log, error });
1165
+
1166
+ if (!result.ok) {
1167
+ error(fmt.status('FAIL', `Legacy import failed: ${result.error}`));
1168
+ exit(1);
1169
+ return;
1170
+ }
1171
+
1172
+ log(fmt.status('PASS', `Imported ${result.imported} legacy artifacts for ${slug}`));
1173
+ }
1174
+
1175
+ // ============================================================================
1176
+ // Main Dispatcher
1177
+ // ============================================================================
1178
+
1179
+ async function review(args, options = {}) {
1180
+ const inferSlugFn = options.inferSlugFn || inferSlug;
1181
+ const log = options.log || fmt.log.plain;
1182
+ const error = options.error || fmt.log.plainError;
1183
+ const exit = options.exit || process.exit;
1184
+ const verifyReviewFn = options.verifyReviewFn || verifyReview;
1185
+ const submitForReviewFn = options.submitForReviewFn || submitForReview;
1186
+ const consumeArtifactsFn = options.consumeArtifactsFn || consumeArtifacts;
1187
+ const pushRoundFn = options.pushRoundFn || pushRound;
1188
+ const readCommentsFn = options.readCommentsFn || readComments;
1189
+ const commentRoundFn = options.commentRoundFn || commentRound;
1190
+ const submitReviewRoundFn = options.submitReviewRoundFn || submitReviewRound;
1191
+ const closeMissionPrFn = options.closeMissionPrFn || closeMissionPr;
1192
+ const startReviewLoopFn = options.startReviewLoopFn || require('./review-loop').startReviewLoop;
1193
+ const startAgentFn = options.startAgentFn || startAgent;
1194
+ const resolveTaskFileFn = options.resolveTaskFileFn || resolveTaskFile;
1195
+ const getTaskImplementerFn = options.getTaskImplementerFn || getTaskImplementer;
1196
+ const getPrStatusFn = options.getPrStatusFn || getPrStatus;
1197
+ const readReviewStateFn = options.readReviewStateFn || readReviewState;
1198
+ const performStaticReviewFn = options.performStaticReviewFn || performStaticReview;
1199
+ const postStaticReviewCommentFn = options.postStaticReviewCommentFn || postStaticReviewComment;
1200
+ const resolveWorktreeFn = options.resolveWorktreeFn || resolveWorktree;
1201
+ const runFn = options.run || run;
1202
+
1203
+ const flags = args.filter(a => a.startsWith('--'));
1204
+ const params = args.filter(a => !a.startsWith('--'));
1205
+
1206
+ const explicitSlug = params[0];
1207
+ const slug = inferSlugFn(explicitSlug);
1208
+ const isSubmit = flags.includes('--submit');
1209
+ const isVerify = flags.includes('--verify');
1210
+ const isStart = flags.includes('--start');
1211
+ const isContinue = flags.includes('--continue');
1212
+ const isPush = flags.includes('--push');
1213
+ const isForce = flags.includes('--force');
1214
+ const isComment = flags.includes('--comment') || flags.includes('--comment-file');
1215
+ const isComments = flags.includes('--comments');
1216
+ const isSubmitReview = flags.includes('--submit-review');
1217
+ const isClose = flags.includes('--close');
1218
+ const isStatus = flags.includes('--status');
1219
+ const skipGate = flags.includes('--no-gate');
1220
+ const isDryRun = flags.includes('--dry-run');
1221
+ const isReset = flags.includes('--reset');
1222
+ const isCreateEvent = flags.includes('--create-event');
1223
+ const isImportLegacy = flags.includes('--import-legacy');
1224
+ const isConsumeArtifacts = flags.includes('--consume-artifacts');
1225
+ const missionPath = flagValue(args, '--mission');
1226
+
1227
+ if (!slug) {
1228
+ error('Usage: px review [<slug>] [--verify] [--submit] [--push] [--force] [--start] [--continue] [--no-gate] [--status] [--comments] [--comment "<msg>"|--comment-file <path>] [--submit-review <outcome> [--message "<summary>"|--message-file <path>] [--close] [--create-event --type <classification> [--input-file <path>] [--actor <name>] [--round <n>] [--phase <phase>] [--mission <path>]] [--import-legacy [--tmp-dir <dir>]] [--consume-artifacts]');
1229
+ exit(1);
1230
+ return;
1231
+ }
1232
+
1233
+ if (isStatus) {
1234
+ showReviewStatus(slug, { ...options, readReviewStateFn });
1235
+ return;
1236
+ } else if (isVerify) {
1237
+ verifyReviewFn(slug, skipGate, { ...options, missionPath });
1238
+ } else if (isSubmit) {
1239
+ // Pre-check: inspect the configured artifact directory for unprocessed files
1240
+ // and emit a warning if artifacts are found but --submit-review was not used.
1241
+ const artifactDir = require('./review-artifacts').resolveArtifactDir(resolveWorktreeFn(slug) || process.cwd());
1242
+ const findingsPath = reviewArtifactPath(slug, 'review-findings.md', artifactDir);
1243
+ const outcomePath = reviewArtifactPath(slug, 'review-outcome.md', artifactDir);
1244
+ const verdictPath = reviewArtifactPath(slug, 'review-verdict.txt', artifactDir);
1245
+ if (fs.existsSync(findingsPath) || fs.existsSync(outcomePath) || fs.existsSync(verdictPath)) {
1246
+ log(fmt.status('WARN', `Unprocessed review artifacts found at ${artifactDir} for ${slug}. Consider using --consume-artifacts to persist them before handoff, or use --submit-review to post a verdict.`));
1247
+ }
1248
+ await submitForReviewFn(slug, skipGate, options);
1249
+ } else if (isConsumeArtifacts) {
1250
+ await consumeArtifactsFn(slug, options);
1251
+ } else if (isPush) {
1252
+ await pushRoundFn(slug, { ...options, force: isForce });
1253
+ } else if (isComments) {
1254
+ await readCommentsFn(slug, options);
1255
+ } else if (isComment) {
1256
+ const message = readTextFlag(args, '--comment', '--comment-file', 'comment', options);
1257
+ if (!message) {
1258
+ error(fmt.status('FAIL', '--comment requires text via --comment "<text>" or --comment-file <path>.'));
1259
+ exit(1);
1260
+ return;
1261
+ }
1262
+ commentRoundFn(slug, message, options);
1263
+ } else if (isSubmitReview) {
1264
+ const outcome = flagValue(args, '--submit-review');
1265
+ const message = readTextFlag(args, '--message', '--message-file', 'review message', options) || '';
1266
+ if (!outcome) {
1267
+ error(fmt.status('FAIL', '--submit-review requires an outcome: px review <slug> --submit-review <approve|request-changes|comment> [--message "<summary>"|--message-file <path>]'));
1268
+ exit(1);
1269
+ return;
1270
+ }
1271
+ submitReviewRoundFn(slug, outcome, message, options);
1272
+ } else if (isClose) {
1273
+ await closeMissionPrFn(slug, options);
1274
+ } else if (isCreateEvent) {
1275
+ createEventHandler(slug, args, options);
1276
+ return;
1277
+ } else if (isImportLegacy) {
1278
+ importLegacyHandler(slug, args, options);
1279
+ return;
1280
+ } else if (isStart || isContinue) {
1281
+ const implementer = flagValue(args, '--implementer');
1282
+ const reviewer = flagValue(args, '--reviewer');
1283
+ const focus = flagValue(args, '--focus') || 'all';
1284
+ const maxAttempts = parseInt(flagValue(args, '--max-attempts') || String(DEFAULT_MAX_ATTEMPTS), 10);
1285
+ const verbose = flags.includes('--verbose');
1286
+ const pollTimeoutRaw = flagValue(args, '--poll-timeout-seconds');
1287
+ const pollTimeoutSeconds = pollTimeoutRaw ? parseInt(pollTimeoutRaw, 10) : null;
1288
+ await startReviewLoopFn(slug, {
1289
+ implementer,
1290
+ reviewer,
1291
+ focus,
1292
+ maxAttempts,
1293
+ dryRun: isDryRun,
1294
+ reset: isReset,
1295
+ isContinue,
1296
+ verbose,
1297
+ pollTimeoutSeconds,
1298
+ missionPath
1299
+ });
1300
+ } else {
1301
+ const pr = getPrStatusFn(missionBranchName(slug, process.cwd()), process.cwd());
1302
+ log(fmt.status('INFO', `Review status for mission: ${fmt.slug(slug)}`));
1303
+ if (pr.exists) {
1304
+ log(pr.raw);
1305
+ } else {
1306
+ log(fmt.status('INFO', 'No active PR found for this mission.'));
1307
+ // Static review: when branch exists but no PR is open, inspect the diff
1308
+ // and check checkpoint evidence. Auto-trigger review loop if findings exist.
1309
+ const worktreeForStatic = resolveWorktreeFn(slug) || process.cwd();
1310
+ const staticResult = performStaticReviewFn(slug, { log, error, findMissionDir: findMissionDir, findCheckpoints: findCheckpoints, readFileSync: fs.readFileSync, run: runFn, resolveWorktree: resolveWorktreeFn, rootDir: worktreeForStatic, missionPath });
1311
+ if (staticResult.findings && staticResult.findings.length > 0) {
1312
+ // Trivial structural findings (missing Goal Check, no evidence rows, mission
1313
+ // dir absent) are self-fixable, so re-launch the implementer with a targeted
1314
+ // prompt instead of burning a full reviewer round via the review loop.
1315
+ const taskResolution = resolveTaskFileFn(slug, worktreeForStatic);
1316
+ const implementer = taskResolution && taskResolution.taskFile
1317
+ ? getTaskImplementerFn(taskResolution.taskFile)
1318
+ : null;
1319
+ if (!implementer) {
1320
+ log(fmt.status('WARN', `Static review found ${staticResult.findings.length} finding(s) but the implementer could not be resolved for ${fmt.slug(slug)} — not re-launching the implementer and not starting the review loop.`));
1321
+ } else {
1322
+ log(`\nStatic review found ${staticResult.findings.length} finding(s). Re-launching implementer (${fmt.agent(implementer)}) with a targeted fix prompt...`);
1323
+ const findingLines = staticResult.findings.map(f => `- ${f}`).join('\n');
1324
+ const prompt = `Static review of your mission branch found the following issue(s). Fix them and commit, then stop:\n${findingLines}`;
1325
+ await startAgentFn('active', { prompt, worktree: worktreeForStatic, agent: implementer, slug });
1326
+ }
1327
+ } else if (staticResult.ok) {
1328
+ log(fmt.status('INFO', 'Static review passed — no findings. Mission branch is clean.'));
1329
+ const prStatus = getPrStatusFn(missionBranchName(slug, process.cwd()), process.cwd());
1330
+ if (!prStatus.exists || prStatus.state !== 'open') {
1331
+ log(fmt.status('INFO', 'No open PR found — submitting for review before finalizing static review...'));
1332
+ await submitForReviewFn(slug, true, options);
1333
+ }
1334
+ postStaticReviewCommentFn(
1335
+ slug,
1336
+ formatStaticReviewSuccess(slug),
1337
+ { ...options, rootDir: worktreeForStatic, log, error }
1338
+ );
1339
+ }
1340
+ }
1341
+ const persisted = readReviewStateFn(slug);
1342
+ if (persisted) {
1343
+ log(fmt.status('INFO', `Persisted reviewer state: reviewer=${fmt.agent(persisted.reviewer)} implementer=${fmt.agent(persisted.implementer)} round=${persisted.round}`));
1344
+ }
1345
+ }
1346
+ }
1347
+
1348
+ // ============================================================================
1349
+ // Module Exports
1350
+ // ============================================================================
1351
+
1352
+ module.exports = {
1353
+ // Internal helpers
1354
+ flagValue,
1355
+ readTextFlag,
1356
+ formatStaticReviewFindings,
1357
+ formatStaticReviewSuccess,
1358
+ postStaticReviewComment,
1359
+ performStaticReview,
1360
+ // Commands
1361
+ verifyReview,
1362
+ submitForReview,
1363
+ consumeArtifacts,
1364
+ readComments,
1365
+ pushRound,
1366
+ showReviewStatus,
1367
+ commentRound,
1368
+ submitReviewRound,
1369
+ closeMissionPr,
1370
+ // Event handlers
1371
+ createEventHandler,
1372
+ importLegacyHandler,
1373
+ // Main dispatcher
1374
+ review
1375
+ };