codex-orchestrator 0.1.43 → 0.1.45

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 (56) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/dist/src/config/schema.d.ts +1 -1
  3. package/dist/src/config/schema.d.ts.map +1 -1
  4. package/dist/src/config/schema.js +1 -0
  5. package/dist/src/config/schema.js.map +1 -1
  6. package/dist/src/runner/acceptance-proof-loop.d.ts +22 -2
  7. package/dist/src/runner/acceptance-proof-loop.d.ts.map +1 -1
  8. package/dist/src/runner/acceptance-proof-loop.js +189 -31
  9. package/dist/src/runner/acceptance-proof-loop.js.map +1 -1
  10. package/dist/src/runner/agent-attempt.d.ts.map +1 -1
  11. package/dist/src/runner/agent-attempt.js +10 -0
  12. package/dist/src/runner/agent-attempt.js.map +1 -1
  13. package/dist/src/runner/completion-report.d.ts +23 -0
  14. package/dist/src/runner/completion-report.d.ts.map +1 -1
  15. package/dist/src/runner/completion-report.js +64 -4
  16. package/dist/src/runner/completion-report.js.map +1 -1
  17. package/dist/src/runner/durable-run-summary.d.ts +5 -0
  18. package/dist/src/runner/durable-run-summary.d.ts.map +1 -1
  19. package/dist/src/runner/durable-run-summary.js +25 -0
  20. package/dist/src/runner/durable-run-summary.js.map +1 -1
  21. package/dist/src/runner/handoff-evidence.d.ts +4 -0
  22. package/dist/src/runner/handoff-evidence.d.ts.map +1 -1
  23. package/dist/src/runner/handoff-evidence.js +135 -21
  24. package/dist/src/runner/handoff-evidence.js.map +1 -1
  25. package/dist/src/runner/local-execution-session.d.ts +24 -1
  26. package/dist/src/runner/local-execution-session.d.ts.map +1 -1
  27. package/dist/src/runner/local-execution-session.js +672 -29
  28. package/dist/src/runner/local-execution-session.js.map +1 -1
  29. package/dist/src/runner/prompt.d.ts.map +1 -1
  30. package/dist/src/runner/prompt.js +17 -2
  31. package/dist/src/runner/prompt.js.map +1 -1
  32. package/dist/src/runner/review-gate-policy.d.ts.map +1 -1
  33. package/dist/src/runner/review-gate-policy.js +8 -0
  34. package/dist/src/runner/review-gate-policy.js.map +1 -1
  35. package/dist/src/runner/review-gates.d.ts +2 -0
  36. package/dist/src/runner/review-gates.d.ts.map +1 -1
  37. package/dist/src/runner/review-gates.js +50 -11
  38. package/dist/src/runner/review-gates.js.map +1 -1
  39. package/dist/src/runner/rework-policy.d.ts +12 -1
  40. package/dist/src/runner/rework-policy.d.ts.map +1 -1
  41. package/dist/src/runner/rework-policy.js +46 -7
  42. package/dist/src/runner/rework-policy.js.map +1 -1
  43. package/dist/src/runner/runner-handoff-decision.d.ts +2 -1
  44. package/dist/src/runner/runner-handoff-decision.d.ts.map +1 -1
  45. package/dist/src/runner/runner-handoff-decision.js +10 -1
  46. package/dist/src/runner/runner-handoff-decision.js.map +1 -1
  47. package/dist/src/runner/scoped-auto-command.d.ts +2 -1
  48. package/dist/src/runner/scoped-auto-command.d.ts.map +1 -1
  49. package/dist/src/runner/scoped-auto-command.js +10 -3
  50. package/dist/src/runner/scoped-auto-command.js.map +1 -1
  51. package/dist/src/setup/project-config.d.ts.map +1 -1
  52. package/dist/src/setup/project-config.js +4 -12
  53. package/dist/src/setup/project-config.js.map +1 -1
  54. package/docs/deep-dive.md +97 -15
  55. package/package.json +1 -1
  56. package/prompts/workflows/scoped-implementation.md +6 -0
@@ -1,14 +1,19 @@
1
+ import { createHash } from 'node:crypto';
1
2
  import { existsSync } from 'node:fs';
2
- import { join } from 'node:path';
3
+ import { readFile } from 'node:fs/promises';
4
+ import { isAbsolute, join, relative, resolve } from 'node:path';
3
5
  import { mergeArtifacts, runConfiguredChecks } from './command-utils.js';
4
- import { readScopedCompletionReport } from './completion-report.js';
6
+ import { readScopedCompletionReportDetailed, } from './completion-report.js';
7
+ import { sessionPromptPath, writeDurablePrompt } from './prompt.js';
8
+ import { sessionLogPath } from './run-log.js';
5
9
  import { evaluateReviewGates, shouldApplyVisualProofGate } from './review-gates.js';
6
10
  import { validateChangedPaths, validateCompletionReportSafety, validateNoAgentOwnedGitPublication, } from './safety.js';
7
11
  import { evaluateScopeIsolation } from './scope-isolation-policy.js';
8
12
  import { runRunnerVisualProofAdapter } from './visual-proof-runner.js';
9
13
  import { runAcceptanceProofAdapter, } from './acceptance-proof-runner.js';
10
14
  import { runAcceptanceProofLoopAttempt } from './acceptance-proof-loop.js';
