@navels/neal 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (170) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +527 -0
  3. package/SECURITY.md +91 -0
  4. package/config.yml +104 -0
  5. package/dist/neal/activity-footer.js +177 -0
  6. package/dist/neal/activity-reporting.js +1 -0
  7. package/dist/neal/adjudicator/artifacts.js +58 -0
  8. package/dist/neal/adjudicator/blocked-adjudicator.js +223 -0
  9. package/dist/neal/adjudicator/contracts.js +139 -0
  10. package/dist/neal/adjudicator/execute.js +611 -0
  11. package/dist/neal/adjudicator/final-completion.js +104 -0
  12. package/dist/neal/adjudicator/planning.js +145 -0
  13. package/dist/neal/adjudicator/specs.js +453 -0
  14. package/dist/neal/agents/prompts.js +120 -0
  15. package/dist/neal/agents/rounds.js +706 -0
  16. package/dist/neal/agents/schemas.js +832 -0
  17. package/dist/neal/agents/structured-coder.js +82 -0
  18. package/dist/neal/agents/structured-json.js +528 -0
  19. package/dist/neal/agents.js +4 -0
  20. package/dist/neal/atomic-write.js +18 -0
  21. package/dist/neal/blocked-guidance.js +406 -0
  22. package/dist/neal/cli.js +471 -0
  23. package/dist/neal/commands/check.js +401 -0
  24. package/dist/neal/commands/compat.js +807 -0
  25. package/dist/neal/commands/interactive-activity.js +57 -0
  26. package/dist/neal/commands/new-run.js +79 -0
  27. package/dist/neal/commands/plan-and-execute.js +44 -0
  28. package/dist/neal/commands/recovery-guidance.js +217 -0
  29. package/dist/neal/commands/resume-run.js +395 -0
  30. package/dist/neal/commands/review.js +21 -0
  31. package/dist/neal/commands/runtime.js +557 -0
  32. package/dist/neal/commands/setup.js +596 -0
  33. package/dist/neal/commands/squash.js +113 -0
  34. package/dist/neal/commands/status.js +33 -0
  35. package/dist/neal/commands/writer-exit-codes.js +42 -0
  36. package/dist/neal/commit-message.js +17 -0
  37. package/dist/neal/config.js +432 -0
  38. package/dist/neal/context/artifacts.js +140 -0
  39. package/dist/neal/context/context.js +324 -0
  40. package/dist/neal/context/inline-review-context.js +131 -0
  41. package/dist/neal/context/reviewer-context.js +166 -0
  42. package/dist/neal/context/shared.js +117 -0
  43. package/dist/neal/context/types.js +1 -0
  44. package/dist/neal/diagnostic.js +208 -0
  45. package/dist/neal/execute-finalization.js +5 -0
  46. package/dist/neal/final-completion-review.js +188 -0
  47. package/dist/neal/final-completion.js +229 -0
  48. package/dist/neal/git.js +339 -0
  49. package/dist/neal/index.js +135 -0
  50. package/dist/neal/interactive-controls.js +85 -0
  51. package/dist/neal/logger.js +102 -0
  52. package/dist/neal/manual-gates.js +121 -0
  53. package/dist/neal/orchestrator/artifacts.js +70 -0
  54. package/dist/neal/orchestrator/completion.js +531 -0
  55. package/dist/neal/orchestrator/failures.js +31 -0
  56. package/dist/neal/orchestrator/notifications.js +175 -0
  57. package/dist/neal/orchestrator/phases/coder.js +516 -0
  58. package/dist/neal/orchestrator/phases/planning.js +540 -0
  59. package/dist/neal/orchestrator/phases/recovery.js +798 -0
  60. package/dist/neal/orchestrator/phases/review.js +136 -0
  61. package/dist/neal/orchestrator/phases/shared.js +279 -0
  62. package/dist/neal/orchestrator/run-loop.js +113 -0
  63. package/dist/neal/orchestrator/split-plan.js +235 -0
  64. package/dist/neal/orchestrator/transitions.js +309 -0
  65. package/dist/neal/orchestrator.js +215 -0
  66. package/dist/neal/phase-display.js +27 -0
  67. package/dist/neal/plan-doc.js +154 -0
  68. package/dist/neal/plan-queue.js +1092 -0
  69. package/dist/neal/plan-refinement.js +39 -0
  70. package/dist/neal/plan-validation.js +525 -0
  71. package/dist/neal/progress.js +237 -0
  72. package/dist/neal/prompts/assert-builder.js +13 -0
  73. package/dist/neal/prompts/execute.js +290 -0
  74. package/dist/neal/prompts/guidance.js +70 -0
  75. package/dist/neal/prompts/planning.js +313 -0
  76. package/dist/neal/prompts/review-doctrine.js +142 -0
  77. package/dist/neal/prompts/shared.js +101 -0
  78. package/dist/neal/prompts/specialized.js +212 -0
  79. package/dist/neal/prompts/specs.js +572 -0
  80. package/dist/neal/providers/anthropic-claude.js +1599 -0
  81. package/dist/neal/providers/detection.js +139 -0
  82. package/dist/neal/providers/generic-agentic-tools.js +586 -0
  83. package/dist/neal/providers/generic-agentic.js +1238 -0
  84. package/dist/neal/providers/liveness.js +151 -0
  85. package/dist/neal/providers/openai-codex.js +1014 -0
  86. package/dist/neal/providers/openai-compatible.js +654 -0
  87. package/dist/neal/providers/registry.js +389 -0
  88. package/dist/neal/providers/telemetry.js +208 -0
  89. package/dist/neal/providers/types.js +21 -0
  90. package/dist/neal/recovery-artifacts.js +50 -0
  91. package/dist/neal/resume-decision.js +220 -0
  92. package/dist/neal/resume-planner.js +265 -0
  93. package/dist/neal/retrospective.js +391 -0
  94. package/dist/neal/review-debt.js +18 -0
  95. package/dist/neal/review-findings/artifacts.js +173 -0
  96. package/dist/neal/review-findings/prompts.js +172 -0
  97. package/dist/neal/review-findings/provider.js +330 -0
  98. package/dist/neal/review-findings/run.js +373 -0
  99. package/dist/neal/review-findings/types.js +1 -0
  100. package/dist/neal/review-mode.js +67 -0
  101. package/dist/neal/review.js +137 -0
  102. package/dist/neal/run-lock.js +334 -0
  103. package/dist/neal/run-metrics.js +355 -0
  104. package/dist/neal/run-narrative-types.js +1 -0
  105. package/dist/neal/run-narrative.js +1374 -0
  106. package/dist/neal/run-registry.js +218 -0
  107. package/dist/neal/run-status.js +25 -0
  108. package/dist/neal/scopes.js +451 -0
  109. package/dist/neal/sensitive-text.js +8 -0
  110. package/dist/neal/squash-message.js +379 -0
  111. package/dist/neal/squash.js +591 -0
  112. package/dist/neal/state-invariants.js +496 -0
  113. package/dist/neal/state-views.js +344 -0
  114. package/dist/neal/state.js +887 -0
  115. package/dist/neal/status-footer.js +258 -0
  116. package/dist/neal/status.js +1260 -0
  117. package/dist/neal/storage-paths.js +57 -0
  118. package/dist/neal/support.js +58 -0
  119. package/dist/neal/terminal-narrator.js +435 -0
  120. package/dist/neal/types.js +1 -0
  121. package/dist/neal/verification-events.js +81 -0
  122. package/dist/neal/version.js +37 -0
  123. package/dist/neal/worktree-status.js +137 -0
  124. package/dist/notifier.js +44 -0
  125. package/docs/ADJUDICATOR_INVENTORY.md +310 -0
  126. package/docs/PROMPT_SPECS.md +266 -0
  127. package/docs/README.md +22 -0
  128. package/docs/architecture.md +113 -0
  129. package/docs/assets/neal-execution-flow.png +0 -0
  130. package/docs/automation.md +65 -0
  131. package/docs/comparison.md +105 -0
  132. package/docs/compat.md +269 -0
  133. package/docs/compatible-models.md +135 -0
  134. package/docs/demo.md +55 -0
  135. package/docs/maintenance.md +64 -0
  136. package/docs/plan-format.md +213 -0
  137. package/docs/providers.md +751 -0
  138. package/docs/release.md +147 -0
  139. package/docs/state-machine.md +266 -0
  140. package/docs/storage.md +207 -0
  141. package/docs/troubleshooting.md +152 -0
  142. package/examples/compat/add-edit-verify/PLAN.md +29 -0
  143. package/examples/compat/add-edit-verify/broken.diff +8 -0
  144. package/examples/compat/add-edit-verify/good.diff +8 -0
  145. package/examples/compat/add-edit-verify/package.json +5 -0
  146. package/examples/compat/add-edit-verify/src/add.js +2 -0
  147. package/examples/compat/add-edit-verify/test/add.test.js +9 -0
  148. package/examples/compat/is-even-add-test/PLAN.md +30 -0
  149. package/examples/compat/is-even-add-test/broken.diff +11 -0
  150. package/examples/compat/is-even-add-test/good.diff +11 -0
  151. package/examples/compat/is-even-add-test/package.json +5 -0
  152. package/examples/compat/is-even-add-test/src/is-even.js +3 -0
  153. package/examples/compat/is-even-add-test/test/is-even.test.js +9 -0
  154. package/examples/compat/manifest.json +60 -0
  155. package/examples/compat/plan-greeting/ISSUE.md +25 -0
  156. package/examples/compat/plan-greeting/package.json +5 -0
  157. package/examples/compat/plan-greeting/src/greet.js +2 -0
  158. package/examples/compat/plan-greeting/test/greet.test.js +8 -0
  159. package/examples/compat/reverse-grep-edit/PLAN.md +32 -0
  160. package/examples/compat/reverse-grep-edit/broken.diff +12 -0
  161. package/examples/compat/reverse-grep-edit/good.diff +12 -0
  162. package/examples/compat/reverse-grep-edit/package.json +5 -0
  163. package/examples/compat/reverse-grep-edit/src/strings.js +10 -0
  164. package/examples/compat/reverse-grep-edit/test/strings.test.js +15 -0
  165. package/examples/issue-triage-js/PLAN.md +83 -0
  166. package/examples/issue-triage-js/README.md +76 -0
  167. package/examples/issue-triage-js/package.json +9 -0
  168. package/examples/issue-triage-js/src/issue-triage.js +87 -0
  169. package/examples/issue-triage-js/test/issue-triage.test.js +107 -0
  170. package/package.json +70 -0
@@ -0,0 +1,798 @@
1
+ import { CoderRoundError, runBlockedRecoveryCoderRound, } from '../../agents.js';
2
+ import { ADJUDICATOR_ELIGIBLE_SOURCE_PHASES, buildRecentBlockCandidate, isReviewerAdjudicatorPhase, runBlockedAdjudicator, upsertRecentBlock, } from '../../adjudicator/blocked-adjudicator.js';
3
+ import { UNATTENDED_AUTO_RESUME_GUIDANCE } from '../../blocked-guidance.js';
4
+ import { getInteractiveBlockedRecoveryMaxTurns, getReviewStuckArbiterMaxAttempts } from '../../config.js';
5
+ import { EXECUTE_FINALIZATION_PHASE } from '../../execute-finalization.js';
6
+ import { hasPendingOperatorGuidance } from '../../run-status.js';
7
+ import { getExecutionPlanPath } from '../../scopes.js';
8
+ import { loadState, saveState } from '../../state.js';
9
+ import { isActivePendingDerivedPlanReview } from '../../state-views.js';
10
+ import { writeExecutionArtifacts } from '../artifacts.js';
11
+ import { isCoderTimeoutError, shouldNotifyFailure } from '../failures.js';
12
+ import { flushDerivedPlanNotifications, notifyBlocked } from '../notifications.js';
13
+ import { persistSplitPlanRecovery } from '../split-plan.js';
14
+ import { bestEffortCleanupTimedOutCoder, persistBlockedScope, persistCoderFailureState, persistUnattendedBlockUnresolvedFailure, scheduleCoderFreshSessionRetry, shouldRetryCoderWithFreshSession, } from './shared.js';
15
+ // Bounded number of synthesized conservative auto-resumes the execute-mode
16
+ // interactive-recovery chokepoint performs under `unattended` before it fails
17
+ // cleanly and terminally. Kept a module constant (not a config knob) and held
18
+ // at or below `interactive_blocked_recovery_max_turns` (default 3) so an
19
+ // auto-resume turn never pushes past the recovery turn cap. Revisit here if the
20
+ // unattended push proves too short or too long for headless runs.
21
+ export const UNATTENDED_MAX_AUTO_RESUMES = 2;
22
+ export class InteractiveBlockedRecoveryPendingTurnError extends Error {
23
+ pendingTurn;
24
+ constructor(pendingTurn) {
25
+ super(`Interactive blocked recovery already has unhandled operator guidance for turn ${pendingTurn}; resume the run before recording more guidance.`);
26
+ this.name = 'InteractiveBlockedRecoveryPendingTurnError';
27
+ this.pendingTurn = pendingTurn;
28
+ }
29
+ }
30
+ function getInteractiveBlockedRecoverySourcePhase(phase) {
31
+ switch (phase) {
32
+ case 'coder_plan':
33
+ case 'reviewer_plan':
34
+ case 'coder_plan_response':
35
+ case 'coder_plan_optional_response':
36
+ case 'awaiting_derived_plan_execution':
37
+ case 'coder_scope':
38
+ case 'reviewer_scope':
39
+ case 'coder_response':
40
+ case 'coder_optional_response':
41
+ case EXECUTE_FINALIZATION_PHASE:
42
+ case 'final_completion_review':
43
+ return phase;
44
+ default:
45
+ throw new Error(`Interactive blocked recovery does not support source phase: ${String(phase)}`);
46
+ }
47
+ }
48
+ function isInteractiveBlockedRecoveryTopLevelMode(state) {
49
+ return state.topLevelMode === 'execute';
50
+ }
51
+ // The generalized adjudicator triages two block classes whose source phase is in
52
+ // `ADJUDICATOR_ELIGIBLE_SOURCE_PHASES`:
53
+ // - a coder-blocked signal (`coder_scope`/`coder_response`/`coder_optional_response`,
54
+ // which also carries the rerouted split-plan invalid-payload block): the coder
55
+ // emits free-text blockers with no structural prefix, so ANY coder block on these
56
+ // phases is eligible;
57
+ // - a reviewer `review_stuck` deadlock (`reviewer_scope`/`reviewer_plan`): only the
58
+ // structural `review_stuck:` reason the review adjudicator emits for a genuine
59
+ // deadlock is eligible. Ordinary blocking-finding blocks that reach recovery via a
60
+ // reviewer phase are normal review back-and-forth, NOT a deadlock, so they keep
61
+ // today's generic recovery behavior.
62
+ // Every other accepted source phase (`coder_plan`, `coder_plan_response`,
63
+ // `coder_plan_optional_response`, `awaiting_derived_plan_execution`,
64
+ // `execute_finalization`, `final_completion_review`) is ineligible and keeps today's
65
+ // generic recovery behavior with zero adjudicator invocations.
66
+ function isAdjudicatorEligibleBlock(reason, sourcePhase) {
67
+ if (!ADJUDICATOR_ELIGIBLE_SOURCE_PHASES.has(sourcePhase)) {
68
+ return false;
69
+ }
70
+ if (isReviewerAdjudicatorPhase(sourcePhase)) {
71
+ return reason.startsWith('review_stuck:');
72
+ }
73
+ return true;
74
+ }
75
+ // Whether the per-scope adjudicator budget allows another invocation. Returns
76
+ // false when the disable knob is 0 or `reviewStuckArbiterCount` has reached the
77
+ // configured maximum for the current scope. Both the disabled and the
78
+ // budget-exhausted cases emit NO `review_stuck_arbiter.*` events so they preserve
79
+ // the generic recovery path byte-for-byte: a disabled or exhausted adjudicator
80
+ // must be indistinguishable from the adjudicator never having existed.
81
+ // The single uniform budget is shared by both run modes — an invocation
82
+ // consumes one unit whether it auto-acts (unattended) or only produces advice
83
+ // (attended) — and is reset to 0 at every scope boundary (see the scope-advance
84
+ // transitions and the split-plan persist) so one scope's adjudication never
85
+ // exhausts a later scope.
86
+ function isAdjudicatorBudgetAvailable(state) {
87
+ const maxAttempts = getReviewStuckArbiterMaxAttempts(state.cwd);
88
+ if (maxAttempts <= 0) {
89
+ return false;
90
+ }
91
+ return state.reviewStuckArbiterCount < maxAttempts;
92
+ }
93
+ // Unattended interception for every eligible block class. Runs the read-only
94
+ // adjudicator (which may itself short-circuit on an anti-thrash repeat) and, on a
95
+ // recoverable verdict with a concrete in-scope directive, enters interactive
96
+ // recovery with that directive injected as the pending turn — bounded by a
97
+ // SEPARATE counter (`reviewStuckArbiterCount`) that never touches
98
+ // `unattendedAutoResumeCount` or the recovery turn cap. A non-recoverable verdict
99
+ // (including a thrash repeat) is finalized TERMINALLY rather than silently
100
+ // auto-resumed. Every gate that prevents the adjudicator from running — an
101
+ // ineligible source phase, the disabled/exhausted budget, the turn cap, or an
102
+ // adjudicator error — returns null so the caller falls through to the existing
103
+ // generic auto-resume / terminal-fail path with `recentBlocks` left unchanged.
104
+ // The adjudicator itself makes zero commits and zero file edits; this function is
105
+ // the sole writer of `recentBlocks`, and only on the branches where the
106
+ // adjudicator actually ran.
107
+ async function maybeResolveBlockedUnattended(state, statePath, reason, sourcePhase, nextRecovery, logger) {
108
+ if (!isAdjudicatorEligibleBlock(reason, sourcePhase)) {
109
+ return null;
110
+ }
111
+ if (!isAdjudicatorBudgetAvailable(state)) {
112
+ return null;
113
+ }
114
+ // Never push past the recovery turn cap; if there is no room for a recovery
115
+ // turn, fall through to the generic bound check unchanged.
116
+ if (nextRecovery.turns.length >= nextRecovery.maxTurns) {
117
+ return null;
118
+ }
119
+ await logger?.event('review_stuck_arbiter.start', {
120
+ scopeNumber: state.currentScopeNumber,
121
+ sourcePhase,
122
+ blockedReason: reason,
123
+ });
124
+ let verdict;
125
+ try {
126
+ verdict = await runBlockedAdjudicator(state, reason, sourcePhase, logger);
127
+ }
128
+ catch (error) {
129
+ // An adjudicator failure must never crash the run or weaken existing recovery;
130
+ // record the decline and fall through to the generic path with `recentBlocks`
131
+ // unchanged (the adjudicator did not complete for this block).
132
+ await logger?.event('review_stuck_arbiter.declined', {
133
+ scopeNumber: state.currentScopeNumber,
134
+ sourcePhase,
135
+ blockedReason: reason,
136
+ error: error instanceof Error ? error.message : String(error),
137
+ });
138
+ return null;
139
+ }
140
+ // The adjudicator ran (possibly short-circuiting internally on a thrash repeat),
141
+ // so this block is recorded in the anti-thrash window regardless of the verdict.
142
+ // The candidate is built from the PRE-update array and written in this same
143
+ // transition, so a block can never match itself. It MUST be built from the same
144
+ // `state` snapshot `runBlockedAdjudicator` checked: the candidate's evidence
145
+ // fingerprint is derived from `state.createdCommits`, and a divergent snapshot
146
+ // would record a fingerprint the guard never compared against.
147
+ const candidate = buildRecentBlockCandidate(state, reason, sourcePhase);
148
+ const recentBlocks = upsertRecentBlock(state.recentBlocks, candidate);
149
+ const resolutionDirective = verdict.resolutionDirective.trim();
150
+ if (!verdict.recoverable || !resolutionDirective) {
151
+ // Genuine wall (recoverable:false) or a thrash repeat: an unattended run has
152
+ // no operator to escalate to, so finalize TERMINALLY instead of synthesizing a
153
+ // generic auto-resume. The adjudicator actually ran, so this branch consumes
154
+ // one unit of the shared per-scope budget (`reviewStuckArbiterCount`) exactly
155
+ // like the recoverable branch, and persists the anti-thrash record via the
156
+ // threaded `recentBlocks`. Only the fallback paths where `runBlockedAdjudicator`
157
+ // was never invoked leave the budget untouched.
158
+ await logger?.event('review_stuck_arbiter.declined', {
159
+ scopeNumber: state.currentScopeNumber,
160
+ sourcePhase,
161
+ blockedReason: reason,
162
+ recoverable: verdict.recoverable,
163
+ triageCategory: verdict.triageCategory,
164
+ reviewStuckArbiterCount: state.reviewStuckArbiterCount + 1,
165
+ });
166
+ return failUnattendedRecoveryTerminally({ ...state, recentBlocks, reviewStuckArbiterCount: state.reviewStuckArbiterCount + 1 }, statePath, 'terminal_block', state.coderSessionHandle, logger);
167
+ }
168
+ await logger?.event('review_stuck_arbiter.verdict', {
169
+ scopeNumber: state.currentScopeNumber,
170
+ sourcePhase,
171
+ blockedReason: reason,
172
+ recoverable: verdict.recoverable,
173
+ triageCategory: verdict.triageCategory,
174
+ targetCanonicalIds: verdict.targetCanonicalIds,
175
+ // Report the post-increment count this verdict is about to consume so the
176
+ // verdict and the later `resolved` event agree on the budget figure.
177
+ reviewStuckArbiterCount: state.reviewStuckArbiterCount + 1,
178
+ });
179
+ // Enter recovery exactly as the generic unattended path does, but increment
180
+ // the SEPARATE adjudicator counter (never `unattendedAutoResumeCount`) and
181
+ // persist the anti-thrash record.
182
+ const enteredState = await saveState(statePath, {
183
+ ...state,
184
+ recentBlocks,
185
+ phase: 'interactive_blocked_recovery',
186
+ status: 'running',
187
+ blockedFromPhase: state.blockedFromPhase ?? state.phase,
188
+ reviewStuckArbiterCount: state.reviewStuckArbiterCount + 1,
189
+ interactiveBlockedRecovery: {
190
+ ...nextRecovery,
191
+ sourcePhase,
192
+ blockedReason: reason,
193
+ },
194
+ });
195
+ await writeExecutionArtifacts(enteredState);
196
+ await logger?.event('interactive_blocked_recovery.entered', {
197
+ scopeNumber: enteredState.currentScopeNumber,
198
+ sourcePhase: enteredState.interactiveBlockedRecovery?.sourcePhase,
199
+ blockedReason: reason,
200
+ });
201
+ // Inject the adjudicator's in-scope directive as the pending recovery turn so
202
+ // the coder consumes it in runInteractiveBlockedRecoveryPhase, exactly like a
203
+ // human-supplied `neal resume --message`.
204
+ const resolvedState = await recordInteractiveBlockedRecoveryGuidance(statePath, resolutionDirective, logger);
205
+ await logger?.event('review_stuck_arbiter.resolved', {
206
+ scopeNumber: resolvedState.currentScopeNumber,
207
+ sourcePhase,
208
+ blockedReason: reason,
209
+ recoverable: verdict.recoverable,
210
+ triageCategory: verdict.triageCategory,
211
+ targetCanonicalIds: verdict.targetCanonicalIds,
212
+ reviewStuckArbiterCount: resolvedState.reviewStuckArbiterCount,
213
+ });
214
+ return resolvedState;
215
+ }
216
+ // Attended interception for every eligible block class. The adjudicator NEVER
217
+ // auto-applies its verdict in attended mode; instead it triages read-only and the
218
+ // verdict is returned as advice (plus the updated anti-thrash window) for the
219
+ // caller to persist alongside the operator yield. Gated by the SAME eligibility +
220
+ // disable knob + per-scope budget as the unattended path; when any gate blocks the
221
+ // adjudicator (ineligible phase, knob 0, exhausted budget, or an adjudicator
222
+ // error) this returns null and the caller yields exactly as today with no advice,
223
+ // no budget consumption, and `recentBlocks` unchanged.
224
+ //
225
+ // The adjudicator runs BEFORE any `review_stuck_arbiter.*` event is emitted, so an
226
+ // adjudicator error degrades to today's plain attended yield with a byte-for-byte
227
+ // generic observable surface: zero adjudicator events, no advice, and no
228
+ // counter/`recentBlocks` mutation. The `start`/`verdict` audit pair is emitted only
229
+ // once the adjudicator has actually produced a verdict for this block.
230
+ async function buildAttendedAdjudicatorAdvice(state, reason, sourcePhase, logger) {
231
+ if (!isAdjudicatorEligibleBlock(reason, sourcePhase)) {
232
+ return null;
233
+ }
234
+ if (!isAdjudicatorBudgetAvailable(state)) {
235
+ return null;
236
+ }
237
+ let verdict;
238
+ try {
239
+ verdict = await runBlockedAdjudicator(state, reason, sourcePhase, logger);
240
+ }
241
+ catch {
242
+ // Degrade to today's plain attended yield: never crash the run, and emit NO
243
+ // `review_stuck_arbiter.*` events so the fallback is indistinguishable from the
244
+ // disabled/exhausted/ineligible generic yield.
245
+ return null;
246
+ }
247
+ await logger?.event('review_stuck_arbiter.start', {
248
+ scopeNumber: state.currentScopeNumber,
249
+ sourcePhase,
250
+ blockedReason: reason,
251
+ });
252
+ await logger?.event('review_stuck_arbiter.verdict', {
253
+ scopeNumber: state.currentScopeNumber,
254
+ sourcePhase,
255
+ blockedReason: reason,
256
+ recoverable: verdict.recoverable,
257
+ triageCategory: verdict.triageCategory,
258
+ targetCanonicalIds: verdict.targetCanonicalIds,
259
+ reviewStuckArbiterCount: state.reviewStuckArbiterCount + 1,
260
+ });
261
+ // Same-snapshot rule as the unattended writer: the recorded candidate's
262
+ // commit-trail evidence fingerprint must come from the `state` the adjudicator
263
+ // just checked, never a fresher snapshot.
264
+ const candidate = buildRecentBlockCandidate(state, reason, sourcePhase);
265
+ const recentBlocks = upsertRecentBlock(state.recentBlocks, candidate);
266
+ const advice = {
267
+ recordedAt: new Date().toISOString(),
268
+ recoverable: verdict.recoverable,
269
+ triageCategory: verdict.triageCategory,
270
+ resolutionDirective: verdict.resolutionDirective,
271
+ rationale: verdict.rationale,
272
+ };
273
+ return { advice, recentBlocks };
274
+ }
275
+ export async function enterInteractiveBlockedRecovery(state, statePath, reason, logger) {
276
+ if (!isInteractiveBlockedRecoveryTopLevelMode(state)) {
277
+ throw new Error('Interactive blocked recovery is only supported for execute-mode runs');
278
+ }
279
+ const sourcePhase = getInteractiveBlockedRecoverySourcePhase(state.blockedFromPhase ?? state.phase);
280
+ const nextRecovery = state.interactiveBlockedRecovery ?? {
281
+ enteredAt: new Date().toISOString(),
282
+ sourcePhase,
283
+ blockedReason: reason,
284
+ // Keep the operator/coder loop short so recovery remains bounded and auditable.
285
+ maxTurns: getInteractiveBlockedRecoveryMaxTurns(state.cwd),
286
+ lastHandledTurn: 0,
287
+ pendingDirective: null,
288
+ turns: [],
289
+ };
290
+ // Unattended runs have no operator to answer, so instead of yielding-and-halting
291
+ // we either synthesize a conservative auto-resume turn (bounded) or, past the
292
+ // bound, run the shared terminal-fail action. Gate purely on structural state:
293
+ // the persisted counter and the recovery turn cap, never on guidance text.
294
+ if (state.unattended) {
295
+ // Before the generic auto-resume/terminal-fail decision, give the bounded
296
+ // read-only adjudicator a chance to triage the block: autonomously resolve it
297
+ // with an in-scope directive (recoverable) or finalize terminally (a genuine
298
+ // wall or thrash repeat). Any ineligible source phase, disabled/exhausted
299
+ // budget, turn cap, or adjudicator error falls through to the existing generic
300
+ // behavior unchanged.
301
+ const adjudicatorResolved = await maybeResolveBlockedUnattended(state, statePath, reason, sourcePhase, nextRecovery, logger);
302
+ if (adjudicatorResolved) {
303
+ return adjudicatorResolved;
304
+ }
305
+ const canAutoResume = state.unattendedAutoResumeCount < UNATTENDED_MAX_AUTO_RESUMES &&
306
+ nextRecovery.turns.length < nextRecovery.maxTurns;
307
+ if (!canAutoResume) {
308
+ // Past the bound, finalize any active recovery record into history and land
309
+ // on a terminal failed shape (status:'failed', phase:'blocked',
310
+ // interactiveBlockedRecovery:null) so the run is never persisted as an
311
+ // active/waiting recovery. Reuses the same finalizer as the disposition
312
+ // terminal-fail paths.
313
+ return failUnattendedRecoveryTerminally(state, statePath, 'terminal_block', state.coderSessionHandle, logger);
314
+ }
315
+ const enteredState = await saveState(statePath, {
316
+ ...state,
317
+ phase: 'interactive_blocked_recovery',
318
+ status: 'running',
319
+ blockedFromPhase: state.blockedFromPhase ?? state.phase,
320
+ unattendedAutoResumeCount: state.unattendedAutoResumeCount + 1,
321
+ interactiveBlockedRecovery: {
322
+ ...nextRecovery,
323
+ sourcePhase,
324
+ blockedReason: reason,
325
+ },
326
+ });
327
+ await writeExecutionArtifacts(enteredState);
328
+ await logger?.event('interactive_blocked_recovery.entered', {
329
+ scopeNumber: enteredState.currentScopeNumber,
330
+ sourcePhase: enteredState.interactiveBlockedRecovery?.sourcePhase,
331
+ blockedReason: reason,
332
+ });
333
+ await logger?.event('interactive_blocked_recovery.unattended_auto_resume', {
334
+ scopeNumber: enteredState.currentScopeNumber,
335
+ sourcePhase,
336
+ autoResumeCount: enteredState.unattendedAutoResumeCount,
337
+ maxAutoResumes: UNATTENDED_MAX_AUTO_RESUMES,
338
+ });
339
+ // Reuse the turn-recording helper so the synthesized guidance turn satisfies
340
+ // the same invariants a human-supplied `neal resume --message` would; the run
341
+ // loop then proceeds into runInteractiveBlockedRecoveryPhase to consume it.
342
+ return recordInteractiveBlockedRecoveryGuidance(statePath, UNATTENDED_AUTO_RESUME_GUIDANCE, logger);
343
+ }
344
+ // Attended runs still stop and wait for the operator; the adjudicator only adds
345
+ // its verdict as advice (and never auto-applies it). When the disable knob,
346
+ // budget, or eligibility gate the adjudicator off, this returns null and the run
347
+ // yields exactly as today with no advice, no budget consumption, and
348
+ // `recentBlocks` unchanged.
349
+ const advisory = await buildAttendedAdjudicatorAdvice(state, reason, sourcePhase, logger);
350
+ const nextState = await saveState(statePath, {
351
+ ...state,
352
+ ...(advisory
353
+ ? { recentBlocks: advisory.recentBlocks, reviewStuckArbiterCount: state.reviewStuckArbiterCount + 1 }
354
+ : {}),
355
+ phase: 'interactive_blocked_recovery',
356
+ status: 'running',
357
+ blockedFromPhase: state.blockedFromPhase ?? state.phase,
358
+ interactiveBlockedRecovery: {
359
+ ...nextRecovery,
360
+ sourcePhase,
361
+ blockedReason: reason,
362
+ ...(advisory ? { adjudicatorAdvice: advisory.advice } : {}),
363
+ },
364
+ });
365
+ await writeExecutionArtifacts(nextState);
366
+ await logger?.event('interactive_blocked_recovery.entered', {
367
+ scopeNumber: nextState.currentScopeNumber,
368
+ sourcePhase: nextState.interactiveBlockedRecovery?.sourcePhase,
369
+ blockedReason: reason,
370
+ });
371
+ return nextState;
372
+ }
373
+ // Whether a caller of `enterInteractiveBlockedRecovery` should emit an attended
374
+ // blocked / interactive-recovery notification for the returned state. Only
375
+ // attended runs leave a pending operator block here; unattended runs instead
376
+ // auto-resume (status:'running' with synthesized guidance) or terminally fail
377
+ // (status:'failed', phase:'blocked'), neither of which warrants an attended
378
+ // notification. Gate structurally on the persisted flag and resulting phase,
379
+ // never on text.
380
+ export function shouldNotifyInteractiveBlockedRecoveryEntry(state) {
381
+ return !state.unattended && state.phase === 'interactive_blocked_recovery';
382
+ }
383
+ export async function recordInteractiveBlockedRecoveryGuidance(statePath, operatorGuidance, logger) {
384
+ const trimmedGuidance = operatorGuidance.trim();
385
+ if (!trimmedGuidance) {
386
+ throw new Error('Recovery guidance must not be empty');
387
+ }
388
+ const state = await loadState(statePath);
389
+ if (state.phase !== 'interactive_blocked_recovery' || !state.interactiveBlockedRecovery) {
390
+ throw new Error(`Run is not in interactive blocked recovery: ${statePath}`);
391
+ }
392
+ const turns = state.interactiveBlockedRecovery.turns;
393
+ if (state.interactiveBlockedRecovery.pendingDirective) {
394
+ throw new InteractiveBlockedRecoveryPendingTurnError(turns.length + 1);
395
+ }
396
+ const pendingTurn = turns.at(-1);
397
+ if (pendingTurn && pendingTurn.number > state.interactiveBlockedRecovery.lastHandledTurn) {
398
+ throw new InteractiveBlockedRecoveryPendingTurnError(pendingTurn.number);
399
+ }
400
+ if (turns.length >= state.interactiveBlockedRecovery.maxTurns) {
401
+ const nextState = await saveState(statePath, {
402
+ ...state,
403
+ interactiveBlockedRecovery: {
404
+ ...state.interactiveBlockedRecovery,
405
+ pendingDirective: {
406
+ recordedAt: new Date().toISOString(),
407
+ operatorGuidance: trimmedGuidance,
408
+ terminalOnly: true,
409
+ },
410
+ },
411
+ });
412
+ await writeExecutionArtifacts(nextState);
413
+ await logger?.event('interactive_blocked_recovery.terminal_directive_recorded', {
414
+ scopeNumber: nextState.currentScopeNumber,
415
+ sourcePhase: nextState.interactiveBlockedRecovery?.sourcePhase,
416
+ recoveryTurn: turns.length,
417
+ });
418
+ return nextState;
419
+ }
420
+ const nextState = await saveState(statePath, {
421
+ ...state,
422
+ interactiveBlockedRecovery: {
423
+ ...state.interactiveBlockedRecovery,
424
+ turns: [
425
+ ...turns,
426
+ {
427
+ number: turns.length + 1,
428
+ recordedAt: new Date().toISOString(),
429
+ operatorGuidance: trimmedGuidance,
430
+ disposition: null,
431
+ },
432
+ ],
433
+ },
434
+ });
435
+ await writeExecutionArtifacts(nextState);
436
+ await logger?.event('interactive_blocked_recovery.guidance_recorded', {
437
+ scopeNumber: nextState.currentScopeNumber,
438
+ recoveryTurn: nextState.interactiveBlockedRecovery?.turns.at(-1)?.number,
439
+ sourcePhase: nextState.interactiveBlockedRecovery?.sourcePhase,
440
+ });
441
+ return nextState;
442
+ }
443
+ export function isResumableBlockedPhase(phase) {
444
+ return (phase === 'coder_scope' ||
445
+ phase === 'coder_response' ||
446
+ phase === 'coder_optional_response' ||
447
+ phase === 'coder_plan' ||
448
+ phase === 'coder_plan_response' ||
449
+ phase === 'coder_plan_optional_response');
450
+ }
451
+ export function hasPendingInteractiveBlockedRecoveryTurn(state) {
452
+ return hasPendingOperatorGuidance(state);
453
+ }
454
+ function withRecordedInteractiveBlockedRecoveryDisposition(state, disposition, sessionHandle, resultingPhase) {
455
+ if (!state.interactiveBlockedRecovery) {
456
+ return state;
457
+ }
458
+ if (state.interactiveBlockedRecovery.pendingDirective) {
459
+ return {
460
+ ...state,
461
+ interactiveBlockedRecovery: {
462
+ ...state.interactiveBlockedRecovery,
463
+ pendingDirective: null,
464
+ turns: [
465
+ ...state.interactiveBlockedRecovery.turns,
466
+ {
467
+ number: state.interactiveBlockedRecovery.turns.length + 1,
468
+ recordedAt: state.interactiveBlockedRecovery.pendingDirective.recordedAt,
469
+ operatorGuidance: state.interactiveBlockedRecovery.pendingDirective.operatorGuidance,
470
+ disposition: {
471
+ recordedAt: new Date().toISOString(),
472
+ sessionHandle,
473
+ action: disposition.action,
474
+ summary: disposition.summary,
475
+ rationale: disposition.rationale,
476
+ blocker: disposition.blocker.trim(),
477
+ replacementPlan: disposition.replacementPlan.trim(),
478
+ resultingPhase,
479
+ },
480
+ },
481
+ ],
482
+ },
483
+ };
484
+ }
485
+ const latestTurn = state.interactiveBlockedRecovery.turns.at(-1);
486
+ if (!latestTurn) {
487
+ return state;
488
+ }
489
+ return {
490
+ ...state,
491
+ interactiveBlockedRecovery: {
492
+ ...state.interactiveBlockedRecovery,
493
+ turns: state.interactiveBlockedRecovery.turns.map((turn) => turn.number === latestTurn.number
494
+ ? {
495
+ ...turn,
496
+ disposition: {
497
+ recordedAt: new Date().toISOString(),
498
+ sessionHandle,
499
+ action: disposition.action,
500
+ summary: disposition.summary,
501
+ rationale: disposition.rationale,
502
+ blocker: disposition.blocker.trim(),
503
+ replacementPlan: disposition.replacementPlan.trim(),
504
+ resultingPhase,
505
+ },
506
+ }
507
+ : turn),
508
+ },
509
+ };
510
+ }
511
+ function finalizeInteractiveBlockedRecovery(state, action, resultPhase) {
512
+ if (!state.interactiveBlockedRecovery) {
513
+ return state;
514
+ }
515
+ return {
516
+ ...state,
517
+ interactiveBlockedRecovery: null,
518
+ interactiveBlockedRecoveryHistory: [
519
+ ...state.interactiveBlockedRecoveryHistory,
520
+ {
521
+ ...state.interactiveBlockedRecovery,
522
+ pendingDirective: null,
523
+ resolvedAt: new Date().toISOString(),
524
+ resolvedByAction: action,
525
+ resultPhase,
526
+ },
527
+ ],
528
+ };
529
+ }
530
+ async function persistFinalizedInteractiveBlockedRecovery(state, statePath, disposition, sessionHandle, resultPhase) {
531
+ const finalizedState = finalizeInteractiveBlockedRecovery(withRecordedInteractiveBlockedRecoveryDisposition(state, disposition, sessionHandle, resultPhase), disposition.action, resultPhase);
532
+ const nextState = await saveState(statePath, {
533
+ ...finalizedState,
534
+ phase: resultPhase,
535
+ status: resultPhase === 'blocked' ? 'blocked' : 'running',
536
+ blockedFromPhase: resultPhase === 'blocked' ? state.interactiveBlockedRecovery?.sourcePhase ?? state.blockedFromPhase : null,
537
+ });
538
+ await writeExecutionArtifacts(nextState);
539
+ return nextState;
540
+ }
541
+ function getInteractiveBlockedRecoveryResumePhase(sourcePhase) {
542
+ switch (sourcePhase) {
543
+ case 'reviewer_scope':
544
+ return 'coder_response';
545
+ case 'reviewer_plan':
546
+ return 'coder_plan_response';
547
+ case 'awaiting_derived_plan_execution':
548
+ return 'coder_scope';
549
+ default:
550
+ return sourcePhase;
551
+ }
552
+ }
553
+ // Shared unattended terminal-fail for a recovery disposition. The active
554
+ // recovery record is finalized into history (so `interactiveBlockedRecovery`
555
+ // becomes null and the lifecycle view is no longer "active"/waiting — required
556
+ // because the state invariant ties a non-null record to the recovery phase),
557
+ // the run lands on the recovery's source phase for diagnostics, and the shared
558
+ // classified terminal-fail action runs (status:'failed', no `notifyBlocked`).
559
+ // `state` must carry the disposition already recorded on its latest turn.
560
+ async function failUnattendedRecoveryTerminally(state, statePath, action, sessionHandle, logger) {
561
+ const recovery = state.interactiveBlockedRecovery;
562
+ const sourcePhase = recovery?.sourcePhase ?? state.blockedFromPhase ?? state.phase;
563
+ // The history record's resultPhase must satisfy the disposition-result invariant
564
+ // (stay_blocked -> recovery, terminal_block/replace -> blocked); the run itself
565
+ // lands on the terminal `blocked` phase with status:'failed', which keeps the
566
+ // lifecycle view out of any waiting/active recovery state.
567
+ const historyResultPhase = action === 'stay_blocked' ? 'interactive_blocked_recovery' : 'blocked';
568
+ await logger?.event('interactive_blocked_recovery.unattended_terminal_fail', {
569
+ scopeNumber: state.currentScopeNumber,
570
+ sourcePhase,
571
+ autoResumeCount: state.unattendedAutoResumeCount,
572
+ maxAutoResumes: UNATTENDED_MAX_AUTO_RESUMES,
573
+ });
574
+ const finalized = recovery ? finalizeInteractiveBlockedRecovery(state, action, historyResultPhase) : state;
575
+ return persistUnattendedBlockUnresolvedFailure({
576
+ ...finalized,
577
+ phase: 'blocked',
578
+ blockedFromPhase: sourcePhase,
579
+ coderSessionHandle: sessionHandle,
580
+ coderSessionProtocol: sessionHandle ? state.coderSessionProtocol : null,
581
+ coderRetryCount: 0,
582
+ }, statePath, 'interactive_blocked_recovery', logger);
583
+ }
584
+ // Under `unattended`, a recovery disposition that would otherwise leave the run
585
+ // waiting for an operator (`stay_blocked`) is resolved structurally: synthesize
586
+ // another conservative auto-resume turn while still under the persisted
587
+ // auto-resume cap and the recovery turn cap, otherwise run the shared
588
+ // terminal-fail action. `state` must already be persisted in
589
+ // `interactive_blocked_recovery` with its recovery record holding the handled
590
+ // turns. Gates only on the persisted counter and turn cap, never on text.
591
+ async function continueOrTerminateUnattendedRecovery(state, statePath, sessionHandle, logger) {
592
+ const recovery = state.interactiveBlockedRecovery;
593
+ const canAutoResume = !!recovery &&
594
+ state.unattendedAutoResumeCount < UNATTENDED_MAX_AUTO_RESUMES &&
595
+ recovery.turns.length < recovery.maxTurns;
596
+ if (!canAutoResume) {
597
+ return failUnattendedRecoveryTerminally(state, statePath, 'stay_blocked', sessionHandle, logger);
598
+ }
599
+ const incremented = await saveState(statePath, {
600
+ ...state,
601
+ unattendedAutoResumeCount: state.unattendedAutoResumeCount + 1,
602
+ });
603
+ await writeExecutionArtifacts(incremented);
604
+ await logger?.event('interactive_blocked_recovery.unattended_auto_resume', {
605
+ scopeNumber: incremented.currentScopeNumber,
606
+ sourcePhase: recovery.sourcePhase,
607
+ autoResumeCount: incremented.unattendedAutoResumeCount,
608
+ maxAutoResumes: UNATTENDED_MAX_AUTO_RESUMES,
609
+ });
610
+ return recordInteractiveBlockedRecoveryGuidance(statePath, UNATTENDED_AUTO_RESUME_GUIDANCE, logger);
611
+ }
612
+ export async function applyInteractiveBlockedRecoveryDisposition(state, statePath, disposition, sessionHandle, logger) {
613
+ if (state.phase !== 'interactive_blocked_recovery' || !state.interactiveBlockedRecovery) {
614
+ throw new Error(`Run is not in interactive blocked recovery: ${statePath}`);
615
+ }
616
+ if (!isInteractiveBlockedRecoveryTopLevelMode(state)) {
617
+ throw new Error('Interactive blocked recovery is only supported for execute-mode runs');
618
+ }
619
+ const latestTurn = state.interactiveBlockedRecovery.turns.at(-1);
620
+ const terminalDirective = state.interactiveBlockedRecovery.pendingDirective;
621
+ if (!latestTurn && !terminalDirective) {
622
+ throw new Error('Interactive blocked recovery requires recorded operator guidance before a coder response can be applied.');
623
+ }
624
+ if (terminalDirective &&
625
+ disposition.action !== 'replace_current_scope' &&
626
+ disposition.action !== 'terminal_block') {
627
+ throw new Error('Interactive blocked recovery reached its turn cap and now only allows replace_current_scope or terminal_block.');
628
+ }
629
+ const turnNumber = latestTurn?.number ?? state.interactiveBlockedRecovery.turns.length + 1;
630
+ const trimmedBlocker = disposition.blocker.trim();
631
+ await logger?.event('interactive_blocked_recovery.disposition', {
632
+ scopeNumber: state.currentScopeNumber,
633
+ recoveryTurn: turnNumber,
634
+ sourcePhase: state.interactiveBlockedRecovery.sourcePhase,
635
+ action: disposition.action,
636
+ sessionHandle,
637
+ });
638
+ if (disposition.action === 'replace_current_scope') {
639
+ const persistedState = await persistSplitPlanRecovery({
640
+ ...state,
641
+ coderSessionHandle: sessionHandle,
642
+ coderSessionProtocol: sessionHandle ? state.coderSessionProtocol : null,
643
+ status: 'running',
644
+ coderRetryCount: 0,
645
+ }, statePath, {
646
+ sourcePhase: state.interactiveBlockedRecovery.sourcePhase,
647
+ derivedPlanMarkdown: disposition.replacementPlan.trim(),
648
+ createdCommits: [],
649
+ logger,
650
+ }, {
651
+ persistBlockedScope,
652
+ writeExecutionArtifacts,
653
+ });
654
+ const resultPhase = persistedState.phase === 'blocked' ? 'blocked' : 'reviewer_plan';
655
+ if (state.unattended && resultPhase === 'blocked') {
656
+ // An unattended replacement that could not produce a runnable plan must not
657
+ // leave the run resumable-blocked; finalize recovery and fail cleanly.
658
+ return failUnattendedRecoveryTerminally({
659
+ ...persistedState,
660
+ interactiveBlockedRecovery: withRecordedInteractiveBlockedRecoveryDisposition({ ...persistedState, interactiveBlockedRecovery: state.interactiveBlockedRecovery }, disposition, sessionHandle, 'blocked').interactiveBlockedRecovery,
661
+ }, statePath, 'replace_current_scope', sessionHandle, logger);
662
+ }
663
+ return persistFinalizedInteractiveBlockedRecovery({
664
+ ...persistedState,
665
+ interactiveBlockedRecovery: state.interactiveBlockedRecovery,
666
+ }, statePath, disposition, sessionHandle, resultPhase);
667
+ }
668
+ if (disposition.action === 'resume_current_scope') {
669
+ const resumedPhase = getInteractiveBlockedRecoveryResumePhase(state.interactiveBlockedRecovery.sourcePhase);
670
+ const finalizedState = await persistFinalizedInteractiveBlockedRecovery(state, statePath, disposition, sessionHandle, resumedPhase);
671
+ const nextState = await saveState(statePath, {
672
+ ...finalizedState,
673
+ coderSessionHandle: sessionHandle,
674
+ coderSessionProtocol: sessionHandle ? state.coderSessionProtocol : null,
675
+ phase: resumedPhase,
676
+ status: 'running',
677
+ blockedFromPhase: null,
678
+ coderRetryCount: 0,
679
+ });
680
+ await writeExecutionArtifacts(nextState);
681
+ return nextState;
682
+ }
683
+ if (disposition.action === 'stay_blocked') {
684
+ const recordedState = withRecordedInteractiveBlockedRecoveryDisposition(state, disposition, sessionHandle, 'interactive_blocked_recovery');
685
+ if (!recordedState.interactiveBlockedRecovery) {
686
+ throw new Error('Interactive blocked recovery state disappeared while recording a stay_blocked disposition.');
687
+ }
688
+ const nextState = await saveState(statePath, {
689
+ ...recordedState,
690
+ coderSessionHandle: sessionHandle,
691
+ coderSessionProtocol: sessionHandle ? state.coderSessionProtocol : null,
692
+ phase: 'interactive_blocked_recovery',
693
+ status: 'running',
694
+ blockedFromPhase: state.interactiveBlockedRecovery.sourcePhase,
695
+ interactiveBlockedRecovery: {
696
+ ...recordedState.interactiveBlockedRecovery,
697
+ blockedReason: trimmedBlocker,
698
+ lastHandledTurn: turnNumber,
699
+ pendingDirective: null,
700
+ },
701
+ coderRetryCount: 0,
702
+ });
703
+ await writeExecutionArtifacts(nextState);
704
+ if (nextState.unattended) {
705
+ // No operator will answer a stay_blocked: synthesize another conservative
706
+ // auto-resume turn under the bounds, or run the shared terminal-fail action.
707
+ return continueOrTerminateUnattendedRecovery(nextState, statePath, sessionHandle, logger);
708
+ }
709
+ return nextState;
710
+ }
711
+ const terminalBlockedState = isActivePendingDerivedPlanReview(state)
712
+ ? {
713
+ ...state,
714
+ derivedPlanStatus: 'rejected',
715
+ derivedScopeIndex: null,
716
+ }
717
+ : state;
718
+ if (state.unattended) {
719
+ // Unattended terminal_block must not take the attended blocked + notifyBlocked
720
+ // path; record the coder's terminal decision for diagnostics, then run the
721
+ // shared classified terminal-fail action (status:'failed', no notifyBlocked).
722
+ const recordedTerminalState = withRecordedInteractiveBlockedRecoveryDisposition(terminalBlockedState, disposition, sessionHandle, 'blocked');
723
+ return failUnattendedRecoveryTerminally(recordedTerminalState, statePath, 'terminal_block', sessionHandle, logger);
724
+ }
725
+ const finalizedBlockedState = await persistFinalizedInteractiveBlockedRecovery(terminalBlockedState, statePath, disposition, sessionHandle, 'blocked');
726
+ const blockedState = await saveState(statePath, {
727
+ ...finalizedBlockedState,
728
+ coderSessionHandle: sessionHandle,
729
+ coderSessionProtocol: sessionHandle ? state.coderSessionProtocol : null,
730
+ phase: 'blocked',
731
+ status: 'blocked',
732
+ lastScopeMarker: state.lastScopeMarker ?? 'AUTONOMY_BLOCKED',
733
+ blockedFromPhase: state.interactiveBlockedRecovery.sourcePhase,
734
+ coderRetryCount: 0,
735
+ });
736
+ await writeExecutionArtifacts(blockedState);
737
+ const persistedState = await persistBlockedScope(blockedState, statePath, trimmedBlocker);
738
+ await notifyBlocked(persistedState, trimmedBlocker, logger);
739
+ return flushDerivedPlanNotifications(persistedState, statePath, logger, trimmedBlocker);
740
+ }
741
+ export async function runInteractiveBlockedRecoveryPhase(state, statePath, logger) {
742
+ if (!state.interactiveBlockedRecovery) {
743
+ throw new Error('Cannot run interactive blocked recovery without blocked-recovery state');
744
+ }
745
+ const latestTurn = state.interactiveBlockedRecovery.turns.at(-1);
746
+ const pendingDirective = state.interactiveBlockedRecovery.pendingDirective;
747
+ const hasPendingTurn = Boolean(latestTurn && latestTurn.number > state.interactiveBlockedRecovery.lastHandledTurn);
748
+ if (!hasPendingTurn && !pendingDirective) {
749
+ throw new Error('Interactive blocked recovery has no pending operator guidance to process.');
750
+ }
751
+ await logger?.event('phase.start', {
752
+ phase: 'interactive_blocked_recovery',
753
+ recoveryTurn: pendingDirective ? state.interactiveBlockedRecovery.turns.length : latestTurn?.number,
754
+ sourcePhase: state.interactiveBlockedRecovery.sourcePhase,
755
+ terminalOnly: Boolean(pendingDirective?.terminalOnly),
756
+ });
757
+ let codex;
758
+ try {
759
+ codex = await runBlockedRecoveryCoderRound({
760
+ coder: state.agentConfig.coder,
761
+ cwd: state.cwd,
762
+ planDoc: getExecutionPlanPath(state),
763
+ progressMarkdownPath: state.progressMarkdownPath,
764
+ recoveryMarkdownPath: state.recoveryMarkdownPath,
765
+ blockedReason: state.interactiveBlockedRecovery.blockedReason,
766
+ operatorGuidance: pendingDirective?.operatorGuidance ?? latestTurn?.operatorGuidance ?? state.interactiveBlockedRecovery.blockedReason,
767
+ maxTurns: state.interactiveBlockedRecovery.maxTurns,
768
+ turnsTaken: pendingDirective ? state.interactiveBlockedRecovery.turns.length : latestTurn?.number ?? 0,
769
+ terminalOnly: Boolean(pendingDirective?.terminalOnly),
770
+ allowReplacement: true,
771
+ sessionHandle: state.coderSessionHandle,
772
+ logger,
773
+ });
774
+ }
775
+ catch (error) {
776
+ if (error instanceof CoderRoundError) {
777
+ if (shouldRetryCoderWithFreshSession(state, 'interactive_blocked_recovery', error)) {
778
+ return scheduleCoderFreshSessionRetry(state, statePath, 'interactive_blocked_recovery', error, logger);
779
+ }
780
+ if (isCoderTimeoutError(error)) {
781
+ await bestEffortCleanupTimedOutCoder(error.sessionHandle ?? state.coderSessionHandle, logger);
782
+ }
783
+ const failedState = await persistCoderFailureState(state, statePath, 'interactive_blocked_recovery', error, logger);
784
+ if (shouldNotifyFailure(error)) {
785
+ await notifyBlocked(failedState, error.message, logger);
786
+ }
787
+ }
788
+ throw error;
789
+ }
790
+ const nextState = await applyInteractiveBlockedRecoveryDisposition(state, statePath, codex.payload, codex.sessionHandle, logger);
791
+ await logger?.event('phase.complete', {
792
+ phase: 'interactive_blocked_recovery',
793
+ recoveryTurn: pendingDirective ? state.interactiveBlockedRecovery.turns.length : latestTurn?.number,
794
+ action: codex.payload.action,
795
+ nextPhase: nextState.phase,
796
+ });
797
+ return nextState;
798
+ }