principles-disciple 1.8.2 → 1.8.3

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 (40) hide show
  1. package/openclaw.plugin.json +4 -4
  2. package/package.json +1 -1
  3. package/templates/langs/en/skills/ai-sprint-orchestration/EXAMPLES.md +63 -0
  4. package/templates/langs/en/skills/ai-sprint-orchestration/REFERENCE.md +136 -0
  5. package/templates/langs/en/skills/ai-sprint-orchestration/SKILL.md +67 -0
  6. package/templates/langs/en/skills/ai-sprint-orchestration/references/agent-registry.json +214 -0
  7. package/templates/langs/en/skills/ai-sprint-orchestration/references/specs/bugfix-complex-template.json +107 -0
  8. package/templates/langs/en/skills/ai-sprint-orchestration/references/specs/feature-complex-template.json +107 -0
  9. package/templates/langs/en/skills/ai-sprint-orchestration/references/specs/workflow-validation-minimal-verify.json +105 -0
  10. package/templates/langs/en/skills/ai-sprint-orchestration/references/specs/workflow-validation-minimal.json +108 -0
  11. package/templates/langs/en/skills/ai-sprint-orchestration/references/workflow-v1-acceptance-checklist.md +58 -0
  12. package/templates/langs/en/skills/ai-sprint-orchestration/references/workflow-v1.4-work-unit-handoff.md +190 -0
  13. package/templates/langs/en/skills/ai-sprint-orchestration/runtime/.gitignore +2 -0
  14. package/templates/langs/en/skills/ai-sprint-orchestration/scripts/lib/archive.mjs +310 -0
  15. package/templates/langs/en/skills/ai-sprint-orchestration/scripts/lib/contract-enforcement.mjs +683 -0
  16. package/templates/langs/en/skills/ai-sprint-orchestration/scripts/lib/decision.mjs +604 -0
  17. package/templates/langs/en/skills/ai-sprint-orchestration/scripts/lib/state-store.mjs +32 -0
  18. package/templates/langs/en/skills/ai-sprint-orchestration/scripts/lib/task-specs.mjs +707 -0
  19. package/templates/langs/en/skills/ai-sprint-orchestration/scripts/run.mjs +3419 -0
  20. package/templates/langs/zh/skills/ai-sprint-orchestration/EXAMPLES.md +63 -0
  21. package/templates/langs/zh/skills/ai-sprint-orchestration/REFERENCE.md +136 -0
  22. package/templates/langs/zh/skills/ai-sprint-orchestration/SKILL.md +67 -0
  23. package/templates/langs/zh/skills/ai-sprint-orchestration/references/agent-registry.json +214 -0
  24. package/templates/langs/zh/skills/ai-sprint-orchestration/references/specs/bugfix-complex-template.json +107 -0
  25. package/templates/langs/zh/skills/ai-sprint-orchestration/references/specs/feature-complex-template.json +107 -0
  26. package/templates/langs/zh/skills/ai-sprint-orchestration/references/specs/workflow-validation-minimal-verify.json +105 -0
  27. package/templates/langs/zh/skills/ai-sprint-orchestration/references/specs/workflow-validation-minimal.json +108 -0
  28. package/templates/langs/zh/skills/ai-sprint-orchestration/references/workflow-v1-acceptance-checklist.md +58 -0
  29. package/templates/langs/zh/skills/ai-sprint-orchestration/references/workflow-v1.4-work-unit-handoff.md +190 -0
  30. package/templates/langs/zh/skills/ai-sprint-orchestration/runtime/.gitignore +2 -0
  31. package/templates/langs/zh/skills/ai-sprint-orchestration/scripts/lib/archive.mjs +310 -0
  32. package/templates/langs/zh/skills/ai-sprint-orchestration/scripts/lib/contract-enforcement.mjs +683 -0
  33. package/templates/langs/zh/skills/ai-sprint-orchestration/scripts/lib/decision.mjs +604 -0
  34. package/templates/langs/zh/skills/ai-sprint-orchestration/scripts/lib/state-store.mjs +32 -0
  35. package/templates/langs/zh/skills/ai-sprint-orchestration/scripts/lib/task-specs.mjs +707 -0
  36. package/templates/langs/zh/skills/ai-sprint-orchestration/scripts/run.mjs +3419 -0
  37. package/templates/langs/zh/skills/ai-sprint-orchestration/test/archive.test.mjs +230 -0
  38. package/templates/langs/zh/skills/ai-sprint-orchestration/test/contract-enforcement.test.mjs +672 -0
  39. package/templates/langs/zh/skills/ai-sprint-orchestration/test/decision.test.mjs +1321 -0
  40. package/templates/langs/zh/skills/ai-sprint-orchestration/test/run.test.mjs +1419 -0
@@ -0,0 +1,3419 @@
1
+ // Packaged AI sprint orchestrator entrypoint.
2
+ // Canonical repo copy lives at packages/openclaw-plugin/templates/langs/zh/skills/ai-sprint-orchestration/scripts/run.mjs.
3
+ // Keep this package-local copy in sync only for package CLI, validation, and runtime-layout changes.
4
+
5
+ import { spawnSync, spawn } from 'child_process';
6
+ import crypto from 'crypto';
7
+ import fs from 'fs';
8
+ import path from 'path';
9
+ import os from 'os';
10
+ import { fileURLToPath } from 'url';
11
+ import { decideStage, buildStageMetrics, buildHandoff } from './lib/decision.mjs';
12
+ import { ensureDir, appendText, fileExists, readJson, writeJson, writeText } from './lib/state-store.mjs';
13
+ import { buildRolePrompt, buildStageBrief, getTaskSpec, getActiveWorkUnit } from './lib/task-specs.mjs';
14
+ import { archiveRunById } from './lib/archive.mjs';
15
+
16
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
17
+ const packageRoot = path.resolve(__dirname, '..');
18
+ const referencesRoot = path.join(packageRoot, 'references');
19
+ const defaultRuntimeRoot = path.join(packageRoot, 'runtime');
20
+ let runtimeRoot = process.env.AI_SPRINT_RUNTIME_ROOT
21
+ ? path.resolve(process.env.AI_SPRINT_RUNTIME_ROOT)
22
+ : defaultRuntimeRoot;
23
+ let sprintRoot = path.join(runtimeRoot, 'runs');
24
+ let tempRoot = path.join(runtimeRoot, 'tmp');
25
+ // Resolve acpx binary path and node executable — spawn directly via node to avoid
26
+ // shebang/env resolution issues when cron/nohup has a minimal PATH.
27
+ const acpxBin = (() => {
28
+ const cmd = process.platform === 'win32' ? 'where' : 'which';
29
+ const r = spawnSync(cmd, ['acpx'], { encoding: 'utf8' });
30
+ if (r.status === 0) {
31
+ const lines = r.stdout.trim().split(/\r?\n/);
32
+ const symlink = lines[0].trim();
33
+ try {
34
+ return fs.realpathSync(symlink);
35
+ } catch {
36
+ return symlink;
37
+ }
38
+ }
39
+ return 'acpx';
40
+ })();
41
+ const nodeBin = process.execPath; // e.g. /usr/bin/node — reliable, no PATH search needed
42
+ // Extended env for detached child processes.
43
+ // Linux: includes /usr/bin so the Node.js shebang (#!/usr/bin/env node) resolves
44
+ // correctly even when cron/nohup has a minimal PATH.
45
+ // Windows: inherit PATH as-is — nodeBin is absolute, no PATH search needed.
46
+ const acpxEnv = process.platform === 'win32'
47
+ ? { ...process.env }
48
+ : {
49
+ ...process.env,
50
+ PATH: `${process.env.PATH ?? ''}:/usr/local/bin:/usr/bin:/bin`,
51
+ };
52
+
53
+ // On Linux, ignore SIGHUP so SSH disconnect doesn't kill the orchestrator.
54
+ // Skip in test environments to avoid interfering with test signal handling.
55
+ if (process.platform !== 'win32' && !process.env.VITEST && process.env.NODE_ENV !== 'test') {
56
+ process.on('SIGHUP', () => { /* ignored — survive SSH disconnect */ });
57
+ }
58
+
59
+ function parseArgs(argv) {
60
+ const args = {};
61
+ for (let i = 0; i < argv.length; i += 1) {
62
+ const token = argv[i];
63
+ if (!token.startsWith('--')) continue;
64
+ const key = token.slice(2);
65
+ const next = argv[i + 1];
66
+ if (!next || next.startsWith('--')) {
67
+ args[key] = true;
68
+ } else {
69
+ args[key] = next;
70
+ i += 1;
71
+ }
72
+ }
73
+ return args;
74
+ }
75
+
76
+ function nowIso() {
77
+ return new Date().toISOString();
78
+ }
79
+
80
+ function configureRuntimeRoots(rootPath) {
81
+ runtimeRoot = path.resolve(rootPath);
82
+ sprintRoot = path.join(runtimeRoot, 'runs');
83
+ tempRoot = path.join(runtimeRoot, 'tmp');
84
+ process.env.AI_SPRINT_RUNTIME_ROOT = runtimeRoot;
85
+ }
86
+
87
+ function checkAcpxAvailable() {
88
+ return process.platform === 'win32'
89
+ ? spawnSync('powershell.exe', ['-NoProfile', '-Command', 'acpx --version'], {
90
+ encoding: 'utf8',
91
+ shell: false,
92
+ timeout: 10_000,
93
+ })
94
+ : spawnSync(nodeBin, [acpxBin, '--version'], {
95
+ encoding: 'utf8',
96
+ shell: false,
97
+ timeout: 10_000,
98
+ });
99
+ }
100
+
101
+ function runSelfCheck() {
102
+ ensureDir(runtimeRoot);
103
+ ensureDir(sprintRoot);
104
+ ensureDir(tempRoot);
105
+
106
+ const checks = [];
107
+ const record = (name, ok, details) => {
108
+ checks.push({ name, ok, details });
109
+ };
110
+
111
+ record('package_root_exists', fileExists(packageRoot), packageRoot);
112
+ record('references_root_exists', fileExists(referencesRoot), referencesRoot);
113
+ record('runtime_root_exists', fileExists(runtimeRoot), runtimeRoot);
114
+ record('agent_registry_exists', fileExists(path.join(referencesRoot, 'agent-registry.json')), path.join(referencesRoot, 'agent-registry.json'));
115
+
116
+ const builtInSpecs = [
117
+ 'workflow-validation-minimal',
118
+ 'workflow-validation-minimal-verify',
119
+ ];
120
+ for (const specId of builtInSpecs) {
121
+ try {
122
+ const spec = getTaskSpec(specId, null);
123
+ record(`spec:${specId}`, true, spec.title);
124
+ } catch (err) {
125
+ record(`spec:${specId}`, false, err.message);
126
+ }
127
+ }
128
+
129
+ const acpxCheck = checkAcpxAvailable();
130
+ record('acpx_available', acpxCheck.status === 0, (acpxCheck.stdout || acpxCheck.stderr || '').trim() || `status=${acpxCheck.status}`);
131
+
132
+ const probePath = path.join(runtimeRoot, '.self-check-write-probe.tmp');
133
+ try {
134
+ fs.writeFileSync(probePath, 'ok', 'utf8');
135
+ fs.rmSync(probePath, { force: true });
136
+ record('runtime_root_writable', true, runtimeRoot);
137
+ } catch (err) {
138
+ record('runtime_root_writable', false, err.message);
139
+ }
140
+
141
+ const failed = checks.filter((check) => !check.ok);
142
+ const lines = [
143
+ 'AI Sprint Orchestrator Self Check',
144
+ '',
145
+ `Package root: ${packageRoot}`,
146
+ `Runtime root: ${runtimeRoot}`,
147
+ '',
148
+ ...checks.map((check) => `${check.ok ? '[OK]' : '[FAIL]'} ${check.name}: ${check.details}`),
149
+ ];
150
+ console.log(lines.join('\n'));
151
+ if (failed.length > 0) {
152
+ process.exitCode = 1;
153
+ }
154
+ }
155
+
156
+ function slugify(value) {
157
+ return String(value)
158
+ .toLowerCase()
159
+ .replace(/[^a-z0-9]+/g, '-')
160
+ .replace(/^-+|-+$/g, '')
161
+ .slice(0, 60);
162
+ }
163
+
164
+ function makeRunId(taskId) {
165
+ const stamp = nowIso().replace(/[:.]/g, '-');
166
+ return `${stamp}-${slugify(taskId)}`;
167
+ }
168
+
169
+ function createSprintState(spec, runId, specPath) {
170
+ return {
171
+ runId,
172
+ taskId: spec.id,
173
+ title: spec.title,
174
+ specPath: specPath || null,
175
+ status: 'running',
176
+ currentStageIndex: 0,
177
+ currentStage: spec.stages[0],
178
+ currentWorkUnitIndex: 0,
179
+ currentRound: 1,
180
+ maxRoundsPerStage: spec.maxRoundsPerStage,
181
+ maxRuntimeMinutes: spec.maxRuntimeMinutes,
182
+ staleAfterMs: spec.staleAfterMs ?? 5 * 60 * 1000,
183
+ orchestratorPid: process.pid,
184
+ lastHeartbeatAt: nowIso(),
185
+ currentRole: null,
186
+ haltReason: null,
187
+ mergePending: null,
188
+ worktree: null, // { worktreePath, branchName, headSha, baseBranch } — set by ensureWorktree
189
+ consecutiveTimeouts: {}, // { stageName: count } — tracks consecutive timeouts per stage
190
+ createdAt: nowIso(),
191
+ updatedAt: nowIso(),
192
+ };
193
+ }
194
+
195
+ function loadOrInitState(args) {
196
+ ensureDir(sprintRoot);
197
+
198
+ if (args.resume) {
199
+ const runDir = path.join(sprintRoot, args.resume);
200
+ const sprintFile = path.join(runDir, 'sprint.json');
201
+ if (!fileExists(sprintFile)) {
202
+ throw new Error(`Run not found: ${args.resume}`);
203
+ }
204
+ const state = reconcileRunState(runDir, readJson(sprintFile));
205
+ if (state.status === 'halted' || state.status === 'aborted') {
206
+ // Validate: if halted mid-stage, the previous stage must have advanced
207
+ // Special case: implement-pass-1 revise → implement-pass-2 is valid routing
208
+ if (state.currentStageIndex > 0) {
209
+ const prevStageName = loadSpec(state, args).stages[state.currentStageIndex - 1];
210
+ const prevDecisionPath = path.join(runDir, 'stages', `${String(state.currentStageIndex).padStart(2, '0')}-${prevStageName}`, 'decision.md');
211
+ if (fileExists(prevDecisionPath)) {
212
+ const prevDecisionText = fs.readFileSync(prevDecisionPath, 'utf8');
213
+ const outcomeMatch = prevDecisionText.match(/Outcome:\s*(\w+)/);
214
+ if (outcomeMatch && outcomeMatch[1] !== 'advance') {
215
+ // Check for special routing: implement-pass-1 revise → implement-pass-2
216
+ const isSpecialRoute = state.currentStage === 'implement-pass-2' && prevStageName === 'implement-pass-1' && outcomeMatch[1] === 'revise';
217
+ if (!isSpecialRoute) {
218
+ throw new Error(`Cannot resume: previous stage "${prevStageName}" outcome was "${outcomeMatch[1]}" (expected "advance"). Fix the previous stage first.`);
219
+ }
220
+ }
221
+ }
222
+ }
223
+ const previousStatus = state.status;
224
+ state.status = 'running';
225
+ state.haltReason = null;
226
+ state.orchestratorPid = process.pid;
227
+ state.lastHeartbeatAt = nowIso();
228
+ state.updatedAt = nowIso();
229
+ // Refresh maxRoundsPerStage from spec in case spec was updated since the sprint started
230
+ const spec = loadSpec(state, args);
231
+ if (spec.maxRoundsPerStage !== undefined && spec.maxRoundsPerStage !== state.maxRoundsPerStage) {
232
+ appendTimeline(runDir, `maxRoundsPerStage updated from spec: ${state.maxRoundsPerStage} → ${spec.maxRoundsPerStage}`);
233
+ state.maxRoundsPerStage = spec.maxRoundsPerStage;
234
+ }
235
+ writeJson(sprintFile, state);
236
+ appendTimeline(runDir, `Sprint resumed from ${previousStatus} by operator`);
237
+ }
238
+ return { runDir, state, resumed: true };
239
+ }
240
+
241
+ if (!args.task) {
242
+ throw new Error('Missing required --task <task-id>');
243
+ }
244
+
245
+ const spec = getTaskSpec(args.task, args.taskSpec);
246
+ const runId = makeRunId(spec.id);
247
+ const runDir = path.join(sprintRoot, runId);
248
+ const state = createSprintState(spec, runId, args.taskSpec);
249
+ ensureDir(runDir);
250
+ writeJson(path.join(runDir, 'sprint.json'), state);
251
+ writeText(path.join(runDir, 'timeline.md'), `# Timeline\n\n- ${nowIso()} Created sprint ${runId}\n`);
252
+ writeText(path.join(runDir, 'latest-summary.md'), `# Latest Summary\n\n- Status: ${state.status}\n- Stage: ${state.currentStage}\n- Round: ${state.currentRound}\n`);
253
+ return { runDir, state, resumed: false };
254
+ }
255
+
256
+ function saveState(runDir, state) {
257
+ state.updatedAt = nowIso();
258
+ writeJson(path.join(runDir, 'sprint.json'), state);
259
+ }
260
+
261
+ function heartbeatState(runDir, state, patch = {}) {
262
+ Object.assign(state, patch, {
263
+ orchestratorPid: process.pid,
264
+ lastHeartbeatAt: nowIso(),
265
+ });
266
+ saveState(runDir, state);
267
+ }
268
+
269
+ function updateSummary(runDir, lines) {
270
+ writeText(path.join(runDir, 'latest-summary.md'), `# Latest Summary\n\n${lines.map((line) => `- ${line}`).join('\n')}\n`);
271
+ }
272
+
273
+ function inferFailureClassification({ summary = '', blockers = [], reviewerTimeouts = null, reviewerViolations = null }) {
274
+ const combined = [summary, ...(blockers ?? [])].join(' ').toLowerCase();
275
+
276
+ if (/acpx|path|enoent|eacces|eprem|permission|writable|runtime root|command not found|not available/.test(combined)) {
277
+ return {
278
+ failureClassification: 'environment issue',
279
+ failureSource: 'runtime environment',
280
+ recommendedNextAction: 'Repair the environment or required binaries, then rerun self-check and validation.',
281
+ };
282
+ }
283
+
284
+ if (/sample-spec|product-side|sample-side|openclaw-plugin|d:\\code\\openclaw|integrationphase|branchworkspace/.test(combined)) {
285
+ return {
286
+ failureClassification: 'sample-spec issue',
287
+ failureSource: 'sample/spec contract',
288
+ recommendedNextAction: 'Classify and stop. Do not reopen product-side closure work in this workflow milestone.',
289
+ };
290
+ }
291
+
292
+ if ((reviewerTimeouts?.length ?? 0) > 0 || (reviewerViolations?.length ?? 0) > 0 || /missing reports|schema violation|report invalidated|timed out|agent .*failed|verdict|dimensions/.test(combined)) {
293
+ return {
294
+ failureClassification: 'agent behavior issue',
295
+ failureSource: 'role execution or report quality',
296
+ recommendedNextAction: 'Adjust agent profile, fallback, or prompt discipline; do not treat this as a product-side bug.',
297
+ };
298
+ }
299
+
300
+ return {
301
+ failureClassification: 'workflow bug',
302
+ failureSource: 'orchestrator runtime',
303
+ recommendedNextAction: 'Fix the workflow plumbing, persistence, or artifact layout before retrying.',
304
+ };
305
+ }
306
+
307
+ function updateSummaryWithClassification(runDir, lines, classification = null) {
308
+ const enriched = [...lines];
309
+ if (classification?.failureClassification) {
310
+ enriched.push(`Failure classification: ${classification.failureClassification}`);
311
+ enriched.push(`Failure source: ${classification.failureSource}`);
312
+ enriched.push(`Recommended next action: ${classification.recommendedNextAction}`);
313
+ }
314
+ updateSummary(runDir, enriched);
315
+ }
316
+
317
+ function readStageFailureClassification(runDir, state) {
318
+ try {
319
+ const stageDirName = `${String(state.currentStageIndex + 1).padStart(2, '0')}-${state.currentStage}`;
320
+ const scorecardPath = path.join(runDir, 'stages', stageDirName, 'scorecard.json');
321
+ if (!fileExists(scorecardPath)) return null;
322
+ const scorecard = readJson(scorecardPath);
323
+ if (!scorecard?.failureClassification) return null;
324
+ return {
325
+ failureClassification: scorecard.failureClassification,
326
+ failureSource: scorecard.failureSource ?? 'n/a',
327
+ recommendedNextAction: scorecard.recommendedNextAction ?? 'n/a',
328
+ };
329
+ } catch {
330
+ return null;
331
+ }
332
+ }
333
+
334
+ function extractEvidenceFiles(reportText) {
335
+ if (!reportText) return [];
336
+ const matches = [
337
+ ...reportText.matchAll(/files_(?:checked|verified):\s*([^\n]+)/gi),
338
+ ];
339
+ return matches
340
+ .flatMap((match) => match[1].split(','))
341
+ .map((value) => value.trim())
342
+ .filter(Boolean);
343
+ }
344
+
345
+ function writeCheckpointSummary(stageDir, { decision, handoff = null, producer = '', reviewerA = '', reviewerB = '', globalReviewer = '' }) {
346
+ const verifiedFiles = Array.from(new Set([
347
+ ...extractEvidenceFiles(producer),
348
+ ...extractEvidenceFiles(reviewerA),
349
+ ...extractEvidenceFiles(reviewerB),
350
+ ...extractEvidenceFiles(globalReviewer),
351
+ ]));
352
+ const accomplished = handoff?.contractItems?.filter((item) => item.status === 'DONE').map((item) => item.deliverable) ?? [];
353
+ const blockers = decision.blockers?.length ? decision.blockers : ['None.'];
354
+ const nextFocus = handoff?.focusForNextRound
355
+ ?? decision.nextRunRecommendation?.reasons?.[0]
356
+ ?? decision.summary;
357
+ const lines = [
358
+ '## Accomplished',
359
+ ...(accomplished.length ? accomplished.map((item) => `- ${item}`) : ['- No contract deliverables were marked DONE in this round.']),
360
+ '',
361
+ '## Blockers',
362
+ ...blockers.map((item) => `- ${item}`),
363
+ '',
364
+ '## Next Focus',
365
+ nextFocus,
366
+ '',
367
+ '## Verified Files',
368
+ ...(verifiedFiles.length ? verifiedFiles.map((item) => `- ${item}`) : ['- None recorded.']),
369
+ ];
370
+ writeText(path.join(stageDir, 'checkpoint-summary.md'), `${lines.join('\n')}\n`);
371
+ }
372
+
373
+ function writeStageFailureArtifacts({ runDir, state, stageName, stageDir, decisionPath, scorecardPath, summary, blockers = [] }) {
374
+ const failure = inferFailureClassification({ summary, blockers });
375
+ writeText(decisionPath, [
376
+ '# Decision',
377
+ '',
378
+ `- Stage: ${stageName}`,
379
+ `- Round: ${state.currentRound}`,
380
+ '- Outcome: error',
381
+ '',
382
+ '## Summary',
383
+ summary,
384
+ '',
385
+ '## Blockers',
386
+ ...(blockers.length ? blockers.map((item) => `- ${item}`) : ['- None.']),
387
+ '',
388
+ '## Failure Classification',
389
+ `- Type: ${failure.failureClassification}`,
390
+ `- Source: ${failure.failureSource}`,
391
+ `- Recommended Next Action: ${failure.recommendedNextAction}`,
392
+ '',
393
+ ].join('\n'));
394
+ writeJson(scorecardPath, {
395
+ stage: stageName,
396
+ round: state.currentRound,
397
+ outcome: 'error',
398
+ summary,
399
+ blockers,
400
+ failureClassification: failure.failureClassification,
401
+ failureSource: failure.failureSource,
402
+ recommendedNextAction: failure.recommendedNextAction,
403
+ updatedAt: nowIso(),
404
+ });
405
+ updateSummaryWithClassification(runDir, [
406
+ `Status: ${state.status}`,
407
+ `Stage: ${stageName}`,
408
+ `Round: ${state.currentRound}`,
409
+ `Halt reason: ${summary}`,
410
+ ], failure);
411
+ }
412
+
413
+ const MAX_TIMELINE_LINES = 500;
414
+
415
+ function appendTimeline(runDir, line) {
416
+ const tlPath = path.join(runDir, 'timeline.md');
417
+ appendText(tlPath, `- ${nowIso()} ${line}\n`);
418
+
419
+ // Rotate if too long
420
+ if (fileExists(tlPath)) {
421
+ const content = fs.readFileSync(tlPath, 'utf8');
422
+ const lineCount = content.split('\n').filter((l) => l.startsWith('- ')).length;
423
+ if (lineCount > MAX_TIMELINE_LINES) {
424
+ const archivePath = path.join(runDir, 'timeline-archive.md');
425
+ appendText(archivePath, content);
426
+ const lines = content.split('\n').filter((l) => l.startsWith('- '));
427
+ const kept = lines.slice(-300);
428
+ writeText(tlPath, `# Timeline (archived ${lineCount - 300} older entries)\n\n${kept.join('\n')}\n`);
429
+ }
430
+ }
431
+ }
432
+
433
+ function runAgent({ cwd, agent, model, prompt, timeoutSeconds = 1800, failLogPath = null, promptDir = cwd, runDir = null, roleLabel = agent }) {
434
+ // promptDir (default=cwd) is where we write the prompt file; must exist and be writable.
435
+ // On Windows, avoid os.tmpdir() (short ~ paths) and non-existent branchWorkspace dirs.
436
+ const promptFile = path.join(promptDir, `.ai-sprint-prompt-${Date.now()}-${Math.random().toString(16).slice(2)}.txt`);
437
+ fs.writeFileSync(promptFile, prompt, 'utf8');
438
+
439
+ let result;
440
+ try {
441
+ if (process.platform === 'win32') {
442
+ result = spawnSync(
443
+ 'powershell.exe',
444
+ [
445
+ '-NoProfile',
446
+ '-Command',
447
+ `acpx --cwd $env:AI_SPRINT_CWD --approve-all --model $env:AI_SPRINT_MODEL --timeout $env:AI_SPRINT_TIMEOUT ${agent} exec -f $env:AI_SPRINT_PROMPT`,
448
+ ],
449
+ {
450
+ cwd,
451
+ encoding: 'utf8',
452
+ maxBuffer: 10 * 1024 * 1024,
453
+ timeout: (timeoutSeconds + 60) * 1000,
454
+ shell: false,
455
+ env: {
456
+ ...process.env,
457
+ AI_SPRINT_CWD: cwd,
458
+ AI_SPRINT_MODEL: model,
459
+ AI_SPRINT_TIMEOUT: String(timeoutSeconds),
460
+ AI_SPRINT_PROMPT: promptFile,
461
+ },
462
+ },
463
+ );
464
+ } else {
465
+ result = spawnSync(
466
+ nodeBin,
467
+ [acpxBin, '--cwd', cwd, '--approve-all', '--model', model, '--timeout', String(timeoutSeconds), '--prompt-retries', '2', '--suppress-reads', agent, 'exec', '-f', promptFile],
468
+ {
469
+ cwd,
470
+ encoding: 'utf8',
471
+ maxBuffer: 10 * 1024 * 1024,
472
+ timeout: (timeoutSeconds + 60) * 1000,
473
+ shell: false,
474
+ // Detach child process into its own session group so SIGHUP
475
+ // from SSH disconnect doesn't propagate to agent processes.
476
+ detached: true,
477
+ stdio: ['pipe', 'pipe', 'pipe'],
478
+ env: acpxEnv,
479
+ },
480
+ );
481
+ // Fallback if resolved path is stale (e.g., npm package updated between module load and spawn)
482
+ if (result.error && result.error.code === 'ENOENT') {
483
+ result = spawnSync(
484
+ nodeBin,
485
+ [acpxBin, '--cwd', cwd, '--approve-all', '--model', model, '--timeout', String(timeoutSeconds), '--prompt-retries', '2', '--suppress-reads', agent, 'exec', '-f', promptFile],
486
+ {
487
+ cwd,
488
+ encoding: 'utf8',
489
+ maxBuffer: 10 * 1024 * 1024,
490
+ timeout: (timeoutSeconds + 60) * 1000,
491
+ shell: false,
492
+ detached: true,
493
+ stdio: ['pipe', 'pipe', 'pipe'],
494
+ env: acpxEnv,
495
+ },
496
+ );
497
+ }
498
+ }
499
+ } finally {
500
+ fs.rmSync(promptFile, { force: true });
501
+ }
502
+
503
+ const stdout = result.stdout ?? '';
504
+ const stderr = result.stderr ?? '';
505
+
506
+ if (result.error || result.status !== 0) {
507
+ // Map acpx exit codes to semantic error types for better error classification:
508
+ // 0 = success
509
+ // 1 = runtime error (agent crash, ACP failure)
510
+ // 2 = usage error (bad flags, missing prompt)
511
+ // 3 = timeout (acpx --timeout exceeded)
512
+ // 4 = no session (session lost/closed)
513
+ // 5 = permission denied (agent approval rejected)
514
+ // 130 = interrupted (SIGINT/SIGHUP — SSH disconnect)
515
+ const exitCode = result.status ?? 1;
516
+ const errorType = exitCode === 3 ? 'timeout'
517
+ : exitCode === 4 ? 'no_session'
518
+ : exitCode === 5 ? 'permission_denied'
519
+ : exitCode === 130 ? 'interrupted'
520
+ : exitCode === 2 ? 'usage_error'
521
+ : 'runtime_error';
522
+
523
+ if ((result.error?.code === 'ETIMEDOUT' || result.signal === 'SIGTERM') && result.pid) {
524
+ terminateProcessTree(result.pid, { runDir, label: roleLabel });
525
+ }
526
+ if (failLogPath) {
527
+ ensureDir(path.dirname(failLogPath));
528
+ writeText(failLogPath, `# Agent Failure Log\n\n- agent: ${agent}\n- model: ${model}\n- exitStatus: ${result.status ?? 'N/A'}\n- exitCode: ${exitCode}\n- errorType: ${errorType}\n- error: ${result.error?.message ?? 'none'}\n\n## stdout\n\n${stdout}\n\n## stderr\n\n${stderr}\n`);
529
+ }
530
+ if (result.error) throw result.error;
531
+ throw Object.assign(new Error(`Agent ${agent} failed with status ${result.status} (${errorType})\n${stdout.slice(0, 2000)}\n${stderr.slice(0, 2000)}`), {
532
+ acpxExitCode: exitCode,
533
+ acpxErrorType: errorType,
534
+ });
535
+ }
536
+
537
+ return stdout.trim();
538
+ }
539
+
540
+ /**
541
+ * Async agent runner using spawn (non-blocking) — used for parallel reviewer execution.
542
+ * Returns a Promise that resolves with { stdout, stderr, status } or rejects on error.
543
+ */
544
+ function runAgentAsync({ cwd, agent, model, prompt, timeoutSeconds = 1800, promptDir = cwd, runDir = null, roleLabel = agent, onSpawn = null }) {
545
+ return new Promise((resolve, reject) => {
546
+ const promptFile = path.join(promptDir, `.ai-sprint-prompt-${process.pid}-${crypto.randomUUID()}.txt`);
547
+
548
+ const cleanup = () => {
549
+ try { fs.rmSync(promptFile, { force: true }); } catch {}
550
+ };
551
+
552
+ try {
553
+ fs.writeFileSync(promptFile, prompt, 'utf8');
554
+ } catch (writeErr) {
555
+ reject(new Error(`Failed to write prompt file: ${writeErr.message}`));
556
+ return;
557
+ }
558
+
559
+ const timeoutMs = (timeoutSeconds + 60) * 1000;
560
+ let settled = false;
561
+ let stdout = '';
562
+ let stderr = '';
563
+ let proc;
564
+
565
+ const timer = setTimeout(() => {
566
+ if (!settled) {
567
+ settled = true;
568
+ clearTimeout(timer);
569
+ cleanup();
570
+ terminateProcessTree(proc?.pid, { runDir, label: roleLabel });
571
+ reject(new Error(`Agent ${agent} timed out after ${timeoutSeconds}s`));
572
+ }
573
+ }, timeoutMs);
574
+
575
+ try {
576
+ if (process.platform === 'win32') {
577
+ proc = spawn('powershell.exe', [
578
+ '-NoProfile', '-Command',
579
+ `acpx --cwd $env:AI_SPRINT_CWD --approve-all --model $env:AI_SPRINT_MODEL --timeout $env:AI_SPRINT_TIMEOUT ${agent} exec -f $env:AI_SPRINT_PROMPT`,
580
+ ], {
581
+ cwd,
582
+ encoding: 'utf8',
583
+ maxBuffer: 10 * 1024 * 1024,
584
+ shell: false,
585
+ env: {
586
+ ...process.env,
587
+ AI_SPRINT_CWD: cwd,
588
+ AI_SPRINT_MODEL: model,
589
+ AI_SPRINT_TIMEOUT: String(timeoutSeconds),
590
+ AI_SPRINT_PROMPT: promptFile,
591
+ },
592
+ });
593
+ } else {
594
+ try {
595
+ proc = spawn(nodeBin, [acpxBin,
596
+ '--cwd', cwd, '--approve-all', '--model', model,
597
+ '--timeout', String(timeoutSeconds), '--prompt-retries', '2', '--suppress-reads', agent, 'exec', '-f', promptFile,
598
+ ], {
599
+ cwd,
600
+ encoding: 'utf8',
601
+ maxBuffer: 10 * 1024 * 1024,
602
+ shell: false,
603
+ detached: true,
604
+ stdio: ['pipe', 'pipe', 'pipe'],
605
+ env: acpxEnv,
606
+ });
607
+ } catch (enoentErr) {
608
+ // Fallback if resolved path is stale
609
+ if (enoentErr.code === 'ENOENT') {
610
+ proc = spawn(nodeBin, [acpxBin,
611
+ '--cwd', cwd, '--approve-all', '--model', model,
612
+ '--timeout', String(timeoutSeconds), '--prompt-retries', '2', '--suppress-reads', agent, 'exec', '-f', promptFile,
613
+ ], {
614
+ cwd,
615
+ encoding: 'utf8',
616
+ maxBuffer: 10 * 1024 * 1024,
617
+ shell: false,
618
+ detached: true,
619
+ stdio: ['pipe', 'pipe', 'pipe'],
620
+ env: acpxEnv,
621
+ });
622
+ } else {
623
+ throw enoentErr;
624
+ }
625
+ }
626
+ }
627
+ if (proc?.pid && onSpawn) onSpawn(proc.pid);
628
+ } catch (spawnErr) {
629
+ settled = true;
630
+ clearTimeout(timer);
631
+ cleanup();
632
+ reject(new Error(`Failed to spawn agent ${agent}: ${spawnErr.message}`));
633
+ return;
634
+ }
635
+
636
+ proc.stdout.on('data', (data) => { stdout += data; });
637
+ proc.stderr.on('data', (data) => { stderr += data; });
638
+ // Use 'exit' instead of 'close' — on Windows, powershell.exe child process chains
639
+ // may keep stdio pipes open after exit, causing 'close' to never fire.
640
+ // 'exit' fires when the process exits regardless of stdio pipe state.
641
+ proc.on('exit', (code) => {
642
+ if (!settled) {
643
+ settled = true;
644
+ clearTimeout(timer);
645
+ // Read any remaining buffered stdout/stderr before resolving
646
+ resolve({ stdout: stdout.trim(), stderr: stderr.trim(), status: code });
647
+ }
648
+ });
649
+ // Cleanup prompt file on close (stdio fully drained) — separate from resolve
650
+ proc.on('close', () => {
651
+ cleanup();
652
+ });
653
+ proc.on('error', (err) => {
654
+ if (!settled) {
655
+ settled = true;
656
+ clearTimeout(timer);
657
+ cleanup();
658
+ reject(err);
659
+ }
660
+ });
661
+ });
662
+ }
663
+
664
+ /**
665
+ * Check if there's progress evidence in the last N seconds.
666
+ * Progress signals:
667
+ * 1. Worklog file updated recently
668
+ * 2. Target directory has file modifications
669
+ * 3. Stdout has new content (tracked externally)
670
+ */
671
+ function checkProgressEvidence({ worktreePath, worklogPath, lastStdoutLength, currentStdout, lookbackSeconds = 120 }) {
672
+ const now = Date.now();
673
+ const lookbackMs = lookbackSeconds * 1000;
674
+ const signals = [];
675
+
676
+ // Check worklog file mtime
677
+ if (worklogPath && fileExists(worklogPath)) {
678
+ try {
679
+ const stat = fs.statSync(worklogPath);
680
+ if (now - stat.mtimeMs < lookbackMs) {
681
+ signals.push({ type: 'worklog_updated', age: Math.round((now - stat.mtimeMs) / 1000) });
682
+ }
683
+ } catch {}
684
+ }
685
+
686
+ // Check for new stdout content
687
+ if (lastStdoutLength !== undefined && currentStdout !== undefined) {
688
+ if (currentStdout.length > lastStdoutLength) {
689
+ signals.push({ type: 'stdout_grew', delta: currentStdout.length - lastStdoutLength });
690
+ }
691
+ }
692
+
693
+ // Check target directory for recent modifications
694
+ if (worktreePath && fileExists(worktreePath)) {
695
+ try {
696
+ const result = spawnSync('git', ['diff', '--name-only', '--since', `${lookbackSeconds}.seconds.ago`], {
697
+ cwd: worktreePath,
698
+ encoding: 'utf8',
699
+ timeout: 10_000,
700
+ });
701
+ const changedFiles = (result.stdout ?? '').trim().split('\n').filter(Boolean);
702
+ if (changedFiles.length > 0) {
703
+ signals.push({ type: 'files_changed', count: changedFiles.length, files: changedFiles.slice(0, 3) });
704
+ }
705
+ } catch {}
706
+ }
707
+
708
+ return {
709
+ hasProgress: signals.length > 0,
710
+ signals,
711
+ };
712
+ }
713
+
714
+ /**
715
+ * Async agent runner with progress-based timeout extension.
716
+ *
717
+ * Implements soft/hard timeout model:
718
+ * - softTimeout: check for progress, extend if evidence found
719
+ * - hardTimeout: force terminate regardless of progress
720
+ * - maxExtensions: limit total extensions to prevent infinite loops
721
+ */
722
+ function runAgentWithProgressCheck({
723
+ cwd,
724
+ agent,
725
+ model,
726
+ prompt,
727
+ timeoutSeconds = 1800,
728
+ promptDir = cwd,
729
+ runDir = null,
730
+ roleLabel = agent,
731
+ worktreePath = null,
732
+ worklogPath = null,
733
+ softTimeoutRatio = 0.67, // soft timeout at 67% of hard timeout
734
+ extensionSeconds = null, // defaults to a timeout-scaled extension window
735
+ maxExtensions = 2, // max 2 extensions unless caller overrides
736
+ progressCheckIntervalSeconds = 60,
737
+ }) {
738
+ return new Promise((resolve, reject) => {
739
+ const scaledExtensionSeconds = extensionSeconds ?? Math.max(30, Math.min(300, Math.floor(timeoutSeconds / 2)));
740
+ const hardTimeoutSeconds = timeoutSeconds + (scaledExtensionSeconds * maxExtensions);
741
+ const softTimeoutSeconds = Math.floor(timeoutSeconds * softTimeoutRatio);
742
+
743
+ let extensionsUsed = 0;
744
+ let lastStdoutLength = 0;
745
+ let lastProgressCheck = Date.now();
746
+ let settled = false;
747
+ let proc = null;
748
+ let stdout = '';
749
+ let stderr = '';
750
+
751
+ const promptFile = path.join(promptDir, `.ai-sprint-prompt-${process.pid}-${crypto.randomUUID()}.txt`);
752
+
753
+ const cleanup = () => {
754
+ try { fs.rmSync(promptFile, { force: true }); } catch {}
755
+ };
756
+
757
+ try {
758
+ fs.writeFileSync(promptFile, prompt, 'utf8');
759
+ } catch (writeErr) {
760
+ reject(new Error(`Failed to write prompt file: ${writeErr.message}`));
761
+ return;
762
+ }
763
+
764
+ // Hard timeout - absolute maximum
765
+ const hardTimer = setTimeout(() => {
766
+ if (!settled) {
767
+ settled = true;
768
+ clearTimeout(softTimer);
769
+ clearTimeout(progressTimer);
770
+ cleanup();
771
+ terminateProcessTree(proc?.pid, { runDir, label: roleLabel });
772
+ reject(new Error(`Agent ${agent} hard timeout after ${hardTimeoutSeconds}s (extended ${extensionsUsed} times)`));
773
+ }
774
+ }, hardTimeoutSeconds * 1000);
775
+
776
+ // Soft timeout - check progress and maybe extend
777
+ let softTimer;
778
+ const setupSoftTimeout = (seconds) => {
779
+ clearTimeout(softTimer);
780
+ softTimer = setTimeout(() => {
781
+ if (settled) return;
782
+
783
+ const progress = checkProgressEvidence({
784
+ worktreePath,
785
+ worklogPath,
786
+ lastStdoutLength,
787
+ currentStdout: stdout,
788
+ lookbackSeconds: 120,
789
+ });
790
+
791
+ if (progress.hasProgress && extensionsUsed < maxExtensions) {
792
+ extensionsUsed++;
793
+ lastStdoutLength = stdout.length;
794
+ if (runDir) {
795
+ appendTimeline(runDir, `Soft timeout: progress detected for ${roleLabel}, extending by ${scaledExtensionSeconds}s (${progress.signals.map(s => s.type).join(', ')})`);
796
+ }
797
+ setupSoftTimeout(scaledExtensionSeconds);
798
+ } else {
799
+ // No progress or max extensions reached - this becomes effective timeout
800
+ // But don't terminate yet - let hard timeout handle it
801
+ if (runDir && extensionsUsed >= maxExtensions) {
802
+ appendTimeline(runDir, `Soft timeout: max extensions (${maxExtensions}) reached for ${roleLabel}, waiting for hard timeout`);
803
+ } else if (runDir) {
804
+ appendTimeline(runDir, `Soft timeout: no progress detected for ${roleLabel}, waiting for hard timeout`);
805
+ }
806
+ }
807
+ }, seconds * 1000);
808
+ };
809
+
810
+ // Progress monitoring timer
811
+ let progressTimer;
812
+ const setupProgressMonitor = () => {
813
+ clearTimeout(progressTimer);
814
+ progressTimer = setTimeout(() => {
815
+ if (settled) return;
816
+
817
+ const progress = checkProgressEvidence({
818
+ worktreePath,
819
+ worklogPath,
820
+ lastStdoutLength,
821
+ currentStdout: stdout,
822
+ lookbackSeconds: 60,
823
+ });
824
+
825
+ if (progress.hasProgress) {
826
+ lastStdoutLength = stdout.length;
827
+ }
828
+
829
+ if (!settled) setupProgressMonitor();
830
+ }, progressCheckIntervalSeconds * 1000);
831
+ };
832
+
833
+ // Spawn the agent process
834
+ try {
835
+ if (process.platform === 'win32') {
836
+ proc = spawn('powershell.exe', [
837
+ '-NoProfile', '-Command',
838
+ `acpx --cwd $env:AI_SPRINT_CWD --approve-all --model $env:AI_SPRINT_MODEL --timeout $env:AI_SPRINT_TIMEOUT ${agent} exec -f $env:AI_SPRINT_PROMPT`,
839
+ ], {
840
+ cwd,
841
+ encoding: 'utf8',
842
+ maxBuffer: 10 * 1024 * 1024,
843
+ shell: false,
844
+ env: {
845
+ ...process.env,
846
+ AI_SPRINT_CWD: cwd,
847
+ AI_SPRINT_MODEL: model,
848
+ AI_SPRINT_TIMEOUT: String(hardTimeoutSeconds),
849
+ AI_SPRINT_PROMPT: promptFile,
850
+ },
851
+ });
852
+ } else {
853
+ try {
854
+ proc = spawn(nodeBin, [acpxBin,
855
+ '--cwd', cwd, '--approve-all', '--model', model,
856
+ '--timeout', String(hardTimeoutSeconds), '--prompt-retries', '2', '--suppress-reads', agent, 'exec', '-f', promptFile,
857
+ ], {
858
+ cwd,
859
+ encoding: 'utf8',
860
+ maxBuffer: 10 * 1024 * 1024,
861
+ shell: false,
862
+ detached: true,
863
+ stdio: ['pipe', 'pipe', 'pipe'],
864
+ env: acpxEnv,
865
+ });
866
+ } catch (enoentErr) {
867
+ if (enoentErr.code === 'ENOENT') {
868
+ proc = spawn(nodeBin, [acpxBin,
869
+ '--cwd', cwd, '--approve-all', '--model', model,
870
+ '--timeout', String(hardTimeoutSeconds), '--prompt-retries', '2', '--suppress-reads', agent, 'exec', '-f', promptFile,
871
+ ], {
872
+ cwd,
873
+ encoding: 'utf8',
874
+ maxBuffer: 10 * 1024 * 1024,
875
+ shell: false,
876
+ detached: true,
877
+ stdio: ['pipe', 'pipe', 'pipe'],
878
+ env: acpxEnv,
879
+ });
880
+ } else {
881
+ throw enoentErr;
882
+ }
883
+ }
884
+ }
885
+ } catch (spawnErr) {
886
+ settled = true;
887
+ clearTimeout(hardTimer);
888
+ clearTimeout(softTimer);
889
+ clearTimeout(progressTimer);
890
+ cleanup();
891
+ reject(new Error(`Failed to spawn agent ${agent}: ${spawnErr.message}`));
892
+ return;
893
+ }
894
+
895
+ proc.stdout.on('data', (data) => { stdout += data; });
896
+ proc.stderr.on('data', (data) => { stderr += data; });
897
+
898
+ proc.on('exit', (code) => {
899
+ if (!settled) {
900
+ settled = true;
901
+ clearTimeout(hardTimer);
902
+ clearTimeout(softTimer);
903
+ clearTimeout(progressTimer);
904
+ resolve({ stdout: stdout.trim(), stderr: stderr.trim(), status: code, extensionsUsed });
905
+ }
906
+ });
907
+
908
+ proc.on('close', () => { cleanup(); });
909
+
910
+ proc.on('error', (err) => {
911
+ if (!settled) {
912
+ settled = true;
913
+ clearTimeout(hardTimer);
914
+ clearTimeout(softTimer);
915
+ clearTimeout(progressTimer);
916
+ cleanup();
917
+ reject(err);
918
+ }
919
+ });
920
+
921
+ // Start timers
922
+ setupSoftTimeout(softTimeoutSeconds);
923
+ setupProgressMonitor();
924
+ });
925
+ }
926
+
927
+ function roleConfig(spec, role) {
928
+ if (role === 'producer') return spec.producer;
929
+ if (role === 'reviewer_a') return spec.reviewerA;
930
+ if (role === 'reviewer_b') return spec.reviewerB;
931
+ if (role === 'global_reviewer') return spec.escalationReviewer ?? null;
932
+ throw new Error(`Unknown role: ${role}`);
933
+ }
934
+
935
+ function roleAttemptConfigs(config) {
936
+ if (!config) return [];
937
+ const primary = { ...config, fallback: undefined, attemptLabel: 'primary' };
938
+ const attempts = [primary];
939
+
940
+ if (config.fallback?.agent && config.fallback?.model) {
941
+ attempts.push({
942
+ ...config,
943
+ ...config.fallback,
944
+ fallback: undefined,
945
+ attemptLabel: 'fallback',
946
+ });
947
+ } else if (config.retryOnce === true) {
948
+ attempts.push({
949
+ ...config,
950
+ fallback: undefined,
951
+ attemptLabel: 'retry',
952
+ });
953
+ }
954
+
955
+ return attempts;
956
+ }
957
+
958
+ function loadSpec(state, args) {
959
+ const specPath = (args && args.taskSpec) || (state && state.specPath) || null;
960
+ return getTaskSpec(state.taskId, specPath);
961
+ }
962
+
963
+ /**
964
+ * Resolve per-role timeout for a given stage.
965
+ * Priority: spec.stageRoleTimeouts[stage][role] > config.timeoutSeconds > 600
966
+ */
967
+ function stageRoleTimeout(spec, stage, role) {
968
+ if (spec.stageRoleTimeouts?.[stage]?.[role] != null) {
969
+ return spec.stageRoleTimeouts[stage][role];
970
+ }
971
+ const config = roleConfig(spec, role);
972
+ return config?.timeoutSeconds ?? 600;
973
+ }
974
+
975
+ function ensureStagePaths(runDir, stageIndex, stageName) {
976
+ const stageDir = path.join(runDir, 'stages', `${String(stageIndex + 1).padStart(2, '0')}-${stageName}`);
977
+ ensureDir(stageDir);
978
+ const paths = {
979
+ stageDir,
980
+ briefPath: path.join(stageDir, 'brief.md'),
981
+ producerPath: path.join(stageDir, 'producer.md'),
982
+ reviewerAPath: path.join(stageDir, 'reviewer-a.md'),
983
+ reviewerBPath: path.join(stageDir, 'reviewer-b.md'),
984
+ globalReviewerPath: path.join(stageDir, 'global-reviewer.md'),
985
+ decisionPath: path.join(stageDir, 'decision.md'),
986
+ scorecardPath: path.join(stageDir, 'scorecard.json'),
987
+ producerStdoutPath: path.join(stageDir, 'producer-stdout.log'),
988
+ reviewerAStdoutPath: path.join(stageDir, 'reviewer-a-stdout.log'),
989
+ reviewerBStdoutPath: path.join(stageDir, 'reviewer-b-stdout.log'),
990
+ globalReviewerStdoutPath: path.join(stageDir, 'global-reviewer-stdout.log'),
991
+ producerWorklogPath: path.join(stageDir, 'producer-worklog.md'),
992
+ reviewerAWorklogPath: path.join(stageDir, 'reviewer-a-worklog.md'),
993
+ reviewerBWorklogPath: path.join(stageDir, 'reviewer-b-worklog.md'),
994
+ globalReviewerWorklogPath: path.join(stageDir, 'global-reviewer-worklog.md'),
995
+ producerStatePath: path.join(stageDir, 'producer-state.json'),
996
+ reviewerAStatePath: path.join(stageDir, 'reviewer-a-state.json'),
997
+ reviewerBStatePath: path.join(stageDir, 'reviewer-b-state.json'),
998
+ globalReviewerStatePath: path.join(stageDir, 'global-reviewer-state.json'),
999
+ };
1000
+
1001
+ const placeholderFiles = [
1002
+ paths.producerWorklogPath,
1003
+ paths.reviewerAWorklogPath,
1004
+ paths.reviewerBWorklogPath,
1005
+ paths.globalReviewerWorklogPath,
1006
+ ];
1007
+ for (const file of placeholderFiles) {
1008
+ if (!fileExists(file)) {
1009
+ writeText(file, '# Worklog\n\n');
1010
+ }
1011
+ }
1012
+
1013
+ const placeholderStates = [
1014
+ [paths.producerStatePath, 'producer'],
1015
+ [paths.reviewerAStatePath, 'reviewer_a'],
1016
+ [paths.reviewerBStatePath, 'reviewer_b'],
1017
+ [paths.globalReviewerStatePath, 'global_reviewer'],
1018
+ ];
1019
+ for (const [file, role] of placeholderStates) {
1020
+ if (!fileExists(file)) {
1021
+ writeJson(file, {
1022
+ role,
1023
+ stage: stageName,
1024
+ round: 0,
1025
+ status: 'idle',
1026
+ lastPid: null,
1027
+ startedAt: null,
1028
+ finishedAt: null,
1029
+ terminatedAt: null,
1030
+ timeoutSeconds: null,
1031
+ lastError: null,
1032
+ checklist: [],
1033
+ updatedAt: nowIso(),
1034
+ });
1035
+ }
1036
+ }
1037
+
1038
+ return paths;
1039
+ }
1040
+
1041
+ function roleArtifactPaths(paths, role) {
1042
+ if (role === 'producer') {
1043
+ return { reportPath: paths.producerPath, stdoutPath: paths.producerStdoutPath };
1044
+ }
1045
+ if (role === 'reviewer_a') {
1046
+ return { reportPath: paths.reviewerAPath, stdoutPath: paths.reviewerAStdoutPath };
1047
+ }
1048
+ if (role === 'reviewer_b') {
1049
+ return { reportPath: paths.reviewerBPath, stdoutPath: paths.reviewerBStdoutPath };
1050
+ }
1051
+ if (role === 'global_reviewer') {
1052
+ return { reportPath: paths.globalReviewerPath, stdoutPath: paths.globalReviewerStdoutPath };
1053
+ }
1054
+ throw new Error(`Unknown role: ${role}`);
1055
+ }
1056
+
1057
+ function roleStatePath(paths, role) {
1058
+ if (role === 'producer') return paths.producerStatePath;
1059
+ if (role === 'reviewer_a') return paths.reviewerAStatePath;
1060
+ if (role === 'reviewer_b') return paths.reviewerBStatePath;
1061
+ if (role === 'global_reviewer') return paths.globalReviewerStatePath;
1062
+ throw new Error(`Unknown role: ${role}`);
1063
+ }
1064
+
1065
+ function updateRoleState(paths, role, patch) {
1066
+ const statePath = roleStatePath(paths, role);
1067
+ const previous = fileExists(statePath) ? readJson(statePath) : { role };
1068
+ writeJson(statePath, {
1069
+ ...previous,
1070
+ ...patch,
1071
+ role,
1072
+ updatedAt: nowIso(),
1073
+ });
1074
+ }
1075
+
1076
+ function terminateProcessTree(pid, { runDir = null, label = 'process' } = {}) {
1077
+ if (!pid) return false;
1078
+ try {
1079
+ if (process.platform === 'win32') {
1080
+ const result = spawnSync('taskkill', ['/T', '/F', '/PID', String(pid)], {
1081
+ encoding: 'utf8',
1082
+ timeout: 30_000,
1083
+ shell: false,
1084
+ });
1085
+ const output = `${result.stdout ?? ''}\n${result.stderr ?? ''}`.trim();
1086
+ const success = result.status === 0 || /not found|no running instance|not found/i.test(output);
1087
+ if (runDir) appendTimeline(runDir, `${success ? 'Terminated' : 'Failed to terminate'} ${label} process tree pid=${pid}${output ? ` (${output})` : ''}`);
1088
+ return success;
1089
+ }
1090
+ // Kill entire process group — negative pid sends signal to all processes
1091
+ // in the same process group. Fallback to direct pid kill if group kill
1092
+ // fails (e.g., process group already destroyed).
1093
+ try {
1094
+ process.kill(-pid, 'SIGKILL');
1095
+ if (runDir) appendTimeline(runDir, `Terminated ${label} process group pgid=${pid}`);
1096
+ } catch (groupErr) {
1097
+ if (groupErr.code === 'ESRCH') {
1098
+ // Process already dead — expected during normal cleanup
1099
+ if (runDir) appendTimeline(runDir, `${label} process already exited pid=${pid}`);
1100
+ return true;
1101
+ }
1102
+ process.kill(pid, 'SIGKILL');
1103
+ if (runDir) appendTimeline(runDir, `Terminated ${label} pid=${pid} (group kill failed: ${groupErr.message})`);
1104
+ }
1105
+ return true;
1106
+ } catch (err) {
1107
+ if (err.code === 'ESRCH') {
1108
+ // Process already dead — expected during normal cleanup
1109
+ if (runDir) appendTimeline(runDir, `${label} process already exited pid=${pid}`);
1110
+ return true;
1111
+ }
1112
+ if (runDir) appendTimeline(runDir, `Failed to terminate ${label} pid=${pid}: ${err.message}`);
1113
+ return false;
1114
+ }
1115
+ }
1116
+
1117
+ function cleanupRecordedRoleProcesses(paths, runDir) {
1118
+ const roles = ['producer', 'reviewer_a', 'reviewer_b', 'global_reviewer'];
1119
+ for (const role of roles) {
1120
+ const statePath = roleStatePath(paths, role);
1121
+ if (!fileExists(statePath)) continue;
1122
+ const roleState = readJson(statePath);
1123
+ if (roleState.lastPid) {
1124
+ const terminated = terminateProcessTree(roleState.lastPid, { runDir, label: role });
1125
+ if (terminated) {
1126
+ updateRoleState(paths, role, {
1127
+ status: roleState.status === 'completed' ? roleState.status : 'terminated',
1128
+ terminatedAt: nowIso(),
1129
+ lastPid: null,
1130
+ });
1131
+ } else {
1132
+ updateRoleState(paths, role, {
1133
+ lastError: roleState.lastError ?? `Failed to terminate recorded ${role} pid ${roleState.lastPid}`,
1134
+ });
1135
+ }
1136
+ }
1137
+ }
1138
+ }
1139
+
1140
+ /**
1141
+ * ISOLATION ARTIFACT ALLOWLIST
1142
+ * Only these files can be collected from isolation directory to stage directory.
1143
+ * This prevents accidental collection of logs, temp files, or other runtime artifacts.
1144
+ */
1145
+ export const ISOLATION_COLLECT_ALLOWLIST = [
1146
+ // Report files - the primary artifacts we want to collect
1147
+ 'report.md',
1148
+ // Stage-specific report filenames
1149
+ 'producer.md',
1150
+ 'reviewer-a.md',
1151
+ 'reviewer-b.md',
1152
+ 'global-reviewer.md',
1153
+ ];
1154
+
1155
+ /**
1156
+ * Check if a filename is allowed to be collected from isolation directory.
1157
+ * @param {string} filename - The filename to check (basename only, no path)
1158
+ * @returns {boolean} True if the file is in the allowlist
1159
+ */
1160
+ export function isIsolationCollectAllowed(filename) {
1161
+ return ISOLATION_COLLECT_ALLOWLIST.includes(filename);
1162
+ }
1163
+
1164
+ /**
1165
+ * Get the isolation directory path for a specific run/stage/role.
1166
+ * This is the canonical isolation directory format: runtime/tmp/sprint-agent/{runId}/{stage}-{role}/
1167
+ *
1168
+ * @param {string} runId - The unique run identifier (e.g., "2026-04-02T14-24-34-009Z-task-name")
1169
+ * @param {string} stageName - The stage name (e.g., "implement", "verify")
1170
+ * @param {string} role - The role name (e.g., "producer", "reviewer_a")
1171
+ * @returns {string} The isolation directory path
1172
+ */
1173
+ export function getIsolationDir(runId, stageName, role) {
1174
+ const roleDir = `${stageName}-${role}`;
1175
+ return path.join(tempRoot, 'sprint-agent', runId, roleDir);
1176
+ }
1177
+
1178
+ /**
1179
+ * Find report in iflow isolation directory.
1180
+ * iflow writes to runtime/tmp/sprint-agent/{runId}/{stage}-{role}/{report}.md
1181
+ *
1182
+ * IMPORTANT: Uses runId directly for isolation lookup, not fragile timestamp extraction.
1183
+ * This ensures different runs have unique isolation directories and prevents cross-contamination.
1184
+ *
1185
+ * @param {object} options
1186
+ * @param {string} options.runId - The unique run identifier
1187
+ * @param {string} options.stageName - The stage name
1188
+ * @param {string} options.role - The role name
1189
+ * @param {string} options.reportFilename - The report filename to look for
1190
+ * @returns {string|null} The isolation report path if found and valid, null otherwise
1191
+ */
1192
+ export function findIsolationReport({ runId, stageName, role, reportFilename }) {
1193
+ if (!runId) return null;
1194
+
1195
+ const isolationDir = getIsolationDir(runId, stageName, role);
1196
+ const isolationReportPath = path.join(isolationDir, reportFilename);
1197
+
1198
+ if (fileExists(isolationReportPath)) {
1199
+ const content = fs.readFileSync(isolationReportPath, 'utf8').trim();
1200
+ // Only return if it contains actual report content (not just session log)
1201
+ if (content && (content.includes('## VERDICT') || content.includes('## SUMMARY'))) {
1202
+ return isolationReportPath;
1203
+ }
1204
+ }
1205
+ return null;
1206
+ }
1207
+
1208
+ /**
1209
+ * Collect allowed artifacts from isolation directory to stage directory.
1210
+ * Only files in ISOLATION_COLLECT_ALLOWLIST will be collected.
1211
+ *
1212
+ * @param {object} options
1213
+ * @param {string} options.runId - The unique run identifier
1214
+ * @param {string} options.stageName - The stage name
1215
+ * @param {string} options.role - The role name
1216
+ * @param {string} options.stageDir - The stage directory to collect to
1217
+ * @param {string} options.reportFilename - The expected report filename
1218
+ * @param {string} options.runDir - The run directory (for timeline logging)
1219
+ * @returns {{collected: string[], skipped: string[], isolationReportPath: string|null}} Lists of collected and skipped files, plus the isolation report path if valid
1220
+ */
1221
+ export function collectIsolationArtifacts({ runId, stageName, role, stageDir, reportFilename, runDir }) {
1222
+ const result = { collected: [], skipped: [], isolationReportPath: null };
1223
+
1224
+ if (!runId) return result;
1225
+
1226
+ const isolationDir = getIsolationDir(runId, stageName, role);
1227
+ if (!fileExists(isolationDir)) return result;
1228
+
1229
+ // Only collect the specific report file if it's in the allowlist
1230
+ if (!isIsolationCollectAllowed(reportFilename)) {
1231
+ result.skipped.push(reportFilename);
1232
+ return result;
1233
+ }
1234
+
1235
+ const isolationReportPath = path.join(isolationDir, reportFilename);
1236
+ const stageReportPath = path.join(stageDir, reportFilename);
1237
+
1238
+ if (!fileExists(isolationReportPath)) return result;
1239
+
1240
+ const isolationContent = fs.readFileSync(isolationReportPath, 'utf8').trim();
1241
+ const stageContent = fileExists(stageReportPath) ? fs.readFileSync(stageReportPath, 'utf8').trim() : '';
1242
+
1243
+ // Only collect if isolation report has actual content and stage report is missing or empty
1244
+ const isolationHasReport = isolationContent && (isolationContent.includes('## VERDICT') || isolationContent.includes('## SUMMARY'));
1245
+ const stageHasReport = stageContent && (stageContent.includes('## VERDICT') || stageContent.includes('## SUMMARY'));
1246
+
1247
+ if (isolationHasReport) {
1248
+ result.isolationReportPath = isolationReportPath; // Always return path when valid content exists
1249
+ if (!stageHasReport) {
1250
+ writeText(stageReportPath, `${isolationContent}\n`);
1251
+ appendTimeline(runDir, `Collected isolation artifact: ${reportFilename}`);
1252
+ result.collected.push(reportFilename);
1253
+ } else {
1254
+ result.skipped.push(reportFilename);
1255
+ }
1256
+ } else {
1257
+ result.skipped.push(reportFilename);
1258
+ }
1259
+
1260
+ return result;
1261
+ }
1262
+
1263
+ function readRoleOutput({ reportPath, stdout, isolationReportPath = null }) {
1264
+ // First try the expected report path
1265
+ if (fileExists(reportPath)) {
1266
+ const report = fs.readFileSync(reportPath, 'utf8').trim();
1267
+ if (report) {
1268
+ return report;
1269
+ }
1270
+ }
1271
+ // Fallback to isolation directory if provided
1272
+ if (isolationReportPath && fileExists(isolationReportPath)) {
1273
+ const report = fs.readFileSync(isolationReportPath, 'utf8').trim();
1274
+ if (report) {
1275
+ return report;
1276
+ }
1277
+ }
1278
+ return String(stdout ?? '').trim();
1279
+ }
1280
+
1281
+ function reportExistsAndNonEmpty(reportPath) {
1282
+ return fileExists(reportPath) && Boolean(fs.readFileSync(reportPath, 'utf8').trim());
1283
+ }
1284
+
1285
+ const PROTECTED_CRITICAL = ['sprint.json', 'timeline.md', 'latest-summary.md'];
1286
+ const PROTECTED_STAGE_CRITICAL = ['decision.md', 'scorecard.json'];
1287
+
1288
+ function protectedArtifacts(runDir, paths) {
1289
+ // Roles must not modify stage-level truth sources that are written only after role execution.
1290
+ // Do not include run-level timeline/latest-summary here: the orchestrator itself updates those
1291
+ // while roles are still running (timeouts, progress signals, state transitions), which would
1292
+ // create false protected-file violations.
1293
+ return [
1294
+ path.join(runDir, 'sprint.json'),
1295
+ paths.decisionPath,
1296
+ paths.scorecardPath,
1297
+ ];
1298
+ }
1299
+
1300
+ function classifyProtectedFile(filePath, runDir) {
1301
+ const basename = path.basename(filePath);
1302
+ if (!runDir) {
1303
+ if (PROTECTED_CRITICAL.includes(basename) || PROTECTED_STAGE_CRITICAL.includes(basename)) {
1304
+ return 'critical';
1305
+ }
1306
+ return 'warn';
1307
+ }
1308
+ const stageDir = path.join(runDir, 'stages');
1309
+ if (filePath.startsWith(stageDir)) {
1310
+ return PROTECTED_STAGE_CRITICAL.includes(basename) ? 'critical' : 'warn';
1311
+ }
1312
+ return PROTECTED_CRITICAL.includes(basename) ? 'critical' : 'warn';
1313
+ }
1314
+
1315
+ function snapshotProtectedFiles(files) {
1316
+ const snapshot = {};
1317
+ for (const file of files) {
1318
+ snapshot[file] = fileExists(file) ? fs.statSync(file).mtimeMs : null;
1319
+ }
1320
+ return snapshot;
1321
+ }
1322
+
1323
+ function detectProtectedWriteViolation(files, snapshot, runDir) {
1324
+ for (const file of files) {
1325
+ const previous = snapshot[file] ?? null;
1326
+ const current = fileExists(file) ? fs.statSync(file).mtimeMs : null;
1327
+ if (previous !== current) {
1328
+ return { file, severity: classifyProtectedFile(file, runDir) };
1329
+ }
1330
+ }
1331
+ return null;
1332
+ }
1333
+
1334
+ /**
1335
+ * Format a role-level validation result for decision.md.
1336
+ * @param {string} roleName - Display name (e.g., "Producer", "Reviewer A")
1337
+ * @param {{valid: boolean, missingSections?: string[], invalidFields?: string[], errorSummary?: string|null}} roleResult
1338
+ * @returns {string[]} Lines for decision.md
1339
+ */
1340
+ export function formatRoleValidation(roleName, roleResult) {
1341
+ if (!roleResult) return [];
1342
+ const lines = [];
1343
+ const status = roleResult.valid ? '[OK]' : '[FAIL]';
1344
+ const mainLine = `- ${roleName}: ${status}`;
1345
+
1346
+ if (roleResult.valid) {
1347
+ lines.push(mainLine);
1348
+ } else {
1349
+ // Show error summary if available
1350
+ const details = [];
1351
+ if (roleResult.missingSections?.length) {
1352
+ details.push(`missing: ${roleResult.missingSections.join(', ')}`);
1353
+ }
1354
+ if (roleResult.invalidFields?.length) {
1355
+ details.push(`invalid: ${roleResult.invalidFields.join(', ')}`);
1356
+ }
1357
+ lines.push(`${mainLine} ${details.join('; ')}`);
1358
+ }
1359
+ return lines;
1360
+ }
1361
+
1362
+ /**
1363
+ * Validate that a report contains the minimum required markdown section headings.
1364
+ * Returns an array of missing section descriptions (empty = pass).
1365
+ *
1366
+ * WF-003 fix: reviewer report format enforcement — prevents stage_error from
1367
+ * reports that exist on disk but lack required sections.
1368
+ * WF-004 fix: producer report format enforcement — prevents max_rounds_exceeded
1369
+ * by giving explicit feedback about which sections are missing.
1370
+ */
1371
+ function validateReportSections(text, requiredSections, reportLabel) {
1372
+ const missing = [];
1373
+ for (const section of requiredSections) {
1374
+ // Match ## or ### heading level, case-insensitive, word boundary after section name
1375
+ const safeSection = section.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1376
+ const re = new RegExp(`^#{2,3}\\s+${safeSection}\\b`, 'im');
1377
+ if (!re.test(text)) {
1378
+ missing.push(`${reportLabel}: missing required section "${section}"`);
1379
+ }
1380
+ }
1381
+ return missing;
1382
+ }
1383
+
1384
+ /**
1385
+ * Core reviewer-report minimum schema. Without these sections, the decision
1386
+ * engine cannot produce valid verdicts or blockers, and the stage will fail
1387
+ * with an opaque stage_error.
1388
+ */
1389
+ const REQUIRED_REVIEWER_SECTIONS = ['VERDICT', 'FINDINGS', 'BLOCKERS', 'NEXT_FOCUS', 'CHECKS'];
1390
+
1391
+ export function decideAndPersist({ runDir, stageName, stageDir, decisionPath, scorecardPath, producerPath, reviewerAPath, reviewerBPath, globalReviewerPath, state, reviewerTimeouts = null, reviewerViolations = null, reviewerFailures = null }) {
1392
+ const spec = loadSpec(state);
1393
+ const workUnit = getActiveWorkUnit(spec, stageName, {
1394
+ workUnitIndex: state.currentWorkUnitIndex ?? 0,
1395
+ });
1396
+ // Build lookup maps for reviewer timeout/error state
1397
+ const timeoutMap = new Map((reviewerTimeouts ?? []).map((r) => [r.role, r]));
1398
+ const failureMap = new Map((reviewerFailures ?? []).map((r) => [r.role, r]));
1399
+ const missingFiles = [];
1400
+ if (!reportExistsAndNonEmpty(producerPath)) missingFiles.push(`producer: ${producerPath}${fileExists(producerPath) ? ' (empty)' : ''}`);
1401
+ if (!reportExistsAndNonEmpty(reviewerAPath)) {
1402
+ const info = timeoutMap.get('reviewer_a');
1403
+ const failure = failureMap.get('reviewer_a');
1404
+ if (info?.timedOut) missingFiles.push(`reviewer_a: ${reviewerAPath} (timed out, no report${failure?.summary ? `; ${failure.summary}` : ''})`);
1405
+ else if (info || failure) missingFiles.push(`reviewer_a: ${reviewerAPath} (error, no report${failure?.summary ? `; ${failure.summary}` : ''})`);
1406
+ else missingFiles.push(`reviewer_a: ${reviewerAPath}${fileExists(reviewerAPath) ? ' (empty)' : ' (missing)'}`);
1407
+ }
1408
+ if (!reportExistsAndNonEmpty(reviewerBPath)) {
1409
+ const info = timeoutMap.get('reviewer_b');
1410
+ const failure = failureMap.get('reviewer_b');
1411
+ if (info?.timedOut) missingFiles.push(`reviewer_b: ${reviewerBPath} (timed out, no report${failure?.summary ? `; ${failure.summary}` : ''})`);
1412
+ else if (info || failure) missingFiles.push(`reviewer_b: ${reviewerBPath} (error, no report${failure?.summary ? `; ${failure.summary}` : ''})`);
1413
+ else missingFiles.push(`reviewer_b: ${reviewerBPath}${fileExists(reviewerBPath) ? ' (empty)' : ' (missing)'}`);
1414
+ }
1415
+
1416
+ const stageCriteria = spec.stageCriteria?.[stageName] ?? {};
1417
+ const globalReviewerRequired = stageCriteria.globalReviewerRequired === true;
1418
+ if (globalReviewerRequired && globalReviewerPath && !reportExistsAndNonEmpty(globalReviewerPath)) {
1419
+ missingFiles.push(`global_reviewer: ${globalReviewerPath}${fileExists(globalReviewerPath) ? ' (empty — required for this stage)' : ' (missing — required for this stage)'}`);
1420
+ }
1421
+
1422
+ if (missingFiles.length > 0) {
1423
+ const failure = inferFailureClassification({
1424
+ summary: `Missing reports: ${missingFiles.join('; ')}`,
1425
+ blockers: missingFiles,
1426
+ reviewerTimeouts,
1427
+ reviewerViolations,
1428
+ });
1429
+ writeText(decisionPath, [
1430
+ `# Decision`,
1431
+ '',
1432
+ `- Stage: ${stageName}`,
1433
+ `- Round: ${state.currentRound}`,
1434
+ `- Outcome: error`,
1435
+ '',
1436
+ `## Missing reports`,
1437
+ ...missingFiles.map((f) => `- ${f}`),
1438
+ '',
1439
+ `Cannot render stage decision because required role reports are missing.`,
1440
+ '',
1441
+ ].join('\n'));
1442
+ writeJson(scorecardPath, {
1443
+ stage: stageName,
1444
+ round: state.currentRound,
1445
+ outcome: 'error',
1446
+ summary: `Missing reports: ${missingFiles.join('; ')}`,
1447
+ missingReports: missingFiles,
1448
+ failureClassification: failure.failureClassification,
1449
+ failureSource: failure.failureSource,
1450
+ recommendedNextAction: failure.recommendedNextAction,
1451
+ reviewerTimeouts: reviewerTimeouts ?? null,
1452
+ reviewerFailures: reviewerFailures ?? null,
1453
+ updatedAt: nowIso(),
1454
+ });
1455
+ appendTimeline(runDir, `Stage ${stageName} round ${state.currentRound} decision: error (missing reports)`);
1456
+ updateSummaryWithClassification(runDir, [
1457
+ `Status: ${state.status}`,
1458
+ `Stage: ${stageName}`,
1459
+ `Round: ${state.currentRound}`,
1460
+ `Outcome: error`,
1461
+ `Missing: ${missingFiles.join('; ')}`,
1462
+ ], failure);
1463
+ return { outcome: 'error', summary: `Missing reports: ${missingFiles.join('; ')}`, blockers: missingFiles, metrics: {} };
1464
+ }
1465
+
1466
+ const producer = fs.readFileSync(producerPath, 'utf8');
1467
+ const reviewerA = fs.readFileSync(reviewerAPath, 'utf8');
1468
+ const reviewerB = fs.readFileSync(reviewerBPath, 'utf8');
1469
+ const globalReviewer = (globalReviewerRequired && globalReviewerPath && reportExistsAndNonEmpty(globalReviewerPath))
1470
+ ? fs.readFileSync(globalReviewerPath, 'utf8')
1471
+ : null;
1472
+
1473
+ // Read reviewer state JSON for dimensions fallback (agent may write dimensions to state but not report text)
1474
+ const reviewerAStatePath = path.join(stageDir, 'reviewer-a-state.json');
1475
+ const reviewerBStatePath = path.join(stageDir, 'reviewer-b-state.json');
1476
+ let reviewerADimensionsFallback = null;
1477
+ let reviewerBDimensionsFallback = null;
1478
+ try {
1479
+ if (fileExists(reviewerAStatePath)) {
1480
+ const aState = readJson(reviewerAStatePath);
1481
+ if (aState.dimensions && typeof aState.dimensions === 'object') reviewerADimensionsFallback = aState.dimensions;
1482
+ }
1483
+ } catch {}
1484
+ try {
1485
+ if (fileExists(reviewerBStatePath)) {
1486
+ const bState = readJson(reviewerBStatePath);
1487
+ if (bState.dimensions && typeof bState.dimensions === 'object') reviewerBDimensionsFallback = bState.dimensions;
1488
+ }
1489
+ } catch {}
1490
+
1491
+ // WF-003/WF-004 fix: validate report schema before entering decision engine.
1492
+ // Reports that exist on disk but lack required sections cause opaque stage_error
1493
+ // or silent max_rounds_exceeded. By validating here, we get explicit error
1494
+ // messages that tell the agent exactly which sections are missing.
1495
+ //
1496
+ // Return 'error' outcome (which advanceState() maps to stage_error → halt).
1497
+ // This is intentional: if both reviewers fail schema validation simultaneously,
1498
+ // the stage cannot proceed. The explicit blockers tell the Executor exactly
1499
+ // what to fix in the next sprint.
1500
+ const schemaErrors = [];
1501
+ const missingReviewerA = validateReportSections(reviewerA, REQUIRED_REVIEWER_SECTIONS, 'reviewer_a');
1502
+ const missingReviewerB = validateReportSections(reviewerB, REQUIRED_REVIEWER_SECTIONS, 'reviewer_b');
1503
+ if (missingReviewerA.length) schemaErrors.push(...missingReviewerA);
1504
+ if (missingReviewerB.length) schemaErrors.push(...missingReviewerB);
1505
+
1506
+ if (schemaErrors.length > 0) {
1507
+ const errorSummary = `Report schema violation: ${schemaErrors.join('; ')}`;
1508
+ const failure = inferFailureClassification({
1509
+ summary: errorSummary,
1510
+ blockers: schemaErrors,
1511
+ reviewerTimeouts,
1512
+ reviewerViolations,
1513
+ });
1514
+ writeText(decisionPath, [
1515
+ `# Decision`,
1516
+ '',
1517
+ `- Stage: ${stageName}`,
1518
+ `- Round: ${state.currentRound}`,
1519
+ `- Outcome: error`,
1520
+ '',
1521
+ `## Report Schema Violation`,
1522
+ ...schemaErrors.map((e) => `- ${e}`),
1523
+ '',
1524
+ `Cannot render stage decision because role reports are missing required sections.`,
1525
+ '',
1526
+ ].join('\n'));
1527
+ writeJson(scorecardPath, {
1528
+ stage: stageName,
1529
+ round: state.currentRound,
1530
+ outcome: 'error',
1531
+ summary: errorSummary,
1532
+ missingReports: schemaErrors,
1533
+ failureClassification: failure.failureClassification,
1534
+ failureSource: failure.failureSource,
1535
+ recommendedNextAction: failure.recommendedNextAction,
1536
+ reviewerTimeouts: reviewerTimeouts ?? null,
1537
+ updatedAt: nowIso(),
1538
+ schemaValidation: {
1539
+ reviewerA_sections: REQUIRED_REVIEWER_SECTIONS,
1540
+ reviewerA_missing: missingReviewerA,
1541
+ reviewerB_sections: REQUIRED_REVIEWER_SECTIONS,
1542
+ reviewerB_missing: missingReviewerB,
1543
+ },
1544
+ });
1545
+ appendTimeline(runDir, `Stage ${stageName} round ${state.currentRound} decision: error (report schema violation)`);
1546
+ updateSummaryWithClassification(runDir, [
1547
+ `Status: ${state.status}`,
1548
+ `Stage: ${stageName}`,
1549
+ `Round: ${state.currentRound}`,
1550
+ `Outcome: error`,
1551
+ `Schema violations: ${schemaErrors.join('; ')}`,
1552
+ ], failure);
1553
+ return { outcome: 'error', summary: errorSummary, blockers: schemaErrors, metrics: {} };
1554
+ }
1555
+
1556
+ const decision = decideStage({
1557
+ stageCriteria: spec.stageCriteria?.[stageName],
1558
+ producer,
1559
+ reviewerA,
1560
+ reviewerB,
1561
+ globalReviewer: globalReviewerRequired ? globalReviewer : null,
1562
+ currentRound: state.currentRound,
1563
+ maxRoundsPerStage: state.maxRoundsPerStage,
1564
+ reviewerViolations,
1565
+ reviewerADimensionsFallback,
1566
+ reviewerBDimensionsFallback,
1567
+ });
1568
+
1569
+ const content = [
1570
+ `# Decision`,
1571
+ '',
1572
+ `- Stage: ${stageName}`,
1573
+ `- Round: ${state.currentRound}`,
1574
+ `- Outcome: ${decision.outcome}`,
1575
+ `- Output Quality: ${decision.outputQuality}`,
1576
+ ...(workUnit
1577
+ ? [
1578
+ `- Work Unit: ${workUnit.workUnitId}`,
1579
+ `- Work Unit Goal: ${workUnit.workUnitGoal}`,
1580
+ ]
1581
+ : []),
1582
+ '',
1583
+ `## Summary`,
1584
+ decision.summary,
1585
+ '',
1586
+ ...(decision.qualityReasons?.length
1587
+ ? [
1588
+ `## Quality Reasons`,
1589
+ ...decision.qualityReasons.map((r) => `- ${r}`),
1590
+ '',
1591
+ ]
1592
+ : []),
1593
+ ...(decision.nextRunRecommendation
1594
+ ? [
1595
+ `## Next Run Recommendation`,
1596
+ `- Type: ${decision.nextRunRecommendation.type}`,
1597
+ ...(decision.nextRunRecommendation.spec
1598
+ ? [`- Spec: ${decision.nextRunRecommendation.spec}`]
1599
+ : []),
1600
+ ...(decision.nextRunRecommendation.reasons?.length
1601
+ ? ['', 'Reasons:', ...decision.nextRunRecommendation.reasons.map((r) => `- ${r}`)]
1602
+ : []),
1603
+ '',
1604
+ ]
1605
+ : []),
1606
+ `## Validation`,
1607
+ `- Contract Valid: ${decision.validation?.valid ?? 'n/a'}`,
1608
+ ...(decision.validation?.producer
1609
+ ? formatRoleValidation('Producer', decision.validation.producer)
1610
+ : []),
1611
+ ...(decision.validation?.reviewerA
1612
+ ? formatRoleValidation('Reviewer A', decision.validation.reviewerA)
1613
+ : []),
1614
+ ...(decision.validation?.reviewerB
1615
+ ? formatRoleValidation('Reviewer B', decision.validation.reviewerB)
1616
+ : []),
1617
+ ...(decision.validation?.globalReviewer
1618
+ ? formatRoleValidation('Global Reviewer', decision.validation.globalReviewer)
1619
+ : []),
1620
+ ...(decision.validation?.errorSummary
1621
+ ? ['', `### Error Summary`, decision.validation.errorSummary]
1622
+ : []),
1623
+ '',
1624
+ `## Blockers`,
1625
+ ...(decision.blockers.length ? decision.blockers.map((b) => `- ${b}`) : ['- None.']),
1626
+ '',
1627
+ `## Metrics`,
1628
+ `- approvalCount: ${decision.metrics.approvalCount}`,
1629
+ `- blockerCount: ${decision.metrics.blockerCount}`,
1630
+ `- reviewerAVerdict: ${decision.metrics.reviewerAVerdict}`,
1631
+ `- reviewerBVerdict: ${decision.metrics.reviewerBVerdict}`,
1632
+ `- producerSectionChecks: ${JSON.stringify(decision.metrics.producerSectionChecks)}`,
1633
+ `- reviewerSectionChecks: ${JSON.stringify(decision.metrics.reviewerSectionChecks)}`,
1634
+ `- producerChecks: ${decision.metrics.producerChecks ?? 'n/a'}`,
1635
+ `- reviewerAChecks: ${decision.metrics.reviewerAChecks ?? 'n/a'}`,
1636
+ `- reviewerBChecks: ${decision.metrics.reviewerBChecks ?? 'n/a'}`,
1637
+ ...(decision.metrics.globalReviewerVerdict
1638
+ ? [
1639
+ `- globalReviewerVerdict: ${decision.metrics.globalReviewerVerdict}`,
1640
+ `- globalReviewerRequired: true`,
1641
+ `- globalReviewerChecks: ${decision.metrics.globalReviewerChecks ?? 'n/a'}`,
1642
+ `- macroAnswersSatisfied: ${decision.metrics.macroAnswersSatisfied ?? false}`,
1643
+ ]
1644
+ : []),
1645
+ ...(decision.metrics.scoringDimensions?.length
1646
+ ? [
1647
+ `- scoringDimensions: ${decision.metrics.scoringDimensions.join(', ')}`,
1648
+ `- reviewerADimensions: ${JSON.stringify(decision.metrics.reviewerADimensions)}`,
1649
+ `- reviewerBDimensions: ${JSON.stringify(decision.metrics.reviewerBDimensions)}`,
1650
+ `- dimensionFailures: ${decision.metrics.dimensionFailures.length}`,
1651
+ ]
1652
+ : []),
1653
+ ...(decision.metrics.contractItems?.length
1654
+ ? [
1655
+ `- contractDoneItems: ${decision.metrics.contractCheck.doneItems}/${decision.metrics.contractCheck.totalItems}`,
1656
+ ]
1657
+ : []),
1658
+ '',
1659
+ `## Files`,
1660
+ `- Producer: ${producerPath}`,
1661
+ `- Reviewer A: ${reviewerAPath}`,
1662
+ `- Reviewer B: ${reviewerBPath}`,
1663
+ ...(globalReviewerRequired ? [`- Global Reviewer: ${globalReviewerPath}`] : []),
1664
+ ].join('\n');
1665
+
1666
+ writeText(decisionPath, `${content}\n`);
1667
+
1668
+ const failure = decision.outcome === 'error'
1669
+ ? inferFailureClassification({
1670
+ summary: decision.summary,
1671
+ blockers: decision.blockers,
1672
+ reviewerTimeouts,
1673
+ reviewerViolations,
1674
+ })
1675
+ : null;
1676
+
1677
+ const scorecard = {
1678
+ stage: stageName,
1679
+ round: state.currentRound,
1680
+ workUnitId: workUnit?.workUnitId ?? null,
1681
+ workUnitGoal: workUnit?.workUnitGoal ?? null,
1682
+ outcome: decision.outcome,
1683
+ outputQuality: decision.outputQuality,
1684
+ qualityReasons: decision.qualityReasons ?? [],
1685
+ nextRunRecommendation: decision.nextRunRecommendation ?? null,
1686
+ validation: {
1687
+ valid: decision.validation?.valid ?? true,
1688
+ errorSummary: decision.validation?.errorSummary ?? null,
1689
+ producer: decision.validation?.producer ?? null,
1690
+ reviewerA: decision.validation?.reviewerA ?? null,
1691
+ reviewerB: decision.validation?.reviewerB ?? null,
1692
+ globalReviewer: decision.validation?.globalReviewer ?? null,
1693
+ },
1694
+ summary: decision.summary,
1695
+ failureClassification: failure?.failureClassification ?? null,
1696
+ failureSource: failure?.failureSource ?? null,
1697
+ recommendedNextAction: failure?.recommendedNextAction ?? null,
1698
+ approvalCount: decision.metrics.approvalCount,
1699
+ blockerCount: decision.metrics.blockerCount,
1700
+ reviewerAVerdict: decision.metrics.reviewerAVerdict,
1701
+ reviewerBVerdict: decision.metrics.reviewerBVerdict,
1702
+ producerSectionChecks: decision.metrics.producerSectionChecks,
1703
+ reviewerSectionChecks: decision.metrics.reviewerSectionChecks,
1704
+ producerChecks: decision.metrics.producerChecks,
1705
+ reviewerAChecks: decision.metrics.reviewerAChecks,
1706
+ reviewerBChecks: decision.metrics.reviewerBChecks,
1707
+ ...(decision.metrics.globalReviewerVerdict
1708
+ ? {
1709
+ globalReviewerVerdict: decision.metrics.globalReviewerVerdict,
1710
+ globalReviewerRequired: true,
1711
+ globalReviewerChecks: decision.metrics.globalReviewerChecks,
1712
+ macroAnswersSatisfied: decision.metrics.macroAnswersSatisfied,
1713
+ macroAnswersRequired: decision.metrics.macroAnswersRequired ?? [],
1714
+ macroAnswersFound: decision.metrics.macroAnswersFound ?? [],
1715
+ }
1716
+ : {}),
1717
+ blockers: decision.blockers,
1718
+ ...(decision.metrics.scoringDimensions?.length
1719
+ ? {
1720
+ scoringDimensions: decision.metrics.scoringDimensions,
1721
+ reviewerADimensions: decision.metrics.reviewerADimensions,
1722
+ reviewerBDimensions: decision.metrics.reviewerBDimensions,
1723
+ dimensionFailures: decision.metrics.dimensionFailures,
1724
+ }
1725
+ : {}),
1726
+ ...(decision.metrics.contractItems?.length
1727
+ ? {
1728
+ contractItems: decision.metrics.contractItems,
1729
+ contractCheck: decision.metrics.contractCheck,
1730
+ }
1731
+ : {}),
1732
+ ...(reviewerTimeouts
1733
+ ? { reviewerTimeouts }
1734
+ : {}),
1735
+ ...(reviewerFailures
1736
+ ? { reviewerFailures }
1737
+ : {}),
1738
+ updatedAt: nowIso(),
1739
+ };
1740
+ writeJson(scorecardPath, scorecard);
1741
+
1742
+ // Build and write handoff for next round
1743
+ if (decision.outcome === 'revise') {
1744
+ const handoff = buildHandoff({
1745
+ reviewerA,
1746
+ reviewerB,
1747
+ globalReviewer,
1748
+ producer,
1749
+ metrics: decision.metrics,
1750
+ stageName,
1751
+ round: state.currentRound,
1752
+ workUnit,
1753
+ });
1754
+ const handoffPath = path.join(stageDir, 'handoff.json');
1755
+ writeJson(handoffPath, handoff);
1756
+ writeCheckpointSummary(stageDir, {
1757
+ decision,
1758
+ handoff,
1759
+ producer,
1760
+ reviewerA,
1761
+ reviewerB,
1762
+ globalReviewer: globalReviewer ?? '',
1763
+ });
1764
+ } else {
1765
+ writeCheckpointSummary(stageDir, {
1766
+ decision,
1767
+ handoff: null,
1768
+ producer,
1769
+ reviewerA,
1770
+ reviewerB,
1771
+ globalReviewer: globalReviewer ?? '',
1772
+ });
1773
+ }
1774
+ appendTimeline(runDir, `Stage ${stageName} round ${state.currentRound} decision: ${decision.outcome}`);
1775
+ updateSummaryWithClassification(runDir, [
1776
+ `Status: ${state.status}`,
1777
+ `Stage: ${stageName}`,
1778
+ `Round: ${state.currentRound}`,
1779
+ `Outcome: ${decision.outcome}`,
1780
+ `Approval count: ${decision.metrics.approvalCount}/2${decision.metrics.globalReviewerVerdict ? ` + global_reviewer: ${decision.metrics.globalReviewerVerdict}` : ''}`,
1781
+ `Blocker count: ${decision.metrics.blockerCount}`,
1782
+ ...(decision.blockers.length ? [`Top blocker: ${decision.blockers[0]}`] : ['Top blocker: none']),
1783
+ ], failure);
1784
+ return decision;
1785
+ }
1786
+
1787
+ /**
1788
+ * Run merge gate: fetch origin and compare local vs remote PR/target branch HEAD SHA.
1789
+ *
1790
+ * IMPORTANT — targetBranch vs worktree.branchName:
1791
+ * - targetBranch = spec.branch ?? 'main' ← the REAL PR branch on remote (e.g. 'feat/my-feature')
1792
+ * - worktree.branchName = sprint/<runId>/<stage> ← internal temp branch, NEVER on remote
1793
+ *
1794
+ * Merge gate must NEVER use worktree.branchName — it does not exist on the remote.
1795
+ * If spec.branch is not set, merge gate compares against 'main' (safe default).
1796
+ *
1797
+ * Returns { localHeadSha, remoteHeadSha, shaMatch, targetBranch }.
1798
+ * Writes result to stages/<stage>/merge-gate.json.
1799
+ */
1800
+ function runMergeGateCheck({ runDir, state, spec, mergeGatePath }) {
1801
+ const workspace = state.worktree?.worktreePath ?? spec.workspace;
1802
+ const remote = 'origin';
1803
+
1804
+ // targetBranch is the REAL PR branch on remote — NEVER the internal sprint worktree branch
1805
+ const targetBranch = spec.branch ?? 'main';
1806
+ const remoteRef = `refs/remotes/${remote}/${targetBranch}`;
1807
+
1808
+ try {
1809
+ // Fetch the specific remote branch via targeted refspec
1810
+ const fetchResult = spawnSync('git', ['fetch', remote, `refs/heads/${targetBranch}:refs/remotes/${remote}/${targetBranch}`], {
1811
+ cwd: workspace,
1812
+ encoding: 'utf8',
1813
+ timeout: 60_000,
1814
+ });
1815
+
1816
+ // If fetch fails (branch doesn't exist on remote), record clearly
1817
+ if (fetchResult.status !== 0) {
1818
+ const fetchErr = fetchResult.stderr?.trim() || fetchResult.stdout?.trim() || 'unknown';
1819
+ appendTimeline(runDir, `Merge gate: target branch '${targetBranch}' does not exist on remote: ${fetchErr}`);
1820
+ const result = { localHeadSha: null, remoteHeadSha: null, shaMatch: false, targetBranch, error: `Branch '${targetBranch}' not found on remote. Has it been pushed?`, fetchFailed: true };
1821
+ writeJson(mergeGatePath, result);
1822
+ return result;
1823
+ }
1824
+
1825
+ const localShaResult = spawnSync('git', ['rev-parse', 'HEAD'], {
1826
+ cwd: workspace, encoding: 'utf8', timeout: 10_000,
1827
+ });
1828
+ if (localShaResult.status !== 0) {
1829
+ const errMsg = localShaResult.stderr?.trim() || localShaResult.stdout?.trim() || 'unknown';
1830
+ appendTimeline(runDir, `Merge gate: git rev-parse HEAD failed with status ${localShaResult.status}: ${errMsg}`);
1831
+ return { localHeadSha: null, remoteHeadSha: null, shaMatch: false, targetBranch, error: `git rev-parse HEAD failed: ${errMsg}` };
1832
+ }
1833
+ const localSha = (localShaResult.stdout ?? '').trim();
1834
+
1835
+ const remoteShaResult = spawnSync('git', ['rev-parse', remoteRef], {
1836
+ cwd: workspace, encoding: 'utf8', timeout: 10_000,
1837
+ });
1838
+ if (remoteShaResult.status !== 0) {
1839
+ appendTimeline(runDir, `Merge gate: remote ref ${remoteRef} not found after fetch (git status ${remoteShaResult.status})`);
1840
+ return { localHeadSha: localSha, remoteHeadSha: null, shaMatch: false, targetBranch, error: `Remote ref '${remoteRef}' not found after fetch` };
1841
+ }
1842
+ const remoteSha = (remoteShaResult.stdout ?? '').trim();
1843
+
1844
+ const shaMatch = localSha === remoteSha;
1845
+
1846
+ const result = { localHeadSha: localSha, remoteHeadSha: remoteSha, shaMatch, targetBranch };
1847
+
1848
+ writeJson(mergeGatePath, result);
1849
+ appendTimeline(runDir, `Merge gate: targetBranch=${targetBranch} local=${localSha.slice(0, 7)} remote=${remoteSha.slice(0, 7)} match=${shaMatch}`);
1850
+
1851
+ return result;
1852
+ } catch (gateErr) {
1853
+ appendTimeline(runDir, `Merge gate check failed: ${gateErr.message}`);
1854
+ return { localHeadSha: null, remoteHeadSha: null, shaMatch: false, targetBranch, error: gateErr.message };
1855
+ }
1856
+ }
1857
+
1858
+ function advanceState(state, spec, decision, { runDir } = {}) {
1859
+ if (decision.outcome === 'advance') {
1860
+ if (state.currentStageIndex >= spec.stages.length - 1) {
1861
+ // Merge gate check before completing final stage
1862
+ if (runDir) {
1863
+ const finalStageDir = `${String(state.currentStageIndex + 1).padStart(2, '0')}-${state.currentStage}`;
1864
+ const mergeGatePath = path.join(runDir, 'stages', finalStageDir, 'merge-gate.json');
1865
+ const mergeGate = runMergeGateCheck({ runDir, state, spec, mergeGatePath });
1866
+ if (!mergeGate.shaMatch) {
1867
+ const fetchFailed = Boolean(mergeGate.fetchFailed);
1868
+ if (fetchFailed) {
1869
+ state.status = 'completed';
1870
+ state.mergePending = {
1871
+ targetBranch: mergeGate.targetBranch,
1872
+ reason: mergeGate.error,
1873
+ mergeGate,
1874
+ updatedAt: nowIso(),
1875
+ };
1876
+ state.haltReason = null;
1877
+ return;
1878
+ }
1879
+ state.status = 'halted';
1880
+ state.haltReason = {
1881
+ type: 'merge_gate_sha_mismatch',
1882
+ stage: state.currentStage,
1883
+ round: state.currentRound,
1884
+ targetBranch: mergeGate.targetBranch,
1885
+ details: `Merge gate failed: local SHA ${mergeGate.localHeadSha?.slice(0, 7) ?? '?'} != remote/${mergeGate.targetBranch} SHA ${mergeGate.remoteHeadSha?.slice(0, 7) ?? '?'}. Push or rebase before completing.`,
1886
+ blockers: ['Local SHA does not match remote target branch head. Push or rebase before completing.'],
1887
+ mergeGate,
1888
+ };
1889
+ return;
1890
+ }
1891
+ }
1892
+ state.mergePending = null;
1893
+ state.status = 'completed';
1894
+ return;
1895
+ }
1896
+ state.currentStageIndex += 1;
1897
+ state.currentStage = spec.stages[state.currentStageIndex];
1898
+ state.currentWorkUnitIndex = 0;
1899
+ state.currentRound = 1;
1900
+ return;
1901
+ }
1902
+
1903
+ if (decision.outcome === 'revise') {
1904
+ // Route implement-pass-1 revise → implement-pass-2 automatically
1905
+ if (state.currentStage === 'implement-pass-1') {
1906
+ state.currentStage = 'implement-pass-2';
1907
+ state.currentStageIndex = spec.stages.indexOf('implement-pass-2');
1908
+ state.currentWorkUnitIndex = 0;
1909
+ state.currentRound = 1;
1910
+ return;
1911
+ }
1912
+ state.currentRound += 1;
1913
+ return;
1914
+ }
1915
+
1916
+ // Both 'halt' and 'error' outcomes halt the sprint
1917
+ // WF-003 fix: schema violations have already been retried in execStage via
1918
+ // the 'revise' outcome path. If we reach here with 'error', it means retries
1919
+ // were exhausted or the error is from the standard decision engine.
1920
+ // Schema violation errors are now returned with a special flag to trigger retry
1921
+ // instead of halt. See the schema validation block in decideAndPersist().
1922
+ state.status = 'halted';
1923
+ state.haltReason = {
1924
+ type: decision.outcome === 'error' ? 'stage_error' : 'max_rounds_exceeded',
1925
+ stage: state.currentStage,
1926
+ round: state.currentRound,
1927
+ details: decision.summary,
1928
+ blockers: decision.blockers,
1929
+ };
1930
+ }
1931
+
1932
+ function maybeAbort(runDir, state) {
1933
+ const fresh = readJson(path.join(runDir, 'sprint.json'));
1934
+ if (fresh.status === 'aborted' || fresh.status === 'paused') {
1935
+ Object.assign(state, fresh);
1936
+ throw new Error(`Sprint ${fresh.status} by operator.`);
1937
+ }
1938
+ Object.assign(state, fresh);
1939
+ }
1940
+
1941
+ function pidExists(pid) {
1942
+ if (!pid) return false;
1943
+ try {
1944
+ process.kill(pid, 0);
1945
+ return true;
1946
+ } catch {
1947
+ return false;
1948
+ }
1949
+ }
1950
+
1951
+ function reconcileRunState(runDir, state) {
1952
+ if (state.status !== 'running') return state;
1953
+ if (pidExists(state.orchestratorPid)) return state;
1954
+
1955
+ // Clean up any orphaned acpx queue-owner daemons and agent processes
1956
+ // from the previous orchestrator run. acpx queue-owners are detached
1957
+ // processes that may outlive their parent acpx invocation.
1958
+ cleanupAcpxOrphans(runDir, state);
1959
+
1960
+ // Detect which artifacts already exist on disk for the current stage/round
1961
+ const stageDirName = `${String(state.currentStageIndex + 1).padStart(2, '0')}-${state.currentStage}`;
1962
+ const stageDir = path.join(runDir, 'stages', stageDirName);
1963
+ const artifacts = {
1964
+ producer: reportExistsAndNonEmpty(path.join(stageDir, 'producer.md')),
1965
+ reviewerA: reportExistsAndNonEmpty(path.join(stageDir, 'reviewer-a.md')),
1966
+ reviewerB: reportExistsAndNonEmpty(path.join(stageDir, 'reviewer-b.md')),
1967
+ globalReviewer: reportExistsAndNonEmpty(path.join(stageDir, 'global-reviewer.md')),
1968
+ decision: reportExistsAndNonEmpty(path.join(stageDir, 'decision.md')),
1969
+ };
1970
+
1971
+ const roleLabel = state.currentRole ?? 'unknown';
1972
+ const diskSummary = Object.entries(artifacts)
1973
+ .filter(([, v]) => v)
1974
+ .map(([k]) => k)
1975
+ .join(', ') || 'none';
1976
+
1977
+ state.status = 'halted';
1978
+ state.haltReason = {
1979
+ type: 'stale_orchestrator',
1980
+ stage: state.currentStage,
1981
+ round: state.currentRound,
1982
+ details: `Orchestrator pid ${state.orchestratorPid ?? 'unknown'} is no longer alive (was: ${roleLabel}). Artifacts on disk: ${diskSummary}.`,
1983
+ blockers: ['The sprint process ended before stage completion. Resume to continue.'],
1984
+ artifacts,
1985
+ };
1986
+ saveState(runDir, state);
1987
+ updateSummary(runDir, [
1988
+ `Status: ${state.status}`,
1989
+ `Stage: ${state.currentStage}`,
1990
+ `Round: ${state.currentRound}`,
1991
+ `Halt reason: ${state.haltReason.details}`,
1992
+ `Disk artifacts: ${diskSummary}`,
1993
+ ]);
1994
+ appendTimeline(runDir, `Sprint reconciled to halted: ${state.haltReason.details}`);
1995
+ const paths = ensureStagePaths(runDir, state.currentStageIndex, state.currentStage);
1996
+ cleanupRecordedRoleProcesses(paths, runDir);
1997
+ cleanupWorktree({ state, runDir });
1998
+ return state;
1999
+ }
2000
+
2001
+ /**
2002
+ * Clean up orphaned acpx queue-owner daemons and agent processes.
2003
+ * acpx spawns detached queue-owner processes that may outlive the acpx CLI
2004
+ * that created them. On stale-run reconciliation, these should be cleaned up.
2005
+ */
2006
+ function cleanupAcpxOrphans(runDir, state) {
2007
+ // Derive workspace from known-good sources only:
2008
+ // 1. worktree (most reliable — we created it)
2009
+ // 2. spec.workspace (loaded fresh, has clear semantic meaning)
2010
+ const spec = state.taskId ? (() => {
2011
+ try { return getTaskSpec(state.taskId, state.specPath); } catch { return null; }
2012
+ })() : null;
2013
+
2014
+ const workspace = state.worktree?.worktreePath
2015
+ ?? (spec?.workspace && spec.workspace !== state.specPath ? spec.workspace : null);
2016
+
2017
+ if (!workspace) {
2018
+ appendTimeline(runDir, 'acpx session cleanup skipped: no trusted workspace available');
2019
+ return;
2020
+ }
2021
+
2022
+ // Try to close any active acpx sessions for the workspace+agent combo
2023
+ const agent = spec?.producer?.agent ?? null;
2024
+
2025
+ if (agent) {
2026
+ try {
2027
+ // acpx <agent> sessions close — this removes the queue-owner lease
2028
+ // Use nodeBin + [acpxBin, ...] for consistency with runAgent spawn pattern
2029
+ const closeResult = spawnSync(nodeBin, [acpxBin, agent, 'sessions', 'close'],
2030
+ { cwd: workspace, encoding: 'utf8', timeout: 10_000, env: acpxEnv, shell: false });
2031
+ if (closeResult.status === 0) {
2032
+ appendTimeline(runDir, `Cleaned up acpx ${agent} sessions for stale run recovery`);
2033
+ }
2034
+ } catch (closeErr) {
2035
+ // Non-fatal — queue-owner may already be gone
2036
+ appendTimeline(runDir, `acpx session cleanup skipped: ${closeErr.message}`);
2037
+ }
2038
+ }
2039
+ }
2040
+
2041
+ /**
2042
+ * Execute a single reviewer role asynchronously with independent timeout tracking.
2043
+ * Returns a result object: { role, output, timedOut, reportExisted, violatedFile }
2044
+ */
2045
+ async function runReviewerRole({ runDir, state, spec, paths, role, worktreeInfo }) {
2046
+ const stageName = state.currentStage;
2047
+ const workUnit = getActiveWorkUnit(spec, stageName, {
2048
+ workUnitIndex: state.currentWorkUnitIndex ?? 0,
2049
+ });
2050
+ const config = roleConfig(spec, role);
2051
+ const { reportPath, stdoutPath } = roleArtifactPaths(paths, role);
2052
+ const failLogBase = path.join(paths.stageDir, `${role.replace('_', '-')}-failure`);
2053
+
2054
+ // Skip if report already exists
2055
+ if (fileExists(reportPath) && fs.readFileSync(reportPath, 'utf8').trim()) {
2056
+ updateRoleState(paths, role, {
2057
+ stage: stageName,
2058
+ round: state.currentRound,
2059
+ status: 'completed',
2060
+ lastPid: null,
2061
+ finishedAt: nowIso(),
2062
+ timeoutSeconds: stageRoleTimeout(spec, stageName, role),
2063
+ lastError: null,
2064
+ });
2065
+ return { role, output: fs.readFileSync(reportPath, 'utf8').trim(), timedOut: false, reportExisted: true, violatedFile: null };
2066
+ }
2067
+
2068
+ const protectedFiles = protectedArtifacts(runDir, paths);
2069
+ const protectedSnapshot = snapshotProtectedFiles(protectedFiles);
2070
+ const prompt = buildRolePrompt({
2071
+ spec,
2072
+ stage: stageName,
2073
+ round: state.currentRound,
2074
+ role,
2075
+ runDir,
2076
+ stageDir: paths.stageDir,
2077
+ briefPath: paths.briefPath,
2078
+ producerPath: paths.producerPath,
2079
+ reviewerAPath: paths.reviewerAPath,
2080
+ reviewerBPath: paths.reviewerBPath,
2081
+ globalReviewerPath: paths.globalReviewerPath,
2082
+ workUnit,
2083
+ });
2084
+ const timeoutSeconds = stageRoleTimeout(spec, stageName, role);
2085
+ // Reviewers must verify code in the same workspace where producer made changes
2086
+ // branchWorkspace may not exist — fall back to spec.workspace (always valid)
2087
+ const cwd = worktreeInfo?.worktreePath ?? (fileExists(spec.branchWorkspace) ? spec.branchWorkspace : spec.workspace);
2088
+
2089
+ let output = null;
2090
+ let timedOut = false;
2091
+ let violatedFile = null;
2092
+ let reportExisted = false;
2093
+ const attemptErrors = [];
2094
+ const attempts = roleAttemptConfigs(config);
2095
+
2096
+ for (let index = 0; index < attempts.length; index += 1) {
2097
+ const attemptConfig = attempts[index];
2098
+ const attemptLabel = attemptConfig.attemptLabel ?? `attempt_${index + 1}`;
2099
+ const attemptTimeoutSeconds = stageRoleTimeout(
2100
+ {
2101
+ ...spec,
2102
+ reviewerA: role === 'reviewer_a' ? attemptConfig : spec.reviewerA,
2103
+ reviewerB: role === 'reviewer_b' ? attemptConfig : spec.reviewerB,
2104
+ },
2105
+ stageName,
2106
+ role
2107
+ );
2108
+ const failLog = `${failLogBase}-${attemptLabel}.log`;
2109
+
2110
+ updateRoleState(paths, role, {
2111
+ stage: stageName,
2112
+ round: state.currentRound,
2113
+ status: index === 0 ? 'running' : 'retrying',
2114
+ startedAt: nowIso(),
2115
+ finishedAt: null,
2116
+ terminatedAt: null,
2117
+ lastPid: null,
2118
+ timeoutSeconds: attemptTimeoutSeconds,
2119
+ lastError: null,
2120
+ });
2121
+
2122
+ if (index > 0) {
2123
+ appendTimeline(
2124
+ runDir,
2125
+ `${role} retrying with ${attemptConfig.agent}/${attemptConfig.model} (${attemptLabel})`
2126
+ );
2127
+ }
2128
+
2129
+ try {
2130
+ const result = await runAgentAsync({
2131
+ cwd,
2132
+ agent: attemptConfig.agent,
2133
+ model: attemptConfig.model,
2134
+ prompt,
2135
+ timeoutSeconds: attemptTimeoutSeconds,
2136
+ promptDir: runDir,
2137
+ runDir,
2138
+ roleLabel: role,
2139
+ onSpawn: (pid) => updateRoleState(paths, role, { lastPid: pid }),
2140
+ });
2141
+ const stdout = result.stdout ?? '';
2142
+ const stderr = result.stderr ?? '';
2143
+
2144
+ if (result.status !== 0) {
2145
+ ensureDir(path.dirname(failLog));
2146
+ writeText(
2147
+ failLog,
2148
+ `# Agent Failure Log\n\n- attempt: ${attemptLabel}\n- agent: ${attemptConfig.agent}\n- model: ${attemptConfig.model}\n- exitStatus: ${result.status}\n\n## stdout\n\n${stdout.slice(0, 2000)}\n\n## stderr\n\n${stderr.slice(0, 2000)}\n`
2149
+ );
2150
+ throw new Error(`Agent ${attemptConfig.agent} failed with status ${result.status}`);
2151
+ }
2152
+
2153
+ output = stdout.trim();
2154
+ if (output) writeText(stdoutPath, `${output}\n`);
2155
+ updateRoleState(paths, role, {
2156
+ status: 'completed',
2157
+ lastPid: null,
2158
+ finishedAt: nowIso(),
2159
+ lastError: null,
2160
+ });
2161
+ break;
2162
+ } catch (err) {
2163
+ const errorMessage = err.message ?? String(err);
2164
+ const hasReport = fileExists(reportPath) && fs.readFileSync(reportPath, 'utf8').trim();
2165
+
2166
+ if (hasReport) {
2167
+ output = null;
2168
+ timedOut = errorMessage.includes('timed out');
2169
+ reportExisted = true;
2170
+ updateRoleState(paths, role, {
2171
+ status: 'completed_after_timeout',
2172
+ lastPid: null,
2173
+ finishedAt: nowIso(),
2174
+ lastError: errorMessage,
2175
+ });
2176
+ break;
2177
+ }
2178
+
2179
+ const isTimedOut = errorMessage.includes('timed out');
2180
+ attemptErrors.push({
2181
+ label: attemptLabel,
2182
+ agent: attemptConfig.agent,
2183
+ model: attemptConfig.model,
2184
+ error: errorMessage,
2185
+ timedOut: isTimedOut,
2186
+ });
2187
+
2188
+ ensureDir(path.dirname(failLog));
2189
+ if (isTimedOut) {
2190
+ timedOut = true;
2191
+ writeText(
2192
+ failLog,
2193
+ `# Agent Timeout Log\n\n- attempt: ${attemptLabel}\n- agent: ${attemptConfig.agent}\n- model: ${attemptConfig.model}\n- timeout: ${attemptTimeoutSeconds}s\n- error: ${errorMessage}\n`
2194
+ );
2195
+ updateRoleState(paths, role, {
2196
+ status: 'timed_out',
2197
+ lastPid: null,
2198
+ finishedAt: nowIso(),
2199
+ terminatedAt: nowIso(),
2200
+ lastError: errorMessage,
2201
+ });
2202
+ } else {
2203
+ writeText(
2204
+ failLog,
2205
+ `# Agent Failure Log\n\n- attempt: ${attemptLabel}\n- agent: ${attemptConfig.agent}\n- model: ${attemptConfig.model}\n- error: ${errorMessage}\n`
2206
+ );
2207
+ updateRoleState(paths, role, {
2208
+ status: 'error',
2209
+ lastPid: null,
2210
+ finishedAt: nowIso(),
2211
+ lastError: errorMessage,
2212
+ });
2213
+ }
2214
+
2215
+ if (index < attempts.length - 1) {
2216
+ continue;
2217
+ }
2218
+
2219
+ if (!isTimedOut) {
2220
+ throw new Error(
2221
+ `${role} failed after ${attemptErrors.length} attempt(s): ${attemptErrors.map((item) => `${item.label}=${item.agent}/${item.model}: ${item.error}`).join(' | ')}`
2222
+ );
2223
+ }
2224
+ }
2225
+ }
2226
+
2227
+ // Collect isolation artifacts using runId for unique isolation directory
2228
+ const reportFilename = path.basename(reportPath);
2229
+ const { collected, isolationReportPath } = collectIsolationArtifacts({
2230
+ runId: state.runId,
2231
+ stageName,
2232
+ role,
2233
+ stageDir: paths.stageDir,
2234
+ reportFilename,
2235
+ runDir,
2236
+ });
2237
+
2238
+ const text = readRoleOutput({ reportPath, stdout: output ?? '', isolationReportPath });
2239
+ if (!text && output) {
2240
+ writeText(reportPath, `${output}\n`);
2241
+ } else if (text) {
2242
+ if (!fileExists(reportPath)) writeText(reportPath, `${text}\n`);
2243
+ }
2244
+
2245
+ const violated = detectProtectedWriteViolation(protectedFiles, protectedSnapshot, runDir);
2246
+ if (violated) {
2247
+ violatedFile = violated.file;
2248
+ }
2249
+
2250
+ return { role, output: text || output || null, timedOut, reportExisted, violatedFile, attemptErrors };
2251
+ }
2252
+
2253
+ const MUTATING_STAGES = ['implement-pass-1', 'implement-pass-2'];
2254
+
2255
+ /**
2256
+ * Ensure a git worktree exists for the given mutating stage.
2257
+ * Reuses existing worktree if already present. Returns null if not applicable.
2258
+ *
2259
+ * Uses a legal git worktree add: git worktree add -b <newBranch> <worktreePath> <baseRef>
2260
+ * where baseRef is a real git ref (branch/tag/SHA), not a filesystem path.
2261
+ */
2262
+ function ensureWorktree({ spec, runDir, state, stageName }) {
2263
+ if (!MUTATING_STAGES.includes(stageName)) return null;
2264
+
2265
+ const baseWorkspace = spec.workspace;
2266
+ const baseBranch = spec.branch ?? 'main';
2267
+ const branchName = `sprint/${state.runId.slice(0, 25)}/${stageName}`;
2268
+ const worktreePath = path.join(runDir, 'worktrees', stageName);
2269
+
2270
+ // Resolve baseRef with robust fallback chain:
2271
+ // 1. spec.branch (local branch)
2272
+ // 2. origin/{spec.branch} (remote branch)
2273
+ // 3. HEAD (current checkout)
2274
+ // 4. main (default branch)
2275
+ const resolveBaseRef = () => {
2276
+ const candidates = [
2277
+ { ref: baseBranch, label: `spec.branch '${baseBranch}'` },
2278
+ { ref: `origin/${baseBranch}`, label: `remote branch 'origin/${baseBranch}'` },
2279
+ { ref: 'HEAD', label: 'current HEAD' },
2280
+ { ref: 'main', label: 'default branch main' },
2281
+ ];
2282
+
2283
+ for (const candidate of candidates) {
2284
+ const result = spawnSync('git', ['rev-parse', '--verify', candidate.ref], {
2285
+ cwd: baseWorkspace,
2286
+ encoding: 'utf8',
2287
+ timeout: 10_000,
2288
+ });
2289
+ if (result.status === 0 && result.stdout?.trim()) {
2290
+ if (candidate.ref !== baseBranch) {
2291
+ appendTimeline(runDir, `Base ref resolved to ${candidate.label}`);
2292
+ }
2293
+ return candidate.ref;
2294
+ }
2295
+ }
2296
+ // Last resort: return 'main' even if verification failed
2297
+ appendTimeline(runDir, `Warning: Could not verify any base ref, using 'main'`);
2298
+ return 'main';
2299
+ };
2300
+
2301
+ const baseRef = resolveBaseRef();
2302
+
2303
+ // Reuse existing worktree if already created for this stage
2304
+ if (state.worktree?.worktreePath && fileExists(state.worktree.worktreePath)) {
2305
+ appendTimeline(runDir, `Reusing existing worktree at ${state.worktree.worktreePath}`);
2306
+ return state.worktree;
2307
+ }
2308
+
2309
+ // Also check if worktree directory exists on disk (state may have been lost)
2310
+ // This handles resume scenarios where sprint.json lost worktree state
2311
+ if (fileExists(worktreePath)) {
2312
+ // Verify it's a valid worktree by checking for .git file
2313
+ const gitFile = path.join(worktreePath, '.git');
2314
+ if (fileExists(gitFile)) {
2315
+ const headSha = (spawnSync('git', ['rev-parse', 'HEAD'], {
2316
+ cwd: worktreePath,
2317
+ encoding: 'utf8',
2318
+ timeout: 10_000,
2319
+ }).stdout ?? '').trim();
2320
+
2321
+ if (headSha) {
2322
+ const recoveredWorktree = {
2323
+ worktreePath,
2324
+ branchName,
2325
+ headSha,
2326
+ baseBranch,
2327
+ baseWorkspace,
2328
+ dirtyFiles: [],
2329
+ };
2330
+ state.worktree = recoveredWorktree;
2331
+ appendTimeline(runDir, `Recovered existing worktree at ${worktreePath} (branch: ${branchName}, sha: ${headSha})`);
2332
+ return recoveredWorktree;
2333
+ }
2334
+ }
2335
+ // Invalid worktree directory - remove it
2336
+ appendTimeline(runDir, `Removing invalid worktree directory at ${worktreePath}`);
2337
+ try { fs.rmSync(worktreePath, { recursive: true, force: true }); } catch {}
2338
+ }
2339
+
2340
+ try {
2341
+ ensureDir(path.dirname(worktreePath));
2342
+
2343
+ // Create a new worktree with a new branch based on baseRef (a real git ref)
2344
+ // Syntax: git worktree add -b <newBranch> <path> <startPoint>
2345
+ // where <startPoint> is baseRef (e.g., 'main' or 'origin/main')
2346
+ const result = spawnSync('git', ['worktree', 'add', '-b', branchName, worktreePath, baseRef], {
2347
+ cwd: baseWorkspace,
2348
+ encoding: 'utf8',
2349
+ timeout: 30_000,
2350
+ });
2351
+
2352
+ if (result.status !== 0) {
2353
+ const errMsg = result.stderr?.trim() || result.stdout?.trim() || 'unknown error';
2354
+ appendTimeline(runDir, `Worktree creation failed: ${errMsg} — falling back to base workspace`);
2355
+ return null;
2356
+ }
2357
+
2358
+ const headSha = (spawnSync('git', ['rev-parse', 'HEAD'], {
2359
+ cwd: worktreePath,
2360
+ encoding: 'utf8',
2361
+ timeout: 10_000,
2362
+ }).stdout ?? '').trim();
2363
+
2364
+ const worktreeInfo = {
2365
+ worktreePath,
2366
+ branchName,
2367
+ headSha,
2368
+ baseBranch,
2369
+ baseWorkspace, // Store for cleanup cwd
2370
+ dirtyFiles: [],
2371
+ };
2372
+
2373
+ state.worktree = worktreeInfo;
2374
+ appendTimeline(runDir, `Created worktree at ${worktreePath} (branch: ${branchName}, baseRef: ${baseRef}, sha: ${headSha})`);
2375
+ return worktreeInfo;
2376
+ } catch (wtErr) {
2377
+ appendTimeline(runDir, `Worktree creation failed: ${wtErr.message} — falling back to base workspace`);
2378
+ return null;
2379
+ }
2380
+ }
2381
+
2382
+ /**
2383
+ * Capture git status for the worktree and write git-status.json to stageDir.
2384
+ */
2385
+ function captureGitStatus({ worktreePath, runDir, stageDir, state }) {
2386
+ if (!worktreePath || !fileExists(worktreePath)) return null;
2387
+ try {
2388
+ const statusResult = spawnSync('git', ['status', '--short'], {
2389
+ cwd: worktreePath,
2390
+ encoding: 'utf8',
2391
+ timeout: 10_000,
2392
+ });
2393
+ const branchResult = spawnSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
2394
+ cwd: worktreePath,
2395
+ encoding: 'utf8',
2396
+ timeout: 10_000,
2397
+ });
2398
+ const shaResult = spawnSync('git', ['rev-parse', 'HEAD'], {
2399
+ cwd: worktreePath,
2400
+ encoding: 'utf8',
2401
+ timeout: 10_000,
2402
+ });
2403
+ const dirtyFiles = ((statusResult.stdout ?? '').trim().split('\n').filter(Boolean) || []);
2404
+
2405
+ const gitStatus = {
2406
+ branch: (branchResult.stdout ?? '').trim(),
2407
+ headSha: (shaResult.stdout ?? '').trim(),
2408
+ worktreePath,
2409
+ dirtyFiles,
2410
+ baseBranch: state.worktree?.baseBranch ?? null,
2411
+ remoteBranch: state.worktree?.branchName ? `origin/${state.worktree.branchName}` : null,
2412
+ };
2413
+
2414
+ const gitStatusPath = path.join(stageDir, 'git-status.json');
2415
+ writeJson(gitStatusPath, gitStatus);
2416
+ appendTimeline(runDir, `Git status: ${dirtyFiles.length} dirty files, sha: ${gitStatus.headSha}`);
2417
+ return gitStatus;
2418
+ } catch (err) {
2419
+ appendTimeline(runDir, `Git status capture failed: ${err.message}`);
2420
+ return null;
2421
+ }
2422
+ }
2423
+
2424
+ /**
2425
+ * Remove the worktree from the filesystem (on halt).
2426
+ * Uses the stored baseWorkspace as cwd (the git repo root), not the worktree's parent dir.
2427
+ */
2428
+ function cleanupWorktree({ state, runDir }) {
2429
+ if (!state.worktree?.worktreePath) return;
2430
+ const { worktreePath, baseWorkspace } = state.worktree;
2431
+ // Use the git repo root (baseWorkspace) as cwd, not the worktree's parent dir
2432
+ const gitCwd = baseWorkspace ?? runDir;
2433
+ try {
2434
+ const result = spawnSync('git', ['worktree', 'remove', '--force', worktreePath], {
2435
+ cwd: gitCwd,
2436
+ encoding: 'utf8',
2437
+ timeout: 30_000,
2438
+ });
2439
+ if (result.status === 0) {
2440
+ appendTimeline(runDir, `Cleaned up worktree at ${worktreePath}`);
2441
+ state.worktree = null;
2442
+ return;
2443
+ }
2444
+ const errMsg = result.stderr?.trim() || result.stdout?.trim() || `git exited ${result.status}`;
2445
+ appendTimeline(runDir, `Worktree cleanup failed for ${worktreePath}: ${errMsg} — manual cleanup may be required`);
2446
+ } catch (cleanupErr) {
2447
+ appendTimeline(runDir, `Worktree cleanup failed for ${worktreePath}: ${cleanupErr.message} — manual cleanup may be required`);
2448
+ }
2449
+ }
2450
+
2451
+ async function executeStage(runDir, state, spec) {
2452
+ const stageName = state.currentStage;
2453
+ const workUnit = getActiveWorkUnit(spec, stageName, {
2454
+ workUnitIndex: state.currentWorkUnitIndex ?? 0,
2455
+ });
2456
+ const previousDecisionPath = path.join(
2457
+ runDir,
2458
+ 'stages',
2459
+ `${String(state.currentStageIndex + 1).padStart(2, '0')}-${stageName}`,
2460
+ 'decision.md',
2461
+ );
2462
+ const previousDecision = fileExists(previousDecisionPath)
2463
+ ? fs.readFileSync(previousDecisionPath, 'utf8')
2464
+ : '';
2465
+
2466
+ const paths = ensureStagePaths(
2467
+ runDir,
2468
+ state.currentStageIndex,
2469
+ stageName,
2470
+ );
2471
+ const { stageDir, briefPath, producerPath, reviewerAPath, reviewerBPath, globalReviewerPath, decisionPath, scorecardPath } = paths;
2472
+ const protectedFiles = protectedArtifacts(runDir, paths);
2473
+
2474
+ // Read handoff data for structured carry forward
2475
+ const handoffPath = path.join(stageDir, 'handoff.json');
2476
+ const handoff = fileExists(handoffPath) ? readJson(handoffPath) : null;
2477
+ const checkpointSummaryPath = path.join(stageDir, 'checkpoint-summary.md');
2478
+ const checkpointSummary = fileExists(checkpointSummaryPath)
2479
+ ? fs.readFileSync(checkpointSummaryPath, 'utf8')
2480
+ : null;
2481
+
2482
+ writeText(briefPath, `${buildStageBrief(spec, stageName, state.currentRound, previousDecision, handoff, checkpointSummary, {
2483
+ workUnitIndex: state.currentWorkUnitIndex ?? 0,
2484
+ runDir,
2485
+ stageDir,
2486
+ })}\n`);
2487
+
2488
+ // On round > 1, clear previous round's role reports so agents must regenerate them
2489
+ if (state.currentRound > 1) {
2490
+ const staleReports = [producerPath, reviewerAPath, reviewerBPath, globalReviewerPath];
2491
+ for (const fp of staleReports) {
2492
+ if (fileExists(fp)) {
2493
+ fs.unlinkSync(fp);
2494
+ }
2495
+ }
2496
+ appendTimeline(runDir, `Cleared previous round reports for stage ${stageName} round ${state.currentRound}`);
2497
+ }
2498
+
2499
+ const outputs = {};
2500
+ const stageStartedAt = Date.now();
2501
+ const stageTimeoutMs = (spec.stageTimeoutMinutes ?? 30) * 60_000;
2502
+
2503
+ // ── Phase 1: Producer (sequential) ────────────────────────────────────────
2504
+ appendTimeline(runDir, `Stage ${stageName} round ${state.currentRound} started (producer)`);
2505
+ updateSummary(runDir, [
2506
+ `Status: ${state.status}`,
2507
+ `Stage: ${stageName}`,
2508
+ `Round: ${state.currentRound}`,
2509
+ 'Producer is running.',
2510
+ ]);
2511
+
2512
+ maybeAbort(runDir, state);
2513
+ heartbeatState(runDir, state, { currentRole: 'producer' });
2514
+
2515
+ // Per-stage runtime check before producer
2516
+ const stageElapsedProducer = Date.now() - stageStartedAt;
2517
+ if (stageElapsedProducer > stageTimeoutMs) {
2518
+ state.status = 'halted';
2519
+ state.haltReason = {
2520
+ type: 'stage_timeout',
2521
+ stage: stageName,
2522
+ round: state.currentRound,
2523
+ details: `Stage ${stageName} exceeded ${(stageTimeoutMs / 60_000).toFixed(0)} minutes before producer`,
2524
+ blockers: [`Stage timeout before producer started after ${(stageElapsedProducer / 60_000).toFixed(1)} minutes`],
2525
+ };
2526
+ saveState(runDir, state);
2527
+ appendTimeline(runDir, `Sprint halted: ${state.haltReason.details}`);
2528
+ writeStageFailureArtifacts({
2529
+ runDir,
2530
+ state,
2531
+ stageName,
2532
+ stageDir,
2533
+ decisionPath,
2534
+ scorecardPath,
2535
+ summary: state.haltReason.details,
2536
+ blockers: state.haltReason.blockers,
2537
+ });
2538
+ cleanupRecordedRoleProcesses(paths, runDir);
2539
+ return { outcome: 'halt', summary: state.haltReason.details, blockers: state.haltReason.blockers, metrics: {} };
2540
+ }
2541
+
2542
+ const producerConfig = roleConfig(spec, 'producer');
2543
+ const producerTimeoutSeconds = stageRoleTimeout(spec, stageName, 'producer');
2544
+ const producerPrompt = buildRolePrompt({
2545
+ spec,
2546
+ stage: stageName,
2547
+ round: state.currentRound,
2548
+ role: 'producer',
2549
+ runDir,
2550
+ stageDir,
2551
+ briefPath,
2552
+ producerPath,
2553
+ reviewerAPath,
2554
+ reviewerBPath,
2555
+ workUnit,
2556
+ });
2557
+ const { reportPath: producerReportPath, stdoutPath: producerStdoutPath } = roleArtifactPaths(paths, 'producer');
2558
+
2559
+ // Worktree setup for mutating stages
2560
+ const worktreeInfo = ensureWorktree({ spec, runDir, state, stageName });
2561
+ // branchWorkspace may not exist — fall back to spec.workspace (always valid)
2562
+ const producerWorkspace = worktreeInfo?.worktreePath ?? (fileExists(spec.branchWorkspace) ? spec.branchWorkspace : spec.workspace);
2563
+
2564
+ if (fileExists(producerReportPath) && fs.readFileSync(producerReportPath, 'utf8').trim()) {
2565
+ outputs.producer = fs.readFileSync(producerReportPath, 'utf8').trim();
2566
+ updateRoleState(paths, 'producer', {
2567
+ stage: stageName,
2568
+ round: state.currentRound,
2569
+ status: 'completed',
2570
+ lastPid: null,
2571
+ finishedAt: nowIso(),
2572
+ timeoutSeconds: producerTimeoutSeconds,
2573
+ lastError: null,
2574
+ });
2575
+ appendTimeline(runDir, `producer skipped (report already exists) stage ${stageName} round ${state.currentRound}`);
2576
+ // Capture git status even for skipped producer (worktree may have changed)
2577
+ if (worktreeInfo?.worktreePath) {
2578
+ captureGitStatus({ worktreePath: worktreeInfo.worktreePath, runDir, stageDir, state });
2579
+ }
2580
+ } else {
2581
+ updateRoleState(paths, 'producer', {
2582
+ stage: stageName,
2583
+ round: state.currentRound,
2584
+ status: 'running',
2585
+ startedAt: nowIso(),
2586
+ finishedAt: null,
2587
+ terminatedAt: null,
2588
+ lastPid: null,
2589
+ timeoutSeconds: producerTimeoutSeconds,
2590
+ lastError: null,
2591
+ });
2592
+ const protectedSnapshot = snapshotProtectedFiles(protectedFiles);
2593
+ const producerFailLog = path.join(stageDir, 'producer-failure.log');
2594
+ const producerWorklogPath = paths.producerWorklogPath;
2595
+ let producerOutput;
2596
+ let producerTimedOut = false;
2597
+ let extensionsUsed = 0;
2598
+ try {
2599
+ const result = await runAgentWithProgressCheck({
2600
+ cwd: producerWorkspace,
2601
+ agent: producerConfig.agent,
2602
+ model: producerConfig.model,
2603
+ prompt: producerPrompt,
2604
+ timeoutSeconds: producerTimeoutSeconds,
2605
+ promptDir: runDir,
2606
+ runDir,
2607
+ roleLabel: 'producer',
2608
+ worktreePath: worktreeInfo?.worktreePath,
2609
+ worklogPath: producerWorklogPath,
2610
+ });
2611
+ producerOutput = result.stdout;
2612
+ extensionsUsed = result.extensionsUsed ?? 0;
2613
+ if (extensionsUsed > 0) {
2614
+ appendTimeline(runDir, `Producer used ${extensionsUsed} timeout extensions`);
2615
+ }
2616
+ } catch (agentErr) {
2617
+ // Track consecutive timeouts for fuse mechanism
2618
+ const isTimeout = agentErr.message?.includes('timed out') || agentErr.message?.includes('ETIMEDOUT');
2619
+ if (isTimeout) {
2620
+ // Initialize consecutiveTimeouts if not present (resume from old state)
2621
+ if (!state.consecutiveTimeouts) state.consecutiveTimeouts = {};
2622
+ state.consecutiveTimeouts[stageName] = (state.consecutiveTimeouts[stageName] || 0) + 1;
2623
+ appendTimeline(runDir, `Producer timeout #${state.consecutiveTimeouts[stageName]} for stage ${stageName}`);
2624
+
2625
+ // Fuse: halt after 2 consecutive timeouts on same stage
2626
+ if (state.consecutiveTimeouts[stageName] >= 2) {
2627
+ state.status = 'halted';
2628
+ state.haltReason = {
2629
+ type: 'repeated_timeout',
2630
+ stage: stageName,
2631
+ round: state.currentRound,
2632
+ details: `Producer timed out ${state.consecutiveTimeouts[stageName]} times on stage ${stageName}. Timeout may be too short or task too complex.`,
2633
+ blockers: [
2634
+ 'Repeated timeout suggests insufficient timeout or overly complex task.',
2635
+ 'Consider: 1) Increase timeout in stageRoleTimeouts, 2) Simplify task scope, 3) Check for infinite loops in agent.',
2636
+ ],
2637
+ };
2638
+ saveState(runDir, state);
2639
+ appendTimeline(runDir, `Sprint halted: repeated timeout on stage ${stageName}`);
2640
+ writeStageFailureArtifacts({
2641
+ runDir,
2642
+ state,
2643
+ stageName,
2644
+ stageDir,
2645
+ decisionPath,
2646
+ scorecardPath,
2647
+ summary: state.haltReason.details,
2648
+ blockers: state.haltReason.blockers,
2649
+ });
2650
+ cleanupRecordedRoleProcesses(paths, runDir);
2651
+ cleanupWorktree({ state, runDir });
2652
+ throw new Error(state.haltReason.details);
2653
+ }
2654
+ } else {
2655
+ // Reset counter on non-timeout error
2656
+ if (!state.consecutiveTimeouts) state.consecutiveTimeouts = {};
2657
+ state.consecutiveTimeouts[stageName] = 0;
2658
+ }
2659
+
2660
+ // Write failure log
2661
+ if (producerFailLog) {
2662
+ ensureDir(path.dirname(producerFailLog));
2663
+ writeText(producerFailLog, `# Agent Failure Log\n\n- agent: ${producerConfig.agent}\n- model: ${producerConfig.model}\n- error: ${agentErr.message ?? String(agentErr)}\n- extensionsUsed: ${extensionsUsed}\n\n`);
2664
+ }
2665
+
2666
+ if (fileExists(producerReportPath) && fs.readFileSync(producerReportPath, 'utf8').trim()) {
2667
+ appendTimeline(runDir, `producer spawnSync timed out but report exists — recovering stage ${stageName} round ${state.currentRound}`);
2668
+ producerOutput = null;
2669
+ producerTimedOut = true;
2670
+ updateRoleState(paths, 'producer', {
2671
+ status: 'completed_after_timeout',
2672
+ lastPid: null,
2673
+ finishedAt: nowIso(),
2674
+ terminatedAt: nowIso(),
2675
+ lastError: agentErr.message ?? String(agentErr),
2676
+ });
2677
+ } else {
2678
+ updateRoleState(paths, 'producer', {
2679
+ status: isTimeout ? 'timed_out' : 'error',
2680
+ lastPid: null,
2681
+ finishedAt: nowIso(),
2682
+ terminatedAt: isTimeout ? nowIso() : null,
2683
+ lastError: agentErr.message ?? String(agentErr),
2684
+ });
2685
+ throw agentErr;
2686
+ }
2687
+ }
2688
+ if (producerOutput) writeText(producerStdoutPath, `${producerOutput}\n`);
2689
+ outputs.producer = readRoleOutput({ reportPath: producerReportPath, stdout: producerOutput ?? '' });
2690
+ if (!fileExists(producerReportPath) || !fs.readFileSync(producerReportPath, 'utf8').trim()) {
2691
+ writeText(producerReportPath, `${outputs.producer}\n`);
2692
+ }
2693
+ updateRoleState(paths, 'producer', {
2694
+ status: producerTimedOut ? 'completed_after_timeout' : 'completed',
2695
+ lastPid: null,
2696
+ finishedAt: nowIso(),
2697
+ lastError: producerTimedOut ? 'Recovered after timeout because report existed on disk.' : null,
2698
+ });
2699
+ // Skip mtime-based violation check during timeout recovery — the producer may have
2700
+ // been mid-write when interrupted, causing spurious mtime changes on protected files.
2701
+ const producerViolated = producerTimedOut ? null : detectProtectedWriteViolation(protectedFiles, protectedSnapshot, runDir);
2702
+ if (producerViolated) {
2703
+ state.status = 'halted';
2704
+ state.haltReason = {
2705
+ type: 'protected_file_modified',
2706
+ stage: stageName,
2707
+ round: state.currentRound,
2708
+ details: `producer modified orchestrator-owned file ${producerViolated.file}`,
2709
+ blockers: [`Protected file modified: ${producerViolated.file}`],
2710
+ };
2711
+ saveState(runDir, state);
2712
+ appendTimeline(runDir, `Sprint halted: producer modified protected file ${producerViolated.file}`);
2713
+ writeStageFailureArtifacts({
2714
+ runDir,
2715
+ state,
2716
+ stageName,
2717
+ stageDir,
2718
+ decisionPath,
2719
+ scorecardPath,
2720
+ summary: state.haltReason.details,
2721
+ blockers: state.haltReason.blockers,
2722
+ });
2723
+ cleanupRecordedRoleProcesses(paths, runDir);
2724
+ cleanupWorktree({ state, runDir });
2725
+ throw new Error(state.haltReason.details);
2726
+ }
2727
+ appendTimeline(runDir, `producer completed stage ${stageName} round ${state.currentRound}`);
2728
+ // Reset consecutive timeout counter on successful completion
2729
+ if (!state.consecutiveTimeouts) state.consecutiveTimeouts = {};
2730
+ if (state.consecutiveTimeouts[stageName]) {
2731
+ state.consecutiveTimeouts[stageName] = 0;
2732
+ }
2733
+ // Capture git status after producer completes for reviewers to reference
2734
+ if (worktreeInfo?.worktreePath) {
2735
+ captureGitStatus({ worktreePath: worktreeInfo.worktreePath, runDir, stageDir, state });
2736
+ }
2737
+ }
2738
+ heartbeatState(runDir, state, { currentRole: null });
2739
+
2740
+ // ── Phase 2: Reviewers in parallel ────────────────────────────────────────
2741
+ maybeAbort(runDir, state);
2742
+ heartbeatState(runDir, state, { currentRole: 'reviewer_a || reviewer_b' });
2743
+
2744
+ // Per-stage runtime check before launching reviewers
2745
+ const stageElapsedReviewers = Date.now() - stageStartedAt;
2746
+ if (stageElapsedReviewers > stageTimeoutMs) {
2747
+ state.status = 'halted';
2748
+ state.haltReason = {
2749
+ type: 'stage_timeout',
2750
+ stage: stageName,
2751
+ round: state.currentRound,
2752
+ details: `Stage ${stageName} exceeded ${(stageTimeoutMs / 60_000).toFixed(0)} minutes before reviewers`,
2753
+ blockers: [`Stage timeout before reviewers after ${(stageElapsedReviewers / 60_000).toFixed(1)} minutes`],
2754
+ };
2755
+ saveState(runDir, state);
2756
+ appendTimeline(runDir, `Sprint halted: ${state.haltReason.details}`);
2757
+ writeStageFailureArtifacts({
2758
+ runDir,
2759
+ state,
2760
+ stageName,
2761
+ stageDir,
2762
+ decisionPath,
2763
+ scorecardPath,
2764
+ summary: state.haltReason.details,
2765
+ blockers: state.haltReason.blockers,
2766
+ });
2767
+ cleanupRecordedRoleProcesses(paths, runDir);
2768
+ cleanupWorktree({ state, runDir });
2769
+ return { outcome: 'halt', summary: state.haltReason.details, blockers: state.haltReason.blockers, metrics: {} };
2770
+ }
2771
+
2772
+ appendTimeline(runDir, `Stage ${stageName} round ${state.currentRound} reviewers launching in parallel`);
2773
+ updateSummary(runDir, [
2774
+ `Status: ${state.status}`,
2775
+ `Stage: ${stageName}`,
2776
+ `Round: ${state.currentRound}`,
2777
+ 'Reviewers are running in parallel.',
2778
+ ]);
2779
+
2780
+ // Run both reviewers in parallel — each wrapped in try/catch so one failure doesn't cancel the other
2781
+ const reviewerResults = [];
2782
+ const [resultA, resultB] = await Promise.all([
2783
+ runReviewerRole({ runDir, state, spec, paths, role: 'reviewer_a', worktreeInfo }).catch((err) => {
2784
+ appendTimeline(runDir, `reviewer_a threw unhandled error: ${err.message}`);
2785
+ return { role: 'reviewer_a', output: null, timedOut: false, reportExisted: false, violatedFile: null, error: String(err) };
2786
+ }),
2787
+ runReviewerRole({ runDir, state, spec, paths, role: 'reviewer_b', worktreeInfo }).catch((err) => {
2788
+ appendTimeline(runDir, `reviewer_b threw unhandled error: ${err.message}`);
2789
+ return { role: 'reviewer_b', output: null, timedOut: false, reportExisted: false, violatedFile: null, error: String(err) };
2790
+ }),
2791
+ ]);
2792
+ reviewerResults.push(resultA, resultB);
2793
+
2794
+ // Collect outputs and timeout flags
2795
+ const reviewerTimeouts = [];
2796
+ const reviewerFailures = [];
2797
+ const reviewerViolations = [];
2798
+ for (const result of reviewerResults) {
2799
+ if (result.error) {
2800
+ appendTimeline(runDir, `${result.role} error: ${result.error}`);
2801
+ }
2802
+ outputs[result.role] = result.output;
2803
+ if (result.attemptErrors?.length) {
2804
+ reviewerFailures.push({
2805
+ role: result.role,
2806
+ summary: result.attemptErrors.map((item) => `${item.label}=${item.agent}/${item.model}: ${item.error}`).join(' | '),
2807
+ attempts: result.attemptErrors,
2808
+ });
2809
+ appendTimeline(runDir, `${result.role} failed attempts: ${result.attemptErrors.map((item) => `${item.label}=${item.agent}/${item.model}`).join(', ')}`);
2810
+ }
2811
+ if (result.timedOut) {
2812
+ appendTimeline(runDir, `${result.role} timed out stage ${stageName} round ${state.currentRound}`);
2813
+ reviewerTimeouts.push({ role: result.role, timedOut: true, hadReport: result.reportExisted });
2814
+ }
2815
+ if (result.violatedFile) {
2816
+ appendTimeline(runDir, `${result.role} modified protected file ${result.violatedFile} — report invalidated`);
2817
+ reviewerViolations.push({ role: result.role, violatedFile: result.violatedFile });
2818
+ }
2819
+ }
2820
+ heartbeatState(runDir, state, { currentRole: null });
2821
+
2822
+ // ── Phase 3: Global reviewer (sequential, only if required) ──────────────
2823
+ const stageCriteria = spec.stageCriteria?.[stageName] ?? {};
2824
+ const globalReviewerRequired = stageCriteria.globalReviewerRequired === true;
2825
+ let globalReviewerOutput = null;
2826
+ let globalReviewerTimedOut = false;
2827
+
2828
+ if (globalReviewerRequired) {
2829
+ maybeAbort(runDir, state);
2830
+ heartbeatState(runDir, state, { currentRole: 'global_reviewer' });
2831
+ appendTimeline(runDir, `Stage ${stageName} round ${state.currentRound} global_reviewer is required — running sequentially after A/B`);
2832
+
2833
+ const globalReviewerConfig = roleConfig(spec, 'global_reviewer');
2834
+ if (!globalReviewerConfig) {
2835
+ appendTimeline(runDir, `global_reviewer config not found in spec — skipping`);
2836
+ } else {
2837
+ const globalReviewerPrompt = buildRolePrompt({
2838
+ spec,
2839
+ stage: stageName,
2840
+ round: state.currentRound,
2841
+ role: 'global_reviewer',
2842
+ runDir,
2843
+ stageDir,
2844
+ briefPath,
2845
+ producerPath,
2846
+ reviewerAPath,
2847
+ reviewerBPath,
2848
+ globalReviewerPath: paths.globalReviewerPath,
2849
+ workUnit,
2850
+ });
2851
+ const { reportPath: grReportPath, stdoutPath: grStdoutPath } = roleArtifactPaths(paths, 'global_reviewer');
2852
+
2853
+ if (fileExists(grReportPath) && fs.readFileSync(grReportPath, 'utf8').trim()) {
2854
+ globalReviewerOutput = fs.readFileSync(grReportPath, 'utf8').trim();
2855
+ updateRoleState(paths, 'global_reviewer', {
2856
+ stage: stageName,
2857
+ round: state.currentRound,
2858
+ status: 'completed',
2859
+ lastPid: null,
2860
+ finishedAt: nowIso(),
2861
+ timeoutSeconds: stageRoleTimeout(spec, stageName, 'global_reviewer'),
2862
+ lastError: null,
2863
+ });
2864
+ appendTimeline(runDir, `global_reviewer skipped (report already exists) stage ${stageName} round ${state.currentRound}`);
2865
+ } else {
2866
+ updateRoleState(paths, 'global_reviewer', {
2867
+ stage: stageName,
2868
+ round: state.currentRound,
2869
+ status: 'running',
2870
+ startedAt: nowIso(),
2871
+ finishedAt: null,
2872
+ terminatedAt: null,
2873
+ lastPid: null,
2874
+ timeoutSeconds: stageRoleTimeout(spec, stageName, 'global_reviewer'),
2875
+ lastError: null,
2876
+ });
2877
+ const protectedSnapshot = snapshotProtectedFiles(protectedFiles);
2878
+ const grFailLog = path.join(stageDir, 'global-reviewer-failure.log');
2879
+ let grOutput;
2880
+ try {
2881
+ grOutput = runAgent({
2882
+ cwd: worktreeInfo?.worktreePath ?? (fileExists(spec.branchWorkspace) ? spec.branchWorkspace : spec.workspace),
2883
+ agent: globalReviewerConfig.agent,
2884
+ model: globalReviewerConfig.model,
2885
+ prompt: globalReviewerPrompt,
2886
+ timeoutSeconds: stageRoleTimeout(spec, stageName, 'global_reviewer'),
2887
+ failLogPath: grFailLog,
2888
+ promptDir: runDir,
2889
+ runDir,
2890
+ roleLabel: 'global_reviewer',
2891
+ });
2892
+ } catch (grErr) {
2893
+ appendTimeline(runDir, `global_reviewer error: ${grErr.message}`);
2894
+ grOutput = null;
2895
+ updateRoleState(paths, 'global_reviewer', {
2896
+ status: grErr.message?.includes('timed out') ? 'timed_out' : 'error',
2897
+ lastPid: null,
2898
+ finishedAt: nowIso(),
2899
+ terminatedAt: grErr.message?.includes('timed out') ? nowIso() : null,
2900
+ lastError: grErr.message ?? String(grErr),
2901
+ });
2902
+ }
2903
+ if (grOutput) writeText(grStdoutPath, `${grOutput}\n`);
2904
+ globalReviewerOutput = readRoleOutput({ reportPath: grReportPath, stdout: grOutput ?? '' });
2905
+ if (globalReviewerOutput && !reportExistsAndNonEmpty(grReportPath)) {
2906
+ writeText(grReportPath, `${globalReviewerOutput}\n`);
2907
+ }
2908
+ if (reportExistsAndNonEmpty(grReportPath)) {
2909
+ updateRoleState(paths, 'global_reviewer', {
2910
+ status: 'completed',
2911
+ lastPid: null,
2912
+ finishedAt: nowIso(),
2913
+ lastError: null,
2914
+ });
2915
+ }
2916
+ const grViolated = detectProtectedWriteViolation(protectedFiles, protectedSnapshot, runDir);
2917
+ if (grViolated) {
2918
+ appendTimeline(runDir, `global_reviewer modified protected file ${grViolated.file} — report invalidated`);
2919
+ reviewerViolations.push({ role: 'global_reviewer', violatedFile: grViolated.file });
2920
+ }
2921
+ if (reportExistsAndNonEmpty(grReportPath)) {
2922
+ appendTimeline(runDir, `global_reviewer completed stage ${stageName} round ${state.currentRound}`);
2923
+ } else {
2924
+ appendTimeline(runDir, `global_reviewer produced no report for stage ${stageName} round ${state.currentRound}`);
2925
+ }
2926
+ }
2927
+ }
2928
+ heartbeatState(runDir, state, { currentRole: null });
2929
+ }
2930
+
2931
+ return decideAndPersist({
2932
+ runDir,
2933
+ stageName,
2934
+ stageDir,
2935
+ decisionPath,
2936
+ scorecardPath,
2937
+ producerPath,
2938
+ reviewerAPath,
2939
+ reviewerBPath,
2940
+ globalReviewerPath,
2941
+ state,
2942
+ reviewerTimeouts: reviewerTimeouts.length > 0 ? reviewerTimeouts : null,
2943
+ reviewerViolations: reviewerViolations.length > 0 ? reviewerViolations : null,
2944
+ reviewerFailures: reviewerFailures.length > 0 ? reviewerFailures : null,
2945
+ });
2946
+ }
2947
+
2948
+ function abortRun(runId) {
2949
+ const runDir = path.join(sprintRoot, runId);
2950
+ const sprintFile = path.join(runDir, 'sprint.json');
2951
+ if (!fileExists(sprintFile)) {
2952
+ throw new Error(`Run not found: ${runId}`);
2953
+ }
2954
+ const state = readJson(sprintFile);
2955
+ state.status = 'aborted';
2956
+ state.haltReason = {
2957
+ type: 'operator_abort',
2958
+ stage: state.currentStage,
2959
+ round: state.currentRound,
2960
+ details: 'Aborted by operator',
2961
+ blockers: [],
2962
+ };
2963
+ saveState(runDir, state);
2964
+ updateSummary(runDir, [
2965
+ `Status: ${state.status}`,
2966
+ `Stage: ${state.currentStage}`,
2967
+ `Round: ${state.currentRound}`,
2968
+ 'Operator aborted this sprint.',
2969
+ ]);
2970
+ const paths = ensureStagePaths(runDir, state.currentStageIndex, state.currentStage);
2971
+ cleanupRecordedRoleProcesses(paths, runDir);
2972
+ terminateProcessTree(state.orchestratorPid, { runDir, label: 'orchestrator' });
2973
+ cleanupWorktree({ state, runDir });
2974
+ }
2975
+
2976
+ function pauseRun(runId) {
2977
+ const runDir = path.join(sprintRoot, runId);
2978
+ const sprintFile = path.join(runDir, 'sprint.json');
2979
+ if (!fileExists(sprintFile)) {
2980
+ throw new Error(`Run not found: ${runId}`);
2981
+ }
2982
+ const state = readJson(sprintFile);
2983
+ state.status = 'paused';
2984
+ state.haltReason = {
2985
+ type: 'operator_pause',
2986
+ stage: state.currentStage,
2987
+ round: state.currentRound,
2988
+ details: 'Paused by operator',
2989
+ blockers: [],
2990
+ };
2991
+ saveState(runDir, state);
2992
+ updateSummary(runDir, [
2993
+ `Status: ${state.status}`,
2994
+ `Stage: ${state.currentStage}`,
2995
+ `Round: ${state.currentRound}`,
2996
+ 'Operator paused this sprint.',
2997
+ ]);
2998
+ const paths = ensureStagePaths(runDir, state.currentStageIndex, state.currentStage);
2999
+ cleanupRecordedRoleProcesses(paths, runDir);
3000
+ }
3001
+
3002
+ function listRuns() {
3003
+ ensureDir(sprintRoot);
3004
+ const entries = [];
3005
+ for (const entry of fs.readdirSync(sprintRoot)) {
3006
+ const sprintFile = path.join(sprintRoot, entry, 'sprint.json');
3007
+ if (!fileExists(sprintFile)) continue;
3008
+ try {
3009
+ const state = readJson(sprintFile);
3010
+ const elapsedMin = ((Date.now() - (Date.parse(state.createdAt) || Date.now())) / 60_000).toFixed(0);
3011
+ entries.push({
3012
+ runId: entry,
3013
+ task: state.taskId ?? '?',
3014
+ title: (state.title ?? '').slice(0, 40),
3015
+ status: state.status ?? '?',
3016
+ stage: `${state.currentStage ?? '?'} (${(state.currentStageIndex ?? 0) + 1})`,
3017
+ round: state.currentRound ?? '?',
3018
+ elapsed: `${elapsedMin}m`,
3019
+ });
3020
+ } catch {}
3021
+ }
3022
+
3023
+ if (entries.length === 0) {
3024
+ console.log('No sprint runs found.');
3025
+ return;
3026
+ }
3027
+
3028
+ // Sort by createdAt descending (newest first)
3029
+ entries.reverse();
3030
+
3031
+ const statusIcon = (s) => s === 'completed' ? '[OK]' : s === 'running' ? '>>>' : s === 'halted' ? '[!!]' : s === 'paused' ? '[||]' : '[??]';
3032
+
3033
+ const lines = [
3034
+ '=== Sprint Runs ===',
3035
+ '',
3036
+ ];
3037
+
3038
+ for (const e of entries) {
3039
+ lines.push(` ${statusIcon(e.status)} ${e.status.padEnd(10)} ${e.runId.slice(0, 50)}`);
3040
+ lines.push(` ${e.task} | stage ${e.stage} | round ${e.round} | ${e.elapsed}`);
3041
+ }
3042
+
3043
+ lines.push('');
3044
+ lines.push(` Total: ${entries.length} runs`);
3045
+ console.log(lines.join('\n'));
3046
+ }
3047
+
3048
+ function showStatus(runId) {
3049
+ const runDir = path.join(sprintRoot, runId);
3050
+ const sprintFile = path.join(runDir, 'sprint.json');
3051
+ if (!fileExists(sprintFile)) {
3052
+ throw new Error(`Run not found: ${runId}`);
3053
+ }
3054
+ const state = reconcileRunState(runDir, readJson(sprintFile));
3055
+ const spec = loadSpec(state);
3056
+ const now = Date.now();
3057
+ const createdMs = Date.parse(state.createdAt) || now;
3058
+ const heartbeatMs = Date.parse(state.lastHeartbeatAt) || now;
3059
+ const elapsedMin = ((now - createdMs) / 60_000).toFixed(1);
3060
+ const heartbeatAgeSec = ((now - heartbeatMs) / 1000).toFixed(0);
3061
+ const maxRuntime = spec.maxRuntimeMinutes ?? 90;
3062
+ const maxRounds = state.maxRoundsPerStage ?? 3;
3063
+
3064
+ // Find latest scorecard
3065
+ const stageDirName = `${String(state.currentStageIndex + 1).padStart(2, '0')}-${state.currentStage}`;
3066
+ const scorecardFile = path.join(runDir, 'stages', stageDirName, 'scorecard.json');
3067
+ const scorecard = fileExists(scorecardFile) ? readJson(scorecardFile) : null;
3068
+
3069
+ // Count completed stages by scanning decision files
3070
+ const stagesDir = path.join(runDir, 'stages');
3071
+ const completedStages = [];
3072
+ if (fileExists(stagesDir)) {
3073
+ for (const entry of fs.readdirSync(stagesDir)) {
3074
+ const decFile = path.join(stagesDir, entry, 'decision.md');
3075
+ if (fileExists(decFile)) {
3076
+ const text = fs.readFileSync(decFile, 'utf8');
3077
+ const outcomeMatch = text.match(/Outcome:\s*(\w+)/);
3078
+ completedStages.push({ dir: entry, outcome: outcomeMatch?.[1] ?? 'unknown' });
3079
+ }
3080
+ }
3081
+ }
3082
+
3083
+ // Latest timeline entries (last 8)
3084
+ const timelineFile = path.join(runDir, 'timeline.md');
3085
+ let recentEvents = [];
3086
+ if (fileExists(timelineFile)) {
3087
+ recentEvents = fs.readFileSync(timelineFile, 'utf8')
3088
+ .split('\n')
3089
+ .filter((l) => l.startsWith('- '))
3090
+ .slice(-8)
3091
+ .map((l) => l.replace(/^- (\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})\d{3}Z\s*/, ''));
3092
+ }
3093
+
3094
+ // Build driver dashboard
3095
+ const statusIcon = state.status === 'running' ? '>>>' : state.status === 'completed' ? '[OK]' : '[!!]';
3096
+ const aliveHint = state.status === 'running'
3097
+ ? (parseInt(heartbeatAgeSec, 10) > 120 ? `WARNING: heartbeat ${heartbeatAgeSec}s ago` : `heartbeat ${heartbeatAgeSec}s ago`)
3098
+ : '';
3099
+
3100
+ const lines = [
3101
+ `=== Sprint Dashboard ===`,
3102
+ '',
3103
+ ` ${statusIcon} ${state.status.toUpperCase()}`,
3104
+ ` Task: ${state.title}`,
3105
+ ` Run: ${runId}`,
3106
+ '',
3107
+ ` Stage: ${state.currentStage} (${state.currentStageIndex + 1}/${spec.stages.length})`,
3108
+ ` Round: ${state.currentRound} / ${maxRounds}`,
3109
+ ` Role: ${state.currentRole ?? 'idle'}`,
3110
+ '',
3111
+ ` Elapsed: ${elapsedMin} min / ${maxRuntime} min max`,
3112
+ ` ${aliveHint}`,
3113
+ '',
3114
+ ];
3115
+
3116
+ // Stage progress bar
3117
+ if (spec.stages.length > 0) {
3118
+ lines.push(' Stages:');
3119
+ for (const stage of spec.stages) {
3120
+ const done = completedStages.find((s) => s.dir.includes(stage));
3121
+ const isCurrent = stage === state.currentStage;
3122
+ const marker = done ? (done.outcome === 'advance' ? ' [PASS]' : ` [${done.outcome}]`) : (isCurrent ? ' >>' : ' --');
3123
+ lines.push(` ${stage}${marker}`);
3124
+ }
3125
+ lines.push('');
3126
+ }
3127
+
3128
+ // Latest scorecard
3129
+ if (scorecard) {
3130
+ lines.push(' Latest round verdict:');
3131
+ lines.push(` reviewer_a: ${scorecard.reviewerAVerdict ?? '?'}`);
3132
+ lines.push(` reviewer_b: ${scorecard.reviewerBVerdict ?? '?'}`);
3133
+ lines.push(` approvals: ${scorecard.approvalCount ?? 0} / 2 required`);
3134
+ if (scorecard.failureClassification) {
3135
+ lines.push(` failure: ${scorecard.failureClassification}`);
3136
+ lines.push(` source: ${scorecard.failureSource ?? 'n/a'}`);
3137
+ }
3138
+ if (scorecard.blockerCount > 0) {
3139
+ lines.push(` blockers: ${scorecard.blockerCount}`);
3140
+ for (const b of (scorecard.blockers ?? []).slice(0, 3)) {
3141
+ lines.push(` - ${b.slice(0, 120)}`);
3142
+ }
3143
+ }
3144
+ lines.push('');
3145
+ }
3146
+
3147
+ // Recent events
3148
+ if (recentEvents.length > 0) {
3149
+ lines.push(' Recent events:');
3150
+ for (const evt of recentEvents) {
3151
+ lines.push(` ${evt}`);
3152
+ }
3153
+ lines.push('');
3154
+ }
3155
+
3156
+ // Controls reminder
3157
+ lines.push(' Controls:');
3158
+ lines.push(` npm run ai-sprint -- --status ${runId}`);
3159
+ lines.push(` npm run ai-sprint -- --pause ${runId}`);
3160
+ lines.push(` npm run ai-sprint -- --abort ${runId}`);
3161
+ lines.push(` npm run ai-sprint -- --resume ${runId}`);
3162
+ lines.push('');
3163
+
3164
+ console.log(lines.join('\n'));
3165
+ }
3166
+
3167
+ async function main() {
3168
+ const args = parseArgs(process.argv.slice(2));
3169
+
3170
+ if (args.help) {
3171
+ console.log([
3172
+ 'AI Sprint Orchestrator',
3173
+ '',
3174
+ 'Usage:',
3175
+ ' node run.mjs --self-check Validate package-local environment',
3176
+ ' node run.mjs --task <task-id> [--task-spec <path>] Start a new sprint',
3177
+ ' node run.mjs --resume <run-id> Resume a halted/aborted sprint',
3178
+ ' node run.mjs --status <run-id> Show sprint dashboard',
3179
+ ' node run.mjs --list List all sprint runs',
3180
+ ' node run.mjs --pause <run-id> Pause a running sprint',
3181
+ ' node run.mjs --abort <run-id> Abort a sprint',
3182
+ ' node run.mjs --archive <run-id> Archive a completed/halted sprint',
3183
+ ' node run.mjs --task <task-id> --runtime-root <path> Override runtime output root',
3184
+ '',
3185
+ 'Sprints auto-archive on completion or halt.',
3186
+ `Task specs are loaded from ${path.relative(packageRoot, path.join(referencesRoot, 'specs'))}/<task-id>.json`,
3187
+ `Runtime output defaults to ${defaultRuntimeRoot}`,
3188
+ 'Override with --task-spec <path> to use a custom spec file.',
3189
+ ].join('\n'));
3190
+ return;
3191
+ }
3192
+
3193
+ if (args['self-check']) {
3194
+ if (args.runtimeRoot) {
3195
+ configureRuntimeRoots(args.runtimeRoot);
3196
+ } else {
3197
+ configureRuntimeRoots(runtimeRoot);
3198
+ }
3199
+ runSelfCheck();
3200
+ return;
3201
+ }
3202
+
3203
+ if (args.list) {
3204
+ listRuns();
3205
+ return;
3206
+ }
3207
+
3208
+ if (args.archive) {
3209
+ try {
3210
+ const archiveDir = archiveRunById(args.archive);
3211
+ console.log(`Archived: ${archiveDir}`);
3212
+ } catch (err) {
3213
+ console.error(`Archive failed: ${err.message}`);
3214
+ process.exitCode = 1;
3215
+ }
3216
+ return;
3217
+ }
3218
+
3219
+ if (args.abort) {
3220
+ abortRun(args.abort);
3221
+ console.log(`Aborted sprint ${args.abort}`);
3222
+ return;
3223
+ }
3224
+
3225
+ if (args.pause) {
3226
+ pauseRun(args.pause);
3227
+ console.log(`Paused sprint ${args.pause}`);
3228
+ return;
3229
+ }
3230
+
3231
+ if (args.status) {
3232
+ showStatus(args.status);
3233
+ return;
3234
+ }
3235
+
3236
+ if (args.runtimeRoot) {
3237
+ configureRuntimeRoots(args.runtimeRoot);
3238
+ } else {
3239
+ configureRuntimeRoots(runtimeRoot);
3240
+ }
3241
+
3242
+ const { runDir, state } = loadOrInitState(args);
3243
+ const spec = loadSpec(state, args);
3244
+
3245
+ // Register graceful shutdown handlers now that state and runDir are available.
3246
+ // This ensures uncaught exceptions during the sprint loop write the halted state
3247
+ // to disk instead of leaving it stuck at 'running'.
3248
+ const gracefulHalt = (signal, detail) => {
3249
+ if (state.status === 'running') {
3250
+ state.status = 'halted';
3251
+ state.haltReason = {
3252
+ type: 'uncaught_exception',
3253
+ stage: state.currentStage,
3254
+ round: state.currentRound,
3255
+ details: String(detail),
3256
+ blockers: [String(detail)],
3257
+ };
3258
+ try {
3259
+ saveState(runDir, state);
3260
+ appendTimeline(runDir, `Sprint halted by uncaught ${signal}: ${String(detail)}`);
3261
+ } catch (saveErr) {
3262
+ console.error(`Failed to write halted state: ${saveErr.message}`);
3263
+ }
3264
+ }
3265
+ console.error(`Fatal ${signal}: ${detail}`);
3266
+ process.exitCode = 1;
3267
+ };
3268
+ process.on('uncaughtException', (err) => {
3269
+ gracefulHalt('uncaughtException', err.stack ?? err.message);
3270
+ });
3271
+ process.on('unhandledRejection', (reason) => {
3272
+ gracefulHalt('unhandledRejection', String(reason));
3273
+ });
3274
+
3275
+ // Pre-flight check: verify acpx is available (the actual execution entry point).
3276
+ // We do NOT check agent names with 'which' because acpx resolves agents via its
3277
+ // own registry (npx, built-in commands, etc.). Checking agent binaries directly
3278
+ // would cause false positives and is Linux-only.
3279
+ if (!args.status && !args.list && !args.archive && !args.abort && !args.pause) {
3280
+ const acpxCheck = checkAcpxAvailable();
3281
+ if (acpxCheck.status !== 0) {
3282
+ appendTimeline(runDir, `Pre-flight check FAILED: acpx not available`);
3283
+ throw new Error(`acpx not available. Install it first: npm install -g acpx`);
3284
+ }
3285
+ appendTimeline(runDir, `Pre-flight check OK: acpx available`);
3286
+ }
3287
+
3288
+ const sprintStartedAt = Date.parse(state.createdAt) || Date.now();
3289
+
3290
+ // Acquire exclusive lock to prevent concurrent orchestrators
3291
+ const lockPath = path.join(runDir, 'orchestrator.lock');
3292
+ if (fileExists(lockPath)) {
3293
+ const lockData = readJson(lockPath);
3294
+ const lockAge = Date.now() - Date.parse(lockData.acquiredAt || 0);
3295
+ if (lockData.pid && pidExists(lockData.pid) && lockData.pid !== process.pid) {
3296
+ if (lockAge < 30 * 60 * 1000) { // lock valid for 30 min
3297
+ throw new Error(`Another orchestrator (PID ${lockData.pid}) is running. Acquired at ${lockData.acquiredAt}. Aborting.`);
3298
+ }
3299
+ }
3300
+ // Clean up expired or stale lock
3301
+ if (lockAge >= 30 * 60 * 1000 || !pidExists(lockData.pid)) {
3302
+ try { fs.unlinkSync(lockPath); } catch {}
3303
+ }
3304
+ }
3305
+ writeJson(lockPath, { pid: process.pid, acquiredAt: nowIso() });
3306
+
3307
+ appendTimeline(runDir, state.currentRound === 1 && state.currentStageIndex === 0 ? 'Sprint execution started' : 'Sprint resumed');
3308
+
3309
+ try {
3310
+ while (state.status === 'running') {
3311
+ heartbeatState(runDir, state);
3312
+
3313
+ // Global runtime check
3314
+ const elapsedMinutes = (Date.now() - sprintStartedAt) / 60_000;
3315
+ const maxRuntime = state.maxRuntimeMinutes ?? spec.maxRuntimeMinutes ?? 90;
3316
+ if (elapsedMinutes > maxRuntime) {
3317
+ state.status = 'halted';
3318
+ state.haltReason = {
3319
+ type: 'max_runtime_exceeded',
3320
+ stage: state.currentStage,
3321
+ round: state.currentRound,
3322
+ details: `Sprint exceeded ${maxRuntime} minutes (elapsed: ${elapsedMinutes.toFixed(1)}min)`,
3323
+ blockers: ['Runtime limit exceeded.'],
3324
+ };
3325
+ saveState(runDir, state);
3326
+ appendTimeline(runDir, `Sprint halted: ${state.haltReason.details}`);
3327
+ updateSummaryWithClassification(runDir, [
3328
+ `Status: ${state.status}`,
3329
+ `Stage: ${state.currentStage}`,
3330
+ `Round: ${state.currentRound}`,
3331
+ `Halt reason: ${state.haltReason.details}`,
3332
+ ], readStageFailureClassification(runDir, state));
3333
+ const paths = ensureStagePaths(runDir, state.currentStageIndex, state.currentStage);
3334
+ cleanupRecordedRoleProcesses(paths, runDir);
3335
+ break;
3336
+ }
3337
+
3338
+ const decision = await executeStage(runDir, state, spec);
3339
+ advanceState(state, spec, decision, { runDir });
3340
+ saveState(runDir, state);
3341
+ if (state.status === 'completed') {
3342
+ cleanupWorktree({ state, runDir });
3343
+ appendTimeline(runDir, 'Sprint completed');
3344
+ updateSummary(runDir, [
3345
+ `Status: ${state.status}`,
3346
+ `Stage: ${state.currentStage}`,
3347
+ `Round: ${state.currentRound}`,
3348
+ ...(state.mergePending
3349
+ ? [`Merge pending: push branch '${state.mergePending.targetBranch}' before merge.`]
3350
+ : ['All stages finished.']),
3351
+ ]);
3352
+ }
3353
+ if (state.status === 'halted') {
3354
+ const paths = ensureStagePaths(runDir, state.currentStageIndex, state.currentStage);
3355
+ cleanupRecordedRoleProcesses(paths, runDir);
3356
+ cleanupWorktree({ state, runDir });
3357
+ appendTimeline(runDir, `Sprint halted: ${state.haltReason?.details ?? 'unknown reason'}`);
3358
+ updateSummaryWithClassification(runDir, [
3359
+ `Status: ${state.status}`,
3360
+ `Stage: ${state.currentStage}`,
3361
+ `Round: ${state.currentRound}`,
3362
+ `Halt reason: ${state.haltReason?.details ?? 'unknown reason'}`,
3363
+ ], readStageFailureClassification(runDir, state));
3364
+ }
3365
+ }
3366
+ } catch (error) {
3367
+ if (state.status !== 'halted') {
3368
+ state.status = 'halted';
3369
+ state.haltReason = {
3370
+ type: 'orchestrator_error',
3371
+ stage: state.currentStage,
3372
+ round: state.currentRound,
3373
+ details: String(error),
3374
+ blockers: [String(error)],
3375
+ };
3376
+ const paths = ensureStagePaths(runDir, state.currentStageIndex, state.currentStage);
3377
+ cleanupRecordedRoleProcesses(paths, runDir);
3378
+ cleanupWorktree({ state, runDir });
3379
+ saveState(runDir, state);
3380
+ appendTimeline(runDir, `Sprint halted by orchestrator error: ${String(error)}`);
3381
+ updateSummaryWithClassification(runDir, [
3382
+ `Status: ${state.status}`,
3383
+ `Stage: ${state.currentStage}`,
3384
+ `Round: ${state.currentRound}`,
3385
+ `Halt reason: ${String(error)}`,
3386
+ ], readStageFailureClassification(runDir, state) ?? inferFailureClassification({
3387
+ summary: String(error),
3388
+ blockers: [String(error)],
3389
+ }));
3390
+ }
3391
+ }
3392
+
3393
+ // Auto-archive if sprint reached a terminal state
3394
+ if (state.status === 'completed' || state.status === 'halted') {
3395
+ try {
3396
+ const archiveDir = archiveRunById(args.resume || path.basename(runDir));
3397
+ appendTimeline(runDir, `Auto-archived to ${path.relative(runtimeRoot, archiveDir)}`);
3398
+ console.log(`Archived: ${archiveDir}`);
3399
+ } catch (archiveErr) {
3400
+ // Archive failure must not block orchestrator exit
3401
+ console.error(`Auto-archive failed: ${archiveErr.message}`);
3402
+ }
3403
+ }
3404
+
3405
+ // Release lock
3406
+ try { fs.unlinkSync(lockPath); } catch {}
3407
+
3408
+ console.log(runDir);
3409
+ }
3410
+
3411
+ // Only run main() when executed directly, not when imported for testing
3412
+ if (process.argv[1] === fileURLToPath(import.meta.url)) {
3413
+ main().catch((err) => {
3414
+ // main() is async and may throw. The try/catch inside main() handles
3415
+ // errors within its body, but rejections from the Promise itself land here.
3416
+ console.error('Fatal error:', err.message);
3417
+ process.exit(1);
3418
+ });
3419
+ }