11
- import { MISSING_COMPLETION_REPORT_REASON, OPTIONAL_FIGMA_MCP_FAILURE_REASON, REQUIRED_FIGMA_MCP_FAILURE_REASON, } from './rework-policy.js';
15
+ import { INCOMPLETE_AFTER_PROGRESS_REASON, MISSING_COMPLETION_REPORT_REASON, OPTIONAL_FIGMA_MCP_FAILURE_REASON, REQUIRED_FIGMA_MCP_FAILURE_REASON, } from './rework-policy.js';
16
+ import { cleanupSessionCodexHome, sessionCodexHomePath } from './session-home.js';
12
17
  export async function runLocalExecutionSession(input) {
13
18
  const phaseResults = [];
14
19
  for (const phaseId of input.phases) {
@@ -34,26 +39,43 @@ export async function runLocalExecutionSession(input) {
34
39
  };
35
40
  }
36
41
  export async function runImplementationPublishabilityCheck(input) {
42
+ const repairAttempts = [];
37
43
  const publicationViolations = input.config.runner.allowAgentLocalCommits
38
44
  ? []
39
45
  : validateNoAgentOwnedGitPublication(input.beforeHead, input.afterHead);
40
46
  if (publicationViolations.length > 0) {
41
47
  return blocked(publicationViolations.map((violation) => violation.message));
42
48
  }
43
- if (input.codexResult.exitCode !== 0) {
44
- return blocked([formatCodexExitReason(input.codexResult)]);
45
- }
46
49
  let report;
47
- try {
48
- const reportResult = await readScopedCompletionReport(input.reportPath);
49
- if (reportResult.kind === 'missing') {
50
- return blocked([MISSING_COMPLETION_REPORT_REASON]);
50
+ const idleTimeout = isIdleTimeoutResult(input.codexResult);
51
+ if (input.codexResult.exitCode !== 0) {
52
+ const figmaReason = formatFigmaMcpFailureReason(input.codexResult);
53
+ if (figmaReason) {
54
+ return blocked([figmaReason]);
55
+ }
56
+ if (!idleTimeout) {
57
+ return blocked([formatCodexExitReason(input.codexResult)]);
58
+ }
59
+ const reportResult = await readScopedCompletionReportDetailed(input.reportPath);
60
+ const preparedReport = await resolveInitialReportRead(input, reportResult, {
61
+ idleTimeout,
62
+ repairAttempts,
63
+ });
64
+ if (preparedReport.kind === 'blocked') {
65
+ return preparedReport.result;
51
66
  }
52
- report = reportResult.report;
67
+ report = preparedReport.report;
53
68
  }
54
- catch (error) {
55
- const message = error instanceof Error ? error.message : 'Invalid scoped completion report';
56
- return blocked([message]);
69
+ else {
70
+ const reportResult = await readScopedCompletionReportDetailed(input.reportPath);
71
+ const preparedReport = await resolveInitialReportRead(input, reportResult, {
72
+ idleTimeout,
73
+ repairAttempts,
74
+ });
75
+ if (preparedReport.kind === 'blocked') {
76
+ return preparedReport.result;
77
+ }
78
+ report = preparedReport.report;
57
79
  }
58
80
  if (report.status === 'needs-promotion') {
59
81
  return { status: 'promotion-requested', report };
@@ -76,6 +98,7 @@ export async function runImplementationPublishabilityCheck(input) {
76
98
  skippedChecks: applicableSkippedChecks(input, report, changeSet.changedPaths),
77
99
  residualRisks: [...report.residualRisks, ...localSession.phaseResults.flatMap((phase) => phase.residualRisks)],
78
100
  commits: changeSet.commits,
101
+ ...repairAttemptsField(repairAttempts),
79
102
  };
80
103
  }
81
104
  report = {
@@ -102,6 +125,7 @@ export async function runImplementationPublishabilityCheck(input) {
102
125
  skippedChecks: report.skippedChecks,
103
126
  residualRisks: report.residualRisks,
104
127
  commits: changeSet.commits,
128
+ ...repairAttemptsField(repairAttempts),
105
129
  };
106
130
  }
107
131
  const scopeIsolation = evaluateScopeIsolation({
@@ -118,6 +142,7 @@ export async function runImplementationPublishabilityCheck(input) {
118
142
  skippedChecks: report.skippedChecks,
119
143
  residualRisks: report.residualRisks,
120
144
  commits: changeSet.commits,
145
+ ...repairAttemptsField(repairAttempts),
121
146
  };
122
147
  }
123
148
  const violations = [
@@ -133,6 +158,7 @@ export async function runImplementationPublishabilityCheck(input) {
133
158
  skippedChecks: report.skippedChecks,
134
159
  residualRisks: report.residualRisks,
135
160
  commits: changeSet.commits,
161
+ ...repairAttemptsField(repairAttempts),
136
162
  };
137
163
  }
138
164
  const validationBeforeChecks = report.validation.length;
@@ -156,6 +182,7 @@ export async function runImplementationPublishabilityCheck(input) {
156
182
  worktreePath: input.worktreePath,
157
183
  beforeHead: input.beforeHead,
158
184
  initialChangedFiles: changedFiles,
185
+ implementationReport: report,
159
186
  adaptiveAdapterAvailable: Boolean(input.acceptanceProof),
160
187
  executeAdaptiveProof: input.acceptanceProof
161
188
  ? async () => {
@@ -245,6 +272,7 @@ export async function runImplementationPublishabilityCheck(input) {
245
272
  residualRisks,
246
273
  commits: changeSet.commits,
247
274
  acceptanceProofAttempt,
275
+ ...repairAttemptsField(repairAttempts),
248
276
  };
249
277
  }
250
278
  }
@@ -263,6 +291,7 @@ export async function runImplementationPublishabilityCheck(input) {
263
291
  residualRisks,
264
292
  commits: changeSet.commits,
265
293
  acceptanceProofAttempt,
294
+ ...repairAttemptsField(repairAttempts),
266
295
  };
267
296
  }
268
297
  const reviewGate = evaluateReviewGates({
@@ -274,23 +303,46 @@ export async function runImplementationPublishabilityCheck(input) {
274
303
  report,
275
304
  worktreePath: input.worktreePath,
276
305
  });
306
+ let reviewGateWarnings = reviewGate.warnings;
277
307
  if (!reviewGate.ok) {
278
- return {
279
- status: 'blocked',
280
- reasons: reviewGate.reasons,
308
+ const evidenceRepair = await maybeRunEvidenceReportRepair(input, {
309
+ report,
310
+ reviewGate,
281
311
  changedFiles,
312
+ changeSet,
282
313
  validation,
283
- skippedChecks: report.skippedChecks,
284
314
  residualRisks,
285
- commits: changeSet.commits,
286
315
  acceptanceProofAttempt,
287
- };
316
+ repairAttempts,
317
+ });
318
+ if (evidenceRepair.kind === 'publish-ready') {
319
+ report = evidenceRepair.report;
320
+ validation = evidenceRepair.validation;
321
+ reviewGateWarnings = evidenceRepair.reviewWarnings;
322
+ }
323
+ else if (evidenceRepair.kind === 'blocked') {
324
+ return evidenceRepair.result;
325
+ }
326
+ else {
327
+ return {
328
+ status: 'blocked',
329
+ reasons: reviewGate.reasons,
330
+ blockers: reviewGate.blockers,
331
+ changedFiles,
332
+ validation,
333
+ skippedChecks: report.skippedChecks,
334
+ residualRisks,
335
+ commits: changeSet.commits,
336
+ acceptanceProofAttempt,
337
+ ...repairAttemptsField(repairAttempts),
338
+ };
339
+ }
288
340
  }
289
- if (reviewGate.warnings.length > 0) {
290
- residualRisks.push(...reviewGate.warnings);
341
+ if (reviewGateWarnings.length > 0) {
342
+ residualRisks.push(...reviewGateWarnings);
291
343
  report = {
292
344
  ...report,
293
- residualRisks: [...report.residualRisks, ...reviewGate.warnings],
345
+ residualRisks: [...report.residualRisks, ...reviewGateWarnings],
294
346
  };
295
347
  }
296
348
  if (!(await input.git.isWorktreeClean(input.worktreePath))) {
@@ -310,8 +362,574 @@ export async function runImplementationPublishabilityCheck(input) {
310
362
  residualRisks,
311
363
  commits: changeSet.commits,
312
364
  acceptanceProofAttempt,
365
+ ...repairAttemptsField(repairAttempts),
366
+ };
367
+ }
368
+ async function resolveInitialReportRead(input, reportResult, state) {
369
+ if (reportResult.kind === 'valid') {
370
+ return { kind: 'valid', report: reportResult.report };
371
+ }
372
+ const repair = state.idleTimeout
373
+ ? { kind: 'not-eligible' }
374
+ : await maybeRunCompletionReportRepair(input, reportResult, state.repairAttempts);
375
+ if (repair.kind === 'valid') {
376
+ return repair;
377
+ }
378
+ if (repair.kind === 'blocked') {
379
+ return repair;
380
+ }
381
+ if (reportResult.kind === 'missing') {
382
+ if (!state.idleTimeout) {
383
+ return {
384
+ kind: 'blocked',
385
+ result: blocked([MISSING_COMPLETION_REPORT_REASON], {
386
+ blockers: [runnerBlocker('missing-completion-report', MISSING_COMPLETION_REPORT_REASON, 'completion-report', 'completion-report')],
387
+ repairAttempts: state.repairAttempts,
388
+ }),
389
+ };
390
+ }
391
+ const changeSet = repair.changeSet ?? await input.git.collectSessionChangeSet({
392
+ worktreePath: input.worktreePath,
393
+ baseHead: input.beforeHead,
394
+ });
395
+ const changedFiles = changeSet.changedPaths;
396
+ if (changedFiles.length === 0) {
397
+ return {
398
+ kind: 'blocked',
399
+ result: blocked(['Codex completed without file changes'], { repairAttempts: state.repairAttempts }),
400
+ };
401
+ }
402
+ const changedPathViolations = validateChangedPaths(changedFiles, input.config);
403
+ if (changedPathViolations.length > 0) {
404
+ return {
405
+ kind: 'blocked',
406
+ result: blocked(changedPathViolations.map((violation) => violation.message), {
407
+ changedFiles,
408
+ commits: changeSet.commits,
409
+ repairAttempts: state.repairAttempts,
410
+ }),
411
+ };
412
+ }
413
+ const scopeIsolation = evaluateScopeIsolation({
414
+ config: input.config,
415
+ issue: input.issue,
416
+ changedFiles,
417
+ });
418
+ if (scopeIsolation.blockers.length > 0) {
419
+ return {
420
+ kind: 'blocked',
421
+ result: blocked(scopeIsolation.blockers, {
422
+ changedFiles,
423
+ commits: changeSet.commits,
424
+ repairAttempts: state.repairAttempts,
425
+ }),
426
+ };
427
+ }
428
+ return {
429
+ kind: 'blocked',
430
+ result: blocked([INCOMPLETE_AFTER_PROGRESS_REASON], {
431
+ changedFiles,
432
+ validation: [],
433
+ commits: changeSet.commits,
434
+ repairAttempts: state.repairAttempts,
435
+ blockers: [runnerBlocker('incomplete-after-progress', INCOMPLETE_AFTER_PROGRESS_REASON, 'recovery', 'implementation-rework')],
436
+ }),
437
+ };
438
+ }
439
+ return {
440
+ kind: 'blocked',
441
+ result: blocked([reportResult.message], {
442
+ blockers: [runnerBlocker('invalid-completion-report', reportResult.message, 'completion-report', 'completion-report')],
443
+ repairAttempts: state.repairAttempts,
444
+ }),
445
+ };
446
+ }
447
+ async function maybeRunCompletionReportRepair(input, reportIssue, repairAttempts) {
448
+ const changeSet = await input.git.collectSessionChangeSet({
449
+ worktreePath: input.worktreePath,
450
+ baseHead: input.beforeHead,
451
+ });
452
+ const changedFiles = changeSet.changedPaths;
453
+ if (changedFiles.length === 0) {
454
+ return { kind: 'not-eligible', changeSet };
455
+ }
456
+ const changedPathViolations = validateChangedPaths(changedFiles, input.config);
457
+ if (changedPathViolations.length > 0) {
458
+ return {
459
+ kind: 'blocked',
460
+ result: blocked(changedPathViolations.map((violation) => violation.message), {
461
+ changedFiles,
462
+ commits: changeSet.commits,
463
+ }),
464
+ };
465
+ }
466
+ const scopeIsolation = evaluateScopeIsolation({
467
+ config: input.config,
468
+ issue: input.issue,
469
+ changedFiles,
470
+ });
471
+ if (scopeIsolation.blockers.length > 0) {
472
+ return {
473
+ kind: 'blocked',
474
+ result: blocked(scopeIsolation.blockers, {
475
+ changedFiles,
476
+ commits: changeSet.commits,
477
+ }),
478
+ };
479
+ }
480
+ if (!input.reportRepair || !input.git.getHead) {
481
+ return { kind: 'not-eligible', changeSet };
482
+ }
483
+ const repairSessionId = `${input.reportRepair.sessionId}-completion-report-repair`;
484
+ const promptPath = sessionPromptPath({
485
+ targetRoot: input.reportRepair.targetRoot,
486
+ config: input.config,
487
+ issueNumber: input.issue.number,
488
+ sessionId: repairSessionId,
489
+ });
490
+ const logPath = sessionLogPath({
491
+ targetRoot: input.reportRepair.targetRoot,
492
+ config: input.config,
493
+ issueNumber: input.issue.number,
494
+ sessionId: repairSessionId,
495
+ });
496
+ const repairAttemptBase = {
497
+ kind: 'completion-report',
498
+ sessionId: repairSessionId,
499
+ promptPath,
500
+ reportPath: input.reportPath,
501
+ logPath,
502
+ };
503
+ const preRepairHead = await input.git.getHead(input.worktreePath);
504
+ const protectedBefore = await captureRepairProtectedFingerprint(input, changeSet);
505
+ const promptText = buildCompletionReportRepairPrompt({
506
+ input,
507
+ reportIssue,
508
+ changedFiles,
509
+ });
510
+ await writeDurablePrompt({
511
+ targetRoot: input.reportRepair.targetRoot,
512
+ config: input.config,
513
+ issueNumber: input.issue.number,
514
+ sessionId: repairSessionId,
515
+ promptText,
516
+ });
517
+ const isolatedHomePath = sessionCodexHomePath({
518
+ targetRoot: input.reportRepair.targetRoot,
519
+ sessionId: repairSessionId,
520
+ });
521
+ let repairResult;
522
+ try {
523
+ repairResult = await input.reportRepair.codexAdapter.run({
524
+ targetRoot: input.reportRepair.targetRoot,
525
+ config: input.config,
526
+ worktreePath: input.worktreePath,
527
+ promptPath,
528
+ promptText,
529
+ reportPath: input.reportPath,
530
+ isolatedHomePath,
531
+ issueNumber: input.issue.number,
532
+ sessionId: repairSessionId,
533
+ branchName: input.reportRepair.branchName,
534
+ phase: 'scoped-issue',
535
+ logPath,
536
+ env: {
537
+ CODEX_ORCHESTRATOR_REPAIR_MODE: 'completion-report',
538
+ },
539
+ });
540
+ }
541
+ finally {
542
+ await cleanupSessionCodexHome(isolatedHomePath);
543
+ }
544
+ if (repairResult.exitCode !== 0) {
545
+ const reason = `Completion report repair failed: ${formatCodexExitReason(repairResult)}`;
546
+ const blockers = [runnerBlocker('invalid-completion-report', reason, 'completion-report', 'none')];
547
+ repairAttempts.push({ ...repairAttemptBase, status: 'blocked', reason, blockers });
548
+ return {
549
+ kind: 'blocked',
550
+ result: blocked([reason], {
551
+ changedFiles,
552
+ commits: changeSet.commits,
553
+ blockers,
554
+ repairAttempts,
555
+ }),
556
+ };
557
+ }
558
+ const postRepairHead = await input.git.getHead(input.worktreePath);
559
+ if (postRepairHead !== preRepairHead) {
560
+ const reason = 'Completion report repair created or moved HEAD; repair sessions must not create local commits.';
561
+ const blockers = [runnerBlocker('publication-violation', reason, 'safety', 'none')];
562
+ repairAttempts.push({ ...repairAttemptBase, status: 'blocked', reason, blockers });
563
+ return {
564
+ kind: 'blocked',
565
+ result: blocked([reason], {
566
+ changedFiles,
567
+ commits: changeSet.commits,
568
+ blockers,
569
+ repairAttempts,
570
+ }),
571
+ };
572
+ }
573
+ const postChangeSet = await input.git.collectSessionChangeSet({
574
+ worktreePath: input.worktreePath,
575
+ baseHead: input.beforeHead,
576
+ });
577
+ const protectedAfter = await captureRepairProtectedFingerprint(input, postChangeSet);
578
+ if (protectedAfter !== protectedBefore) {
579
+ const reason = 'Completion report repair changed protected worktree content; repair may only write the completion report JSON.';
580
+ const blockers = [runnerBlocker('destructive-or-production-action', reason, 'safety', 'none')];
581
+ repairAttempts.push({ ...repairAttemptBase, status: 'blocked', reason, blockers });
582
+ return {
583
+ kind: 'blocked',
584
+ result: blocked([reason], {
585
+ changedFiles: postChangeSet.changedPaths,
586
+ commits: postChangeSet.commits,
587
+ blockers,
588
+ repairAttempts,
589
+ }),
590
+ };
591
+ }
592
+ const repairedReport = await readScopedCompletionReportDetailed(input.reportPath);
593
+ if (repairedReport.kind === 'missing') {
594
+ const reason = `${MISSING_COMPLETION_REPORT_REASON} Completion report repair did not write CODEX_ORCHESTRATOR_REPORT_FILE.`;
595
+ const blockers = [runnerBlocker('missing-completion-report', reason, 'completion-report', 'none')];
596
+ repairAttempts.push({ ...repairAttemptBase, status: 'blocked', reason, blockers });
597
+ return {
598
+ kind: 'blocked',
599
+ result: blocked([reason], {
600
+ changedFiles,
601
+ commits: changeSet.commits,
602
+ blockers,
603
+ repairAttempts,
604
+ }),
605
+ };
606
+ }
607
+ if (repairedReport.kind === 'invalid') {
608
+ const reason = `Completion report repair wrote an invalid report: ${repairedReport.message}`;
609
+ const blockers = [runnerBlocker('invalid-completion-report', reason, 'completion-report', 'none')];
610
+ repairAttempts.push({ ...repairAttemptBase, status: 'blocked', reason, blockers });
611
+ return {
612
+ kind: 'blocked',
613
+ result: blocked([reason], {
614
+ changedFiles,
615
+ commits: changeSet.commits,
616
+ blockers,
617
+ repairAttempts,
618
+ }),
619
+ };
620
+ }
621
+ repairAttempts.push({ ...repairAttemptBase, status: 'passed', reason: 'Completion report repair wrote a valid scoped report.' });
622
+ return { kind: 'valid', report: repairedReport.report };
623
+ }
624
+ async function maybeRunEvidenceReportRepair(input, context) {
625
+ const repairableBlockers = (context.reviewGate.blockers ?? [])
626
+ .filter((blocker) => blocker.repair === 'evidence'
627
+ && (blocker.key === 'missing-quality-gate-evidence' || blocker.key === 'risk-routing-policy'));
628
+ if (repairableBlockers.length === 0 || !input.reportRepair || !input.git.getHead) {
629
+ return { kind: 'not-eligible' };
630
+ }
631
+ const repairSessionId = `${input.reportRepair.sessionId}-evidence-repair`;
632
+ const promptPath = sessionPromptPath({
633
+ targetRoot: input.reportRepair.targetRoot,
634
+ config: input.config,
635
+ issueNumber: input.issue.number,
636
+ sessionId: repairSessionId,
637
+ });
638
+ const logPath = sessionLogPath({
639
+ targetRoot: input.reportRepair.targetRoot,
640
+ config: input.config,
641
+ issueNumber: input.issue.number,
642
+ sessionId: repairSessionId,
643
+ });
644
+ const repairAttemptBase = {
645
+ kind: 'evidence',
646
+ sessionId: repairSessionId,
647
+ promptPath,
648
+ reportPath: input.reportPath,
649
+ logPath,
650
+ };
651
+ const preRepairHead = await input.git.getHead(input.worktreePath);
652
+ const protectedBefore = await captureRepairProtectedFingerprint(input, context.changeSet);
653
+ const promptText = buildEvidenceReportRepairPrompt({
654
+ input,
655
+ changedFiles: context.changedFiles,
656
+ reviewGate: context.reviewGate,
657
+ validation: context.validation,
658
+ });
659
+ await writeDurablePrompt({
660
+ targetRoot: input.reportRepair.targetRoot,
661
+ config: input.config,
662
+ issueNumber: input.issue.number,
663
+ sessionId: repairSessionId,
664
+ promptText,
665
+ });
666
+ const isolatedHomePath = sessionCodexHomePath({
667
+ targetRoot: input.reportRepair.targetRoot,
668
+ sessionId: repairSessionId,
669
+ });
670
+ let repairResult;
671
+ try {
672
+ repairResult = await input.reportRepair.codexAdapter.run({
673
+ targetRoot: input.reportRepair.targetRoot,
674
+ config: input.config,
675
+ worktreePath: input.worktreePath,
676
+ promptPath,
677
+ promptText,
678
+ reportPath: input.reportPath,
679
+ isolatedHomePath,
680
+ issueNumber: input.issue.number,
681
+ sessionId: repairSessionId,
682
+ branchName: input.reportRepair.branchName,
683
+ phase: 'scoped-issue',
684
+ logPath,
685
+ env: {
686
+ CODEX_ORCHESTRATOR_REPAIR_MODE: 'evidence',
687
+ },
688
+ });
689
+ }
690
+ finally {
691
+ await cleanupSessionCodexHome(isolatedHomePath);
692
+ }
693
+ if (repairResult.exitCode !== 0) {
694
+ const reason = `Evidence repair failed: ${formatCodexExitReason(repairResult)}`;
695
+ const blockers = [runnerBlocker('missing-quality-gate-evidence', reason, 'review-gate', 'none')];
696
+ context.repairAttempts.push({ ...repairAttemptBase, status: 'blocked', reason, blockers });
697
+ return {
698
+ kind: 'blocked',
699
+ result: blocked([reason], {
700
+ changedFiles: context.changedFiles,
701
+ validation: context.validation,
702
+ skippedChecks: context.report.skippedChecks,
703
+ residualRisks: context.residualRisks,
704
+ commits: context.changeSet.commits,
705
+ acceptanceProofAttempt: context.acceptanceProofAttempt,
706
+ blockers,
707
+ repairAttempts: context.repairAttempts,
708
+ }),
709
+ };
710
+ }
711
+ const postRepairHead = await input.git.getHead(input.worktreePath);
712
+ if (postRepairHead !== preRepairHead) {
713
+ const reason = 'Evidence repair created or moved HEAD; repair sessions must not create local commits.';
714
+ const blockers = [runnerBlocker('publication-violation', reason, 'safety', 'none')];
715
+ context.repairAttempts.push({ ...repairAttemptBase, status: 'blocked', reason, blockers });
716
+ return {
717
+ kind: 'blocked',
718
+ result: blocked([reason], {
719
+ changedFiles: context.changedFiles,
720
+ validation: context.validation,
721
+ skippedChecks: context.report.skippedChecks,
722
+ residualRisks: context.residualRisks,
723
+ commits: context.changeSet.commits,
724
+ acceptanceProofAttempt: context.acceptanceProofAttempt,
725
+ blockers,
726
+ repairAttempts: context.repairAttempts,
727
+ }),
728
+ };
729
+ }
730
+ const postChangeSet = await input.git.collectSessionChangeSet({
731
+ worktreePath: input.worktreePath,
732
+ baseHead: input.beforeHead,
733
+ });
734
+ const protectedAfter = await captureRepairProtectedFingerprint(input, postChangeSet);
735
+ if (protectedAfter !== protectedBefore) {
736
+ const reason = 'Evidence repair changed protected worktree content; repair may only write corrected completion report evidence.';
737
+ const blockers = [runnerBlocker('destructive-or-production-action', reason, 'safety', 'none')];
738
+ context.repairAttempts.push({ ...repairAttemptBase, status: 'blocked', reason, blockers });
739
+ return {
740
+ kind: 'blocked',
741
+ result: blocked([reason], {
742
+ changedFiles: postChangeSet.changedPaths,
743
+ validation: context.validation,
744
+ skippedChecks: context.report.skippedChecks,
745
+ residualRisks: context.residualRisks,
746
+ commits: postChangeSet.commits,
747
+ acceptanceProofAttempt: context.acceptanceProofAttempt,
748
+ blockers,
749
+ repairAttempts: context.repairAttempts,
750
+ }),
751
+ };
752
+ }
753
+ const repairedRead = await readScopedCompletionReportDetailed(input.reportPath);
754
+ if (repairedRead.kind !== 'valid') {
755
+ const reason = repairedRead.kind === 'missing'
756
+ ? `${MISSING_COMPLETION_REPORT_REASON} Evidence repair did not write CODEX_ORCHESTRATOR_REPORT_FILE.`
757
+ : `Evidence repair wrote an invalid report: ${repairedRead.message}`;
758
+ const blockers = [runnerBlocker(repairedRead.kind === 'missing' ? 'missing-completion-report' : 'invalid-completion-report', reason, 'completion-report', 'none')];
759
+ context.repairAttempts.push({ ...repairAttemptBase, status: 'blocked', reason, blockers });
760
+ return {
761
+ kind: 'blocked',
762
+ result: blocked([reason], {
763
+ changedFiles: context.changedFiles,
764
+ validation: context.validation,
765
+ skippedChecks: context.report.skippedChecks,
766
+ residualRisks: context.residualRisks,
767
+ commits: context.changeSet.commits,
768
+ acceptanceProofAttempt: context.acceptanceProofAttempt,
769
+ blockers,
770
+ repairAttempts: context.repairAttempts,
771
+ }),
772
+ };
773
+ }
774
+ if (repairedRead.report.status !== context.report.status) {
775
+ const reason = 'Evidence repair changed completion report status; repair may only correct review-gate evidence.';
776
+ const blockers = [runnerBlocker('missing-quality-gate-evidence', reason, 'review-gate', 'none')];
777
+ context.repairAttempts.push({ ...repairAttemptBase, status: 'blocked', reason, blockers });
778
+ return {
779
+ kind: 'blocked',
780
+ result: blocked([reason], {
781
+ changedFiles: context.changedFiles,
782
+ validation: context.validation,
783
+ skippedChecks: context.report.skippedChecks,
784
+ residualRisks: context.residualRisks,
785
+ commits: context.changeSet.commits,
786
+ acceptanceProofAttempt: context.acceptanceProofAttempt,
787
+ blockers,
788
+ repairAttempts: context.repairAttempts,
789
+ }),
790
+ };
791
+ }
792
+ const externalValidation = context.validation.slice(context.report.validation.length);
793
+ const repairedReport = {
794
+ ...context.report,
795
+ ...repairedRead.report,
796
+ skippedChecks: applicableSkippedChecks(input, repairedRead.report, context.changedFiles),
797
+ artifacts: mergeArtifacts(repairedRead.report.artifacts, context.report.artifacts),
798
+ };
799
+ const repairedValidation = [...repairedReport.validation, ...externalValidation];
800
+ const repairedReviewGate = evaluateReviewGates({
801
+ config: input.config,
802
+ issue: input.issue,
803
+ changedFiles: context.changedFiles,
804
+ validation: repairedValidation,
805
+ skippedChecks: repairedReport.skippedChecks,
806
+ report: repairedReport,
807
+ worktreePath: input.worktreePath,
808
+ });
809
+ if (!repairedReviewGate.ok) {
810
+ const reason = `Evidence repair did not satisfy review gates: ${repairedReviewGate.reasons.join('; ')}`;
811
+ const blockers = repairedReviewGate.blockers ?? repairableBlockers;
812
+ context.repairAttempts.push({ ...repairAttemptBase, status: 'blocked', reason, blockers });
813
+ return {
814
+ kind: 'blocked',
815
+ result: blocked(repairedReviewGate.reasons, {
816
+ changedFiles: context.changedFiles,
817
+ validation: repairedValidation,
818
+ skippedChecks: repairedReport.skippedChecks,
819
+ residualRisks: context.residualRisks,
820
+ commits: context.changeSet.commits,
821
+ acceptanceProofAttempt: context.acceptanceProofAttempt,
822
+ blockers,
823
+ repairAttempts: context.repairAttempts,
824
+ }),
825
+ };
826
+ }
827
+ context.repairAttempts.push({ ...repairAttemptBase, status: 'passed', reason: 'Evidence repair wrote review-gate evidence that passed rerun review gates.' });
828
+ return {
829
+ kind: 'publish-ready',
830
+ report: repairedReport,
831
+ validation: repairedValidation,
832
+ reviewWarnings: repairedReviewGate.warnings,
313
833
  };
314
834
  }
835
+ async function captureRepairProtectedFingerprint(input, changeSet) {
836
+ const reportRelativePath = relativeReportPathInsideWorktree(input);
837
+ const entries = [];
838
+ for (const changedPath of changeSet.changedPaths) {
839
+ if (reportRelativePath && changedPath === reportRelativePath) {
840
+ continue;
841
+ }
842
+ const absolutePath = join(input.worktreePath, changedPath);
843
+ try {
844
+ const content = await readFile(absolutePath);
845
+ entries.push({
846
+ path: changedPath,
847
+ state: 'file',
848
+ sha256: createHash('sha256').update(content).digest('hex'),
849
+ });
850
+ }
851
+ catch (error) {
852
+ if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
853
+ entries.push({ path: changedPath, state: 'missing' });
854
+ continue;
855
+ }
856
+ throw error;
857
+ }
858
+ }
859
+ return JSON.stringify(entries);
860
+ }
861
+ function relativeReportPathInsideWorktree(input) {
862
+ const worktreePath = resolve(input.worktreePath);
863
+ const reportPath = resolve(input.reportPath);
864
+ const relativePath = relative(worktreePath, reportPath);
865
+ if (relativePath === '' || relativePath.startsWith('..') || isAbsolute(relativePath)) {
866
+ return undefined;
867
+ }
868
+ return relativePath.split(/[\\/]+/u).join('/');
869
+ }
870
+ function buildCompletionReportRepairPrompt(input) {
871
+ const issue = input.input.issue;
872
+ const reportIssueText = input.reportIssue.kind === 'missing'
873
+ ? 'The completion report is missing.'
874
+ : [
875
+ input.reportIssue.message,
876
+ ...input.reportIssue.errors,
877
+ input.reportIssue.rawContent ? `Raw invalid report content (truncated):\n${input.reportIssue.rawContent}` : undefined,
878
+ ].filter(Boolean).join('\n');
879
+ return [
880
+ '# Completion Report Repair',
881
+ '',
882
+ 'Repair only the completion report JSON at CODEX_ORCHESTRATOR_REPORT_FILE.',
883
+ 'Do not edit product files. Do not edit GitHub. Do not create commits.',
884
+ 'Use the changed files, issue text, schema errors/raw content, and validation/check evidence to write the corrected scoped completion report.',
885
+ 'Final response raw JSON only.',
886
+ '',
887
+ `Issue #${issue.number}: ${issue.title}`,
888
+ issue.body,
889
+ '',
890
+ 'Changed files:',
891
+ ...input.changedFiles.map((file) => `- ${file}`),
892
+ '',
893
+ 'Report problem:',
894
+ reportIssueText,
895
+ '',
896
+ 'Original workflow prompt:',
897
+ input.input.reportRepair?.workflowPromptText ?? '',
898
+ ].join('\n');
899
+ }
900
+ function buildEvidenceReportRepairPrompt(input) {
901
+ const issue = input.input.issue;
902
+ return [
903
+ '# Evidence Repair',
904
+ '',
905
+ 'Repair only missing review-gate evidence in the completion report JSON at CODEX_ORCHESTRATOR_REPORT_FILE.',
906
+ 'Do not edit product files. Do not edit GitHub. Do not create commits.',
907
+ 'Only correct completion report JSON evidence fields such as validation, skippedChecks, residualRisks, and reviewHandoff.',
908
+ 'You may run read-only review commands or review skills if needed, but final publication still depends on the runner rerunning gates.',
909
+ 'Final response raw JSON only.',
910
+ '',
911
+ `Issue #${issue.number}: ${issue.title}`,
912
+ issue.body,
913
+ '',
914
+ 'Changed files:',
915
+ ...input.changedFiles.map((file) => `- ${file}`),
916
+ '',
917
+ 'Review gate reasons to repair:',
918
+ ...input.reviewGate.reasons.map((reason) => `- ${reason}`),
919
+ '',
920
+ 'Typed blocker keys:',
921
+ ...(input.reviewGate.blockers ?? []).map((blocker) => `- ${blocker.key}: ${blocker.reason}`),
922
+ '',
923
+ 'Current validation evidence:',
924
+ ...input.validation.map((line) => `- ${line.command}: ${line.status} - ${line.summary}`),
925
+ '',
926
+ 'Original workflow prompt:',
927
+ input.input.reportRepair?.workflowPromptText ?? '',
928
+ ].join('\n');
929
+ }
930
+ function runnerBlocker(key, reason, source, repair) {
931
+ return { key, reason, source, repair };
932
+ }
315
933
  function applicableSkippedChecks(input, report, changedFiles) {
316
934
  if (shouldApplyVisualProofGate({ config: input.config, issue: input.issue, changedFiles })) {
317
935
  return report.skippedChecks;
@@ -352,15 +970,31 @@ function localSessionBlockReasons(phaseResults) {
352
970
  ];
353
971
  });
354
972
  }
355
- function blocked(reasons) {
356
- return {
973
+ function blocked(reasons, options = {}) {
974
+ const result = {
357
975
  status: 'blocked',
358
976
  reasons,
359
- changedFiles: [],
360
- skippedChecks: [],
361
- residualRisks: [],
362
- commits: [],
977
+ changedFiles: options.changedFiles ?? [],
978
+ skippedChecks: options.skippedChecks ?? [],
979
+ residualRisks: options.residualRisks ?? [],
980
+ commits: options.commits ?? [],
363
981
  };
982
+ if (options.validation) {
983
+ result.validation = options.validation;
984
+ }
985
+ if (options.blockers && options.blockers.length > 0) {
986
+ result.blockers = options.blockers;
987
+ }
988
+ if (options.repairAttempts && options.repairAttempts.length > 0) {
989
+ result.repairAttempts = options.repairAttempts;
990
+ }
991
+ if (options.acceptanceProofAttempt) {
992
+ result.acceptanceProofAttempt = options.acceptanceProofAttempt;
993
+ }
994
+ return result;
995
+ }
996
+ function repairAttemptsField(repairAttempts) {
997
+ return repairAttempts.length > 0 ? { repairAttempts } : {};
364
998
  }
365
999
  function formatCodexExitReason(result) {
366
1000
  const figmaReason = formatFigmaMcpFailureReason(result);
@@ -381,6 +1015,15 @@ function formatCodexExitReason(result) {
381
1015
  ? `Codex exited with code ${result.exitCode}: ${truncate(detail, 600)}`
382
1016
  : `Codex exited with code ${result.exitCode}`;
383
1017
  }
1018
+ function isIdleTimeoutResult(result) {
1019
+ if (result.exitCode !== 124) {
1020
+ return false;
1021
+ }
1022
+ return `${result.stderr}\n${result.stdout}`
1023
+ .split(/\r?\n/u)
1024
+ .map((line) => line.trim())
1025
+ .some((line) => /^Command idle timed out after [1-9][0-9]*ms\.$/u.test(line));
1026
+ }
384
1027
  function formatFigmaMcpFailureReason(result) {
385
1028
  if (result.exitCode === 0 || result.figmaMcp?.enabled !== true) {
386
1029
  return undefined;