@navels/neal 0.4.2 → 0.5.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 (38) hide show
  1. package/README.md +18 -13
  2. package/dist/neal/adjudicator/execute.js +0 -1
  3. package/dist/neal/adjudicator/final-completion.js +0 -1
  4. package/dist/neal/adjudicator/planning.js +0 -1
  5. package/dist/neal/agents/rounds.js +1 -2
  6. package/dist/neal/blocked-guidance.js +1 -7
  7. package/dist/neal/cli.js +3 -19
  8. package/dist/neal/commands/compat.js +36 -13
  9. package/dist/neal/commands/new-run.js +1 -6
  10. package/dist/neal/commands/plan-and-execute.js +1 -5
  11. package/dist/neal/commands/resume-run.js +5 -7
  12. package/dist/neal/config.js +3 -11
  13. package/dist/neal/orchestrator/completion.js +7 -23
  14. package/dist/neal/orchestrator/notifications.js +1 -1
  15. package/dist/neal/orchestrator/phases/coder.js +0 -1
  16. package/dist/neal/orchestrator/phases/planning.js +7 -16
  17. package/dist/neal/orchestrator/phases/recovery.js +42 -285
  18. package/dist/neal/orchestrator/phases/shared.js +0 -26
  19. package/dist/neal/orchestrator/run-loop.js +1 -5
  20. package/dist/neal/orchestrator.js +0 -1
  21. package/dist/neal/plan-queue.js +5 -15
  22. package/dist/neal/prompts/execute.js +2 -4
  23. package/dist/neal/prompts/planning.js +1 -3
  24. package/dist/neal/prompts/shared.js +0 -7
  25. package/dist/neal/prompts/specialized.js +0 -2
  26. package/dist/neal/prompts/specs.js +25 -5
  27. package/dist/neal/state-invariants.js +0 -1
  28. package/dist/neal/state.js +0 -4
  29. package/docs/adjudicator-inventory.md +2 -3
  30. package/docs/architecture.md +1 -2
  31. package/docs/compat.md +17 -7
  32. package/docs/maintenance.md +7 -6
  33. package/docs/release.md +9 -0
  34. package/docs/review-convergence.md +7 -8
  35. package/docs/state-machine.md +53 -63
  36. package/docs/troubleshooting.md +19 -6
  37. package/neal.yml +0 -9
  38. package/package.json +8 -8
@@ -1,6 +1,5 @@
1
1
  import { CoderRoundError, runBlockedRecoveryCoderRound, } from '../../agents.js';
2
2
  import { CONSULTANT_ELIGIBLE_SOURCE_PHASES, buildRecentBlockCandidate, isReviewerConsultantPhase, runConsultant, upsertRecentBlock, } from '../../adjudicator/consultant.js';
3
- import { UNATTENDED_AUTO_RESUME_GUIDANCE } from '../../blocked-guidance.js';
4
3
  import { getInteractiveBlockedRecoveryMaxTurns, getConsultantMaxAttempts } from '../../config.js';
5
4
  import { EXECUTE_FINALIZATION_PHASE } from '../../execute-finalization.js';
6
5
  import { hasPendingOperatorGuidance } from '../../run-status.js';
@@ -11,14 +10,7 @@ import { writeExecutionArtifacts } from '../artifacts.js';
11
10
  import { isCoderTimeoutError, shouldNotifyFailure } from '../failures.js';
12
11
  import { flushDerivedPlanNotifications, notifyBlocked } from '../notifications.js';
13
12
  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;
13
+ import { bestEffortCleanupTimedOutCoder, persistBlockedScope, persistCoderFailureState, scheduleCoderFreshSessionRetry, shouldRetryCoderWithFreshSession, } from './shared.js';
22
14
  export class InteractiveBlockedRecoveryPendingTurnError extends Error {
23
15
  pendingTurn;
24
16
  constructor(pendingTurn) {
@@ -78,12 +70,10 @@ function isConsultantEligibleBlock(reason, sourcePhase) {
78
70
  // budget-exhausted cases emit NO `consultant.*` events so they preserve
79
71
  // the generic recovery path byte-for-byte: a disabled or exhausted consultant
80
72
  // must be indistinguishable from the consultant never having existed.
81
- // The single uniform budget is shared by both run modes — an invocation consumes
82
- // one unit whether it auto-applies a recoverable verdict (either mode) or, on a
83
- // non-recoverable verdict, finalizes terminally (unattended) or produces advice
84
- // and yields (attended) and is reset to 0 at every scope boundary (see the
85
- // scope-advance transitions and the split-plan persist) so one scope's
86
- // adjudication never exhausts a later scope.
73
+ // An invocation consumes one unit whether it auto-applies a recoverable verdict
74
+ // or produces advice and yields for the operator, and the budget is reset to 0
75
+ // at every scope boundary (see the scope-advance transitions and the split-plan
76
+ // persist) so one scope's adjudication never exhausts a later scope.
87
77
  function isConsultantBudgetAvailable(state) {
88
78
  const maxAttempts = getConsultantMaxAttempts(state.cwd);
89
79
  if (maxAttempts <= 0) {
@@ -91,121 +81,21 @@ function isConsultantBudgetAvailable(state) {
91
81
  }
92
82
  return state.consultantAttemptCount < maxAttempts;
93
83
  }
94
- // Unattended interception for every eligible block class. Runs the read-only
95
- // consultant (which may itself short-circuit on an anti-thrash repeat) and, on a
96
- // recoverable verdict with a concrete in-scope directive, enters interactive
97
- // recovery with that directive injected as the pending turn bounded by a
98
- // SEPARATE counter (`consultantAttemptCount`) that never touches
99
- // `unattendedAutoResumeCount` or the recovery turn cap. A non-recoverable verdict
100
- // (including a thrash repeat) is finalized TERMINALLY rather than silently
101
- // auto-resumed. Every gate that prevents the consultant from running — an
102
- // ineligible source phase, the disabled/exhausted budget, the turn cap, or an
103
- // consultant error — returns null so the caller falls through to the existing
104
- // generic auto-resume / terminal-fail path with `recentBlocks` left unchanged.
105
- // The consultant itself makes zero commits and zero file edits; this function is
106
- // the sole writer of `recentBlocks`, and only on the branches where the
107
- // consultant actually ran.
108
- async function maybeResolveBlockedUnattended(state, statePath, reason, sourcePhase, nextRecovery, logger) {
109
- if (!isConsultantEligibleBlock(reason, sourcePhase)) {
110
- return null;
111
- }
112
- if (!isConsultantBudgetAvailable(state)) {
113
- return null;
114
- }
115
- // Never push past the recovery turn cap; if there is no room for a recovery
116
- // turn, fall through to the generic bound check unchanged.
117
- if (nextRecovery.turns.length >= nextRecovery.maxTurns) {
118
- return null;
119
- }
120
- await logger?.event('consultant.start', {
121
- scopeNumber: state.currentScopeNumber,
122
- sourcePhase,
123
- blockedReason: reason,
124
- });
125
- let verdict;
126
- try {
127
- verdict = await runConsultant(state, reason, sourcePhase, logger);
128
- }
129
- catch (error) {
130
- // An consultant failure must never crash the run or weaken existing recovery;
131
- // record the decline and fall through to the generic path with `recentBlocks`
132
- // unchanged (the consultant did not complete for this block).
133
- await logger?.event('consultant.declined', {
134
- scopeNumber: state.currentScopeNumber,
135
- sourcePhase,
136
- blockedReason: reason,
137
- error: error instanceof Error ? error.message : String(error),
138
- });
139
- return null;
140
- }
141
- // The consultant ran (possibly short-circuiting internally on a thrash repeat),
142
- // so this block is recorded in the anti-thrash window regardless of the verdict.
143
- // The candidate is built from the PRE-update array and written in this same
144
- // transition, so a block can never match itself. It MUST be built from the same
145
- // `state` snapshot `runConsultant` checked: the candidate's evidence
146
- // fingerprint is derived from `state.createdCommits`, and a divergent snapshot
147
- // would record a fingerprint the guard never compared against.
148
- const candidate = buildRecentBlockCandidate(state, reason, sourcePhase);
149
- const recentBlocks = upsertRecentBlock(state.recentBlocks, candidate);
150
- const resolutionDirective = verdict.resolutionDirective.trim();
151
- if (!verdict.recoverable || !resolutionDirective) {
152
- // Genuine wall (recoverable:false) or a thrash repeat: an unattended run has
153
- // no operator to escalate to, so finalize TERMINALLY instead of synthesizing a
154
- // generic auto-resume. The consultant actually ran, so this branch consumes
155
- // one unit of the shared per-scope budget (`consultantAttemptCount`) exactly
156
- // like the recoverable branch, and persists the anti-thrash record via the
157
- // threaded `recentBlocks`. Only the fallback paths where `runConsultant`
158
- // was never invoked leave the budget untouched.
159
- await logger?.event('consultant.declined', {
160
- scopeNumber: state.currentScopeNumber,
161
- sourcePhase,
162
- blockedReason: reason,
163
- recoverable: verdict.recoverable,
164
- triageCategory: verdict.triageCategory,
165
- consultantAttemptCount: state.consultantAttemptCount + 1,
166
- });
167
- return failUnattendedRecoveryTerminally({ ...state, recentBlocks, consultantAttemptCount: state.consultantAttemptCount + 1 }, statePath, 'terminal_block', state.coderSessionHandle, logger);
168
- }
169
- await logger?.event('consultant.verdict', {
170
- scopeNumber: state.currentScopeNumber,
171
- sourcePhase,
172
- blockedReason: reason,
173
- recoverable: verdict.recoverable,
174
- triageCategory: verdict.triageCategory,
175
- targetCanonicalIds: verdict.targetCanonicalIds,
176
- // Report the post-increment count this verdict is about to consume so the
177
- // verdict and the later `resolved` event agree on the budget figure.
178
- consultantAttemptCount: state.consultantAttemptCount + 1,
179
- });
180
- // Recoverable verdict with a concrete directive: auto-apply it (shared with the
181
- // attended path) so the coder consumes the directive and the run continues.
182
- return applyRecoverableConsultantDirective({
183
- state,
184
- statePath,
185
- reason,
186
- sourcePhase,
187
- nextRecovery,
188
- recentBlocks,
189
- resolutionDirective,
190
- verdict,
191
- logger,
192
- });
193
- }
194
- // Attended interception for every eligible block class. The consultant NEVER
195
- // auto-applies its verdict in attended mode; instead it triages read-only and the
196
- // verdict is returned as advice (plus the updated anti-thrash window) for the
197
- // caller to persist alongside the operator yield. Gated by the SAME eligibility +
198
- // disable knob + per-scope budget as the unattended path; when any gate blocks the
199
- // consultant (ineligible phase, knob 0, exhausted budget, or an consultant
200
- // error) this returns null and the caller yields exactly as today with no advice,
201
- // no budget consumption, and `recentBlocks` unchanged.
84
+ // Consultant interception for every eligible block class. The consultant triages
85
+ // read-only and the verdict is returned as advice (plus the updated anti-thrash
86
+ // window) for the caller to either auto-apply (recoverable verdict with a
87
+ // concrete directive) or persist alongside the operator yield. Gated by the
88
+ // eligibility check, the disable knob, and the per-scope budget; when any gate
89
+ // blocks the consultant (ineligible phase, knob 0, exhausted budget, or a
90
+ // consultant error) this returns null and the caller yields for the operator
91
+ // with no advice, no budget consumption, and `recentBlocks` unchanged.
202
92
  //
203
- // The consultant runs BEFORE any `consultant.*` event is emitted, so an
204
- // consultant error degrades to today's plain attended yield with a byte-for-byte
93
+ // The consultant runs BEFORE any `consultant.*` event is emitted, so a
94
+ // consultant error degrades to a plain operator yield with a byte-for-byte
205
95
  // generic observable surface: zero consultant events, no advice, and no
206
96
  // counter/`recentBlocks` mutation. The `start`/`verdict` audit pair is emitted only
207
97
  // once the consultant has actually produced a verdict for this block.
208
- async function buildAttendedConsultantAdvice(state, reason, sourcePhase, logger) {
98
+ async function buildConsultantAdvice(state, reason, sourcePhase, logger) {
209
99
  if (!isConsultantEligibleBlock(reason, sourcePhase)) {
210
100
  return null;
211
101
  }
@@ -217,7 +107,7 @@ async function buildAttendedConsultantAdvice(state, reason, sourcePhase, logger)
217
107
  verdict = await runConsultant(state, reason, sourcePhase, logger);
218
108
  }
219
109
  catch {
220
- // Degrade to today's plain attended yield: never crash the run, and emit NO
110
+ // Degrade to a plain operator yield: never crash the run, and emit NO
221
111
  // `consultant.*` events so the fallback is indistinguishable from the
222
112
  // disabled/exhausted/ineligible generic yield.
223
113
  return null;
@@ -236,9 +126,11 @@ async function buildAttendedConsultantAdvice(state, reason, sourcePhase, logger)
236
126
  targetCanonicalIds: verdict.targetCanonicalIds,
237
127
  consultantAttemptCount: state.consultantAttemptCount + 1,
238
128
  });
239
- // Same-snapshot rule as the unattended writer: the recorded candidate's
240
- // commit-trail evidence fingerprint must come from the `state` the consultant
241
- // just checked, never a fresher snapshot.
129
+ // Same-snapshot rule: the recorded candidate's commit-trail evidence
130
+ // fingerprint must come from the `state` the consultant just checked, never a
131
+ // fresher snapshot the candidate's evidence fingerprint is derived from
132
+ // `state.createdCommits`, and a divergent snapshot would record a fingerprint
133
+ // the anti-thrash guard never compared against.
242
134
  const candidate = buildRecentBlockCandidate(state, reason, sourcePhase);
243
135
  const recentBlocks = upsertRecentBlock(state.recentBlocks, candidate);
244
136
  const advice = {
@@ -250,13 +142,13 @@ async function buildAttendedConsultantAdvice(state, reason, sourcePhase, logger)
250
142
  };
251
143
  return { advice, recentBlocks, verdict };
252
144
  }
253
- // Applies a recoverable consultant verdict shared by both run modes. Enters
254
- // interactive recovery and injects the consultant's in-scope directive as the
255
- // pending turn, exactly like a human-supplied `neal resume --message`, so the
256
- // coder consumes it and the run continues. Consumes one unit of the per-scope
257
- // consultant budget (`consultantAttemptCount`, never `unattendedAutoResumeCount`)
258
- // and persists the anti-thrash `recentBlocks`. The caller has already emitted the
259
- // `consultant.verdict` audit event for this verdict.
145
+ // Applies a recoverable consultant verdict. Enters interactive recovery and
146
+ // injects the consultant's in-scope directive as the pending turn, exactly like
147
+ // a human-supplied `neal resume --message`, so the coder consumes it and the
148
+ // run continues. Consumes one unit of the per-scope consultant budget
149
+ // (`consultantAttemptCount`) and persists the anti-thrash `recentBlocks`. The
150
+ // caller has already emitted the `consultant.verdict` audit event for this
151
+ // verdict.
260
152
  async function applyRecoverableConsultantDirective(args) {
261
153
  const { state, statePath, reason, sourcePhase, nextRecovery, recentBlocks, resolutionDirective, verdict, logger } = args;
262
154
  const enteredState = await saveState(statePath, {
@@ -305,67 +197,13 @@ export async function enterInteractiveBlockedRecovery(state, statePath, reason,
305
197
  pendingDirective: null,
306
198
  turns: [],
307
199
  };
308
- // Unattended runs have no operator to answer, so instead of yielding-and-halting
309
- // we either synthesize a conservative auto-resume turn (bounded) or, past the
310
- // bound, run the shared terminal-fail action. Gate purely on structural state:
311
- // the persisted counter and the recovery turn cap, never on guidance text.
312
- if (state.unattended) {
313
- // Before the generic auto-resume/terminal-fail decision, give the bounded
314
- // read-only consultant a chance to triage the block: autonomously resolve it
315
- // with an in-scope directive (recoverable) or finalize terminally (a genuine
316
- // wall or thrash repeat). Any ineligible source phase, disabled/exhausted
317
- // budget, turn cap, or consultant error falls through to the existing generic
318
- // behavior unchanged.
319
- const consultantResolved = await maybeResolveBlockedUnattended(state, statePath, reason, sourcePhase, nextRecovery, logger);
320
- if (consultantResolved) {
321
- return consultantResolved;
322
- }
323
- const canAutoResume = state.unattendedAutoResumeCount < UNATTENDED_MAX_AUTO_RESUMES &&
324
- nextRecovery.turns.length < nextRecovery.maxTurns;
325
- if (!canAutoResume) {
326
- // Past the bound, finalize any active recovery record into history and land
327
- // on a terminal failed shape (status:'failed', phase:'blocked',
328
- // interactiveBlockedRecovery:null) so the run is never persisted as an
329
- // active/waiting recovery. Reuses the same finalizer as the disposition
330
- // terminal-fail paths.
331
- return failUnattendedRecoveryTerminally(state, statePath, 'terminal_block', state.coderSessionHandle, logger);
332
- }
333
- const enteredState = await saveState(statePath, {
334
- ...state,
335
- phase: 'interactive_blocked_recovery',
336
- status: 'running',
337
- blockedFromPhase: state.blockedFromPhase ?? state.phase,
338
- unattendedAutoResumeCount: state.unattendedAutoResumeCount + 1,
339
- interactiveBlockedRecovery: {
340
- ...nextRecovery,
341
- sourcePhase,
342
- blockedReason: reason,
343
- },
344
- });
345
- await writeExecutionArtifacts(enteredState);
346
- await logger?.event('interactive_blocked_recovery.entered', {
347
- scopeNumber: enteredState.currentScopeNumber,
348
- sourcePhase: enteredState.interactiveBlockedRecovery?.sourcePhase,
349
- blockedReason: reason,
350
- });
351
- await logger?.event('interactive_blocked_recovery.unattended_auto_resume', {
352
- scopeNumber: enteredState.currentScopeNumber,
353
- sourcePhase,
354
- autoResumeCount: enteredState.unattendedAutoResumeCount,
355
- maxAutoResumes: UNATTENDED_MAX_AUTO_RESUMES,
356
- });
357
- // Reuse the turn-recording helper so the synthesized guidance turn satisfies
358
- // the same invariants a human-supplied `neal resume --message` would; the run
359
- // loop then proceeds into runInteractiveBlockedRecoveryPhase to consume it.
360
- return recordInteractiveBlockedRecoveryGuidance(statePath, UNATTENDED_AUTO_RESUME_GUIDANCE, logger);
361
- }
362
- // Attended runs run the same bounded read-only consultant. On a recoverable
363
- // verdict with a concrete directive, they auto-apply it exactly as unattended
364
- // runs do — the consultant's advice is acted on in both modes. On a genuine wall
200
+ // Give the bounded read-only consultant a chance to triage the block. On a
201
+ // recoverable verdict with a concrete directive, auto-apply it so the coder
202
+ // consumes the directive and the run continues. On a genuine wall
365
203
  // (recoverable:false), or when the disable knob / budget / eligibility gate the
366
- // consultant off, the attended run yields for the operator, carrying the verdict
367
- // as advice when there is one so the operator sees why it stopped.
368
- const advisory = await buildAttendedConsultantAdvice(state, reason, sourcePhase, logger);
204
+ // consultant off, the run yields for the operator, carrying the verdict as
205
+ // advice when there is one so the operator sees why it stopped.
206
+ const advisory = await buildConsultantAdvice(state, reason, sourcePhase, logger);
369
207
  if (advisory) {
370
208
  const resolutionDirective = advisory.advice.resolutionDirective.trim();
371
209
  if (advisory.advice.recoverable && resolutionDirective) {
@@ -405,16 +243,14 @@ export async function enterInteractiveBlockedRecovery(state, statePath, reason,
405
243
  });
406
244
  return nextState;
407
245
  }
408
- // Whether a caller of `enterInteractiveBlockedRecovery` should emit an attended
409
- // blocked / interactive-recovery notification for the returned state. Notify only
410
- // when an attended run is actually WAITING for the operator. An attended run whose
411
- // block the consultant auto-fixed leaves a pending directive to consume (status
412
- // 'running', a recorded recovery turn) — that is `waitingForOperatorGuidance:
413
- // false`, so it must not notify. Unattended runs never wait here (they auto-resume
414
- // or terminally fail), so `!state.unattended` already excludes them. Gate
415
- // structurally on the derived recovery view, never on text.
246
+ // Whether a caller of `enterInteractiveBlockedRecovery` should emit a blocked /
247
+ // interactive-recovery notification for the returned state. Notify only when the
248
+ // run is actually WAITING for the operator. A run whose block the consultant
249
+ // auto-fixed leaves a pending directive to consume (status 'running', a recorded
250
+ // recovery turn) — that is `waitingForOperatorGuidance: false`, so it must not
251
+ // notify. Gate structurally on the derived recovery view, never on text.
416
252
  export function shouldNotifyInteractiveBlockedRecoveryEntry(state) {
417
- return !state.unattended && (getInteractiveRecoveryView(state)?.waitingForOperatorGuidance ?? false);
253
+ return getInteractiveRecoveryView(state)?.waitingForOperatorGuidance ?? false;
418
254
  }
419
255
  export async function recordInteractiveBlockedRecoveryGuidance(statePath, operatorGuidance, logger) {
420
256
  const trimmedGuidance = operatorGuidance.trim();
@@ -586,65 +422,6 @@ function getInteractiveBlockedRecoveryResumePhase(sourcePhase) {
586
422
  return sourcePhase;
587
423
  }
588
424
  }
589
- // Shared unattended terminal-fail for a recovery disposition. The active
590
- // recovery record is finalized into history (so `interactiveBlockedRecovery`
591
- // becomes null and the lifecycle view is no longer "active"/waiting — required
592
- // because the state invariant ties a non-null record to the recovery phase),
593
- // the run lands on the recovery's source phase for diagnostics, and the shared
594
- // classified terminal-fail action runs (status:'failed', no `notifyBlocked`).
595
- // `state` must carry the disposition already recorded on its latest turn.
596
- async function failUnattendedRecoveryTerminally(state, statePath, action, sessionHandle, logger) {
597
- const recovery = state.interactiveBlockedRecovery;
598
- const sourcePhase = recovery?.sourcePhase ?? state.blockedFromPhase ?? state.phase;
599
- // The history record's resultPhase must satisfy the disposition-result invariant
600
- // (stay_blocked -> recovery, terminal_block/replace -> blocked); the run itself
601
- // lands on the terminal `blocked` phase with status:'failed', which keeps the
602
- // lifecycle view out of any waiting/active recovery state.
603
- const historyResultPhase = action === 'stay_blocked' ? 'interactive_blocked_recovery' : 'blocked';
604
- await logger?.event('interactive_blocked_recovery.unattended_terminal_fail', {
605
- scopeNumber: state.currentScopeNumber,
606
- sourcePhase,
607
- autoResumeCount: state.unattendedAutoResumeCount,
608
- maxAutoResumes: UNATTENDED_MAX_AUTO_RESUMES,
609
- });
610
- const finalized = recovery ? finalizeInteractiveBlockedRecovery(state, action, historyResultPhase) : state;
611
- return persistUnattendedBlockUnresolvedFailure({
612
- ...finalized,
613
- phase: 'blocked',
614
- blockedFromPhase: sourcePhase,
615
- coderSessionHandle: sessionHandle,
616
- coderSessionProtocol: sessionHandle ? state.coderSessionProtocol : null,
617
- coderRetryCount: 0,
618
- }, statePath, 'interactive_blocked_recovery', logger);
619
- }
620
- // Under `unattended`, a recovery disposition that would otherwise leave the run
621
- // waiting for an operator (`stay_blocked`) is resolved structurally: synthesize
622
- // another conservative auto-resume turn while still under the persisted
623
- // auto-resume cap and the recovery turn cap, otherwise run the shared
624
- // terminal-fail action. `state` must already be persisted in
625
- // `interactive_blocked_recovery` with its recovery record holding the handled
626
- // turns. Gates only on the persisted counter and turn cap, never on text.
627
- async function continueOrTerminateUnattendedRecovery(state, statePath, sessionHandle, logger) {
628
- const recovery = state.interactiveBlockedRecovery;
629
- const canAutoResume = !!recovery &&
630
- state.unattendedAutoResumeCount < UNATTENDED_MAX_AUTO_RESUMES &&
631
- recovery.turns.length < recovery.maxTurns;
632
- if (!canAutoResume) {
633
- return failUnattendedRecoveryTerminally(state, statePath, 'stay_blocked', sessionHandle, logger);
634
- }
635
- const incremented = await saveState(statePath, {
636
- ...state,
637
- unattendedAutoResumeCount: state.unattendedAutoResumeCount + 1,
638
- });
639
- await writeExecutionArtifacts(incremented);
640
- await logger?.event('interactive_blocked_recovery.unattended_auto_resume', {
641
- scopeNumber: incremented.currentScopeNumber,
642
- sourcePhase: recovery.sourcePhase,
643
- autoResumeCount: incremented.unattendedAutoResumeCount,
644
- maxAutoResumes: UNATTENDED_MAX_AUTO_RESUMES,
645
- });
646
- return recordInteractiveBlockedRecoveryGuidance(statePath, UNATTENDED_AUTO_RESUME_GUIDANCE, logger);
647
- }
648
425
  export async function applyInteractiveBlockedRecoveryDisposition(state, statePath, disposition, sessionHandle, logger) {
649
426
  if (state.phase !== 'interactive_blocked_recovery' || !state.interactiveBlockedRecovery) {
650
427
  throw new Error(`Run is not in interactive blocked recovery: ${statePath}`);
@@ -688,14 +465,6 @@ export async function applyInteractiveBlockedRecoveryDisposition(state, statePat
688
465
  writeExecutionArtifacts,
689
466
  });
690
467
  const resultPhase = persistedState.phase === 'blocked' ? 'blocked' : 'reviewer_plan';
691
- if (state.unattended && resultPhase === 'blocked') {
692
- // An unattended replacement that could not produce a runnable plan must not
693
- // leave the run resumable-blocked; finalize recovery and fail cleanly.
694
- return failUnattendedRecoveryTerminally({
695
- ...persistedState,
696
- interactiveBlockedRecovery: withRecordedInteractiveBlockedRecoveryDisposition({ ...persistedState, interactiveBlockedRecovery: state.interactiveBlockedRecovery }, disposition, sessionHandle, 'blocked').interactiveBlockedRecovery,
697
- }, statePath, 'replace_current_scope', sessionHandle, logger);
698
- }
699
468
  return persistFinalizedInteractiveBlockedRecovery({
700
469
  ...persistedState,
701
470
  interactiveBlockedRecovery: state.interactiveBlockedRecovery,
@@ -737,11 +506,6 @@ export async function applyInteractiveBlockedRecoveryDisposition(state, statePat
737
506
  coderRetryCount: 0,
738
507
  });
739
508
  await writeExecutionArtifacts(nextState);
740
- if (nextState.unattended) {
741
- // No operator will answer a stay_blocked: synthesize another conservative
742
- // auto-resume turn under the bounds, or run the shared terminal-fail action.
743
- return continueOrTerminateUnattendedRecovery(nextState, statePath, sessionHandle, logger);
744
- }
745
509
  return nextState;
746
510
  }
747
511
  const terminalBlockedState = isActivePendingDerivedPlanReview(state)
@@ -751,13 +515,6 @@ export async function applyInteractiveBlockedRecoveryDisposition(state, statePat
751
515
  derivedScopeIndex: null,
752
516
  }
753
517
  : state;
754
- if (state.unattended) {
755
- // Unattended terminal_block must not take the attended blocked + notifyBlocked
756
- // path; record the coder's terminal decision for diagnostics, then run the
757
- // shared classified terminal-fail action (status:'failed', no notifyBlocked).
758
- const recordedTerminalState = withRecordedInteractiveBlockedRecoveryDisposition(terminalBlockedState, disposition, sessionHandle, 'blocked');
759
- return failUnattendedRecoveryTerminally(recordedTerminalState, statePath, 'terminal_block', sessionHandle, logger);
760
- }
761
518
  const finalizedBlockedState = await persistFinalizedInteractiveBlockedRecovery(terminalBlockedState, statePath, disposition, sessionHandle, 'blocked');
762
519
  const blockedState = await saveState(statePath, {
763
520
  ...finalizedBlockedState,
@@ -86,32 +86,6 @@ export async function persistCoderFailureState(state, statePath, phase, error, l
86
86
  });
87
87
  return failedState;
88
88
  }
89
- // Shared terminal-fail action for the three operator-block sites under
90
- // `unattended`. Mirrors `persistCoderFailureState`: save `status:'failed'`,
91
- // re-render the execution artifacts, write a `failed` checkpoint retrospective,
92
- // and emit a classified log event — deliberately WITHOUT `notifyBlocked` (that
93
- // is the attended wait notification). Any produced diff/plan is left in the
94
- // worktree/run dir as an artifact and is not submitted, exactly as today's
95
- // failed runs leave it. `phase`/`blockedFromPhase` are preserved for
96
- // diagnostics (defaulting `blockedFromPhase` to the current phase).
97
- export async function persistUnattendedBlockUnresolvedFailure(state, statePath, site, logger) {
98
- const blockedFromPhase = state.blockedFromPhase ?? state.phase;
99
- const failedState = await saveState(statePath, {
100
- ...state,
101
- status: 'failed',
102
- blockedFromPhase,
103
- });
104
- await writeExecutionArtifacts(failedState);
105
- await writeCheckpointRetrospective(failedState, 'failed');
106
- await logger?.event('unattended.block_unresolved', {
107
- reason: 'unattended_block_unresolved',
108
- site,
109
- blockedFromPhase,
110
- phase: failedState.phase,
111
- scopeNumber: failedState.currentScopeNumber,
112
- });
113
- return failedState;
114
- }
115
89
  export async function persistBlockedScope(state, statePath, reason) {
116
90
  const scopeLabel = getCurrentScopeLabel(state);
117
91
  if (state.completedScopes.some((scope) => scope.number === scopeLabel)) {
@@ -103,11 +103,7 @@ export async function runOnePass(args) {
103
103
  archivedReviewPath: currentState.archivedReviewPath,
104
104
  });
105
105
  if (currentState.phase === 'blocked' || currentState.phase === 'done') {
106
- // A terminal-failed run (e.g. unattended site-A terminal-fail lands on
107
- // phase:'blocked' with status:'failed') must keep its failed retrospective
108
- // rather than be overwritten as a blocked one.
109
- const retrospectiveReason = currentState.status === 'failed' ? 'failed' : currentState.phase === 'blocked' ? 'blocked' : 'done';
110
- await runtime.writeCheckpointRetrospective(currentState, retrospectiveReason);
106
+ await runtime.writeCheckpointRetrospective(currentState, currentState.phase === 'blocked' ? 'blocked' : 'done');
111
107
  }
112
108
  return currentState;
113
109
  }
@@ -74,7 +74,6 @@ export async function initializeOrchestration(planDoc, cwd, agentConfig, topLeve
74
74
  topLevelMode,
75
75
  allowedDirtyPaths: options?.allowedDirtyPaths ?? [],
76
76
  agentConfig,
77
- unattended: options?.unattended ?? false,
78
77
  autoSquashOnCompletion: options?.autoSquashOnCompletion ?? true,
79
78
  progressJsonPath: join(logger.runDir, 'plan-progress.json'),
80
79
  progressMarkdownPath: join(logger.runDir, 'PLAN_PROGRESS.md'),
@@ -148,7 +148,6 @@ export async function runPlanAndExecuteQueue(args) {
148
148
  state: queue,
149
149
  agentConfig: args.agentConfig,
150
150
  squashOnCompletion: args.squashOnCompletion ?? true,
151
- unattended: args.unattended ?? false,
152
151
  deps: args.deps,
153
152
  });
154
153
  }
@@ -162,7 +161,7 @@ export async function continuePlanAndExecuteQueue(args) {
162
161
  break;
163
162
  }
164
163
  if (item.status === 'pending') {
165
- state = await runQueueChildStage(state, item.index, 'planning', args.agentConfig, args.squashOnCompletion ?? true, args.unattended ?? false, deps);
164
+ state = await runQueueChildStage(state, item.index, 'planning', args.agentConfig, args.squashOnCompletion ?? true, deps);
166
165
  if (state.status !== 'running') {
167
166
  return state;
168
167
  }
@@ -172,7 +171,7 @@ export async function continuePlanAndExecuteQueue(args) {
172
171
  break;
173
172
  }
174
173
  if (plannedItem.status === 'planned') {
175
- state = await runQueueChildStage(state, plannedItem.index, 'execution', args.agentConfig, args.squashOnCompletion ?? true, args.unattended ?? false, deps);
174
+ state = await runQueueChildStage(state, plannedItem.index, 'execution', args.agentConfig, args.squashOnCompletion ?? true, deps);
176
175
  if (state.status !== 'running') {
177
176
  return state;
178
177
  }
@@ -220,15 +219,9 @@ export async function continuePlanAndExecuteQueueFromChildRun(args) {
220
219
  state: advancedState,
221
220
  agentConfig: args.agentConfig,
222
221
  // Carry the squash preference forward across the cross-process
223
- // queue-resume handoff the same way unattended is below: the resumed
224
- // child's persisted run state wins when the caller supplies nothing.
222
+ // queue-resume handoff: the resumed child's persisted run state wins when
223
+ // the caller supplies nothing.
225
224
  squashOnCompletion: args.squashOnCompletion ?? args.childResult.finalState.autoSquashOnCompletion,
226
- // Carry unattended forward across the cross-process queue-resume handoff.
227
- // The resumed child's persisted finalState is the authoritative source (it
228
- // was initialized with the value the original `neal run` resolved), so
229
- // subsequent queue children do not silently revert to attended mode. An
230
- // explicit caller-supplied value still wins.
231
- unattended: args.unattended ?? args.childResult.finalState.unattended,
232
225
  deps: args.deps,
233
226
  });
234
227
  }
@@ -352,7 +345,7 @@ function resolveQueueRunnerDeps(deps) {
352
345
  runFreshChild: deps?.runFreshChild ?? runFreshPlanAndExecuteChild,
353
346
  };
354
347
  }
355
- async function runQueueChildStage(state, itemIndex, stage, agentConfig, squashOnCompletion, unattended, deps) {
348
+ async function runQueueChildStage(state, itemIndex, stage, agentConfig, squashOnCompletion, deps) {
356
349
  try {
357
350
  await assertQueueWorktreeReady(state, deps);
358
351
  }
@@ -374,7 +367,6 @@ async function runQueueChildStage(state, itemIndex, stage, agentConfig, squashOn
374
367
  planDoc,
375
368
  agentConfig,
376
369
  squashOnCompletion,
377
- unattended,
378
370
  }, async (child) => {
379
371
  initialized = true;
380
372
  activeState = await recordQueueChildInitialized(activeState, itemIndex, stage, child);
@@ -490,7 +482,6 @@ async function runFreshPlanAndExecuteChild(args, onInitialized) {
490
482
  const loaded = await loadOrInitialize(planDoc, cwd, args.agentConfig, undefined, topLevelMode, {
491
483
  allowedDirtyPaths: args.stage === 'execution' ? [planDoc] : [],
492
484
  runDir: prepared.runDir,
493
- unattended: args.unattended,
494
485
  // Seed the queue's resolved squash preference onto the child run
495
486
  // state (unstaged: planning children persist it too so a resumed
496
487
  // planning child carries it back into queue continuation). Once
@@ -513,7 +504,6 @@ async function runFreshPlanAndExecuteChild(args, onInitialized) {
513
504
  });
514
505
  return executeRun(loaded.state, loaded.statePath, loaded.logger, {
515
506
  autoSquashOnCompletion: args.stage === 'execution' && args.squashOnCompletion,
516
- unattended: args.unattended,
517
507
  });
518
508
  });
519
509
  }
@@ -1,5 +1,5 @@
1
1
  import { renderInlinedRangeDiffSection, truncateInlineSectionBody } from '../context/inline-review-context.js';
2
- import { AUTONOMY_BLOCKED, AUTONOMY_DONE, AUTONOMY_SCOPE_DONE, AUTONOMY_SPLIT_PLAN, buildProgressSection, getCanonicalPlanContractLines, getDerivedPlanSectionContractLines, getExecuteScopeProgressPayloadContractLines, getProtocolMarkerArtifactProhibitionLines, getStandalonePlanPayloadSourceOfTruthLines, getTerminalMarkerArtifactBoundaryLines, getUnattendedAutonomyLines, } from './shared.js';
2
+ import { AUTONOMY_BLOCKED, AUTONOMY_DONE, AUTONOMY_SCOPE_DONE, AUTONOMY_SPLIT_PLAN, buildProgressSection, getCanonicalPlanContractLines, getDerivedPlanSectionContractLines, getExecuteScopeProgressPayloadContractLines, getProtocolMarkerArtifactProhibitionLines, getStandalonePlanPayloadSourceOfTruthLines, getTerminalMarkerArtifactBoundaryLines, } from './shared.js';
3
3
  import { assertPromptBuilder } from './assert-builder.js';
4
4
  import { getUserGuidanceLines } from './guidance.js';
5
5
  import { getAdversarialReviewDoctrineLines, getCodeReviewFalsificationLines, getFindingQualityLines, getPreexistingFailureContractLines, getRegressionPreservationLines, getVerificationSkepticismLines, } from './review-doctrine.js';
@@ -11,7 +11,7 @@ export const CODER_PREEXISTING_FAILURE_LINES = [
11
11
  "When it is inside the surface and the fix is bounded, fix it and add a focused regression check when practical. When it is inside the surface but the fix is not bounded, do not silently expand the scope; surface it as a blocking concern through this prompt's blocked escape naming the failure as the blocker, or through this prompt's split-plan escape with a derived plan that sequences the fix, so it becomes an explicit decision.",
12
12
  'When it is outside the surface, record what you observed and why it cannot affect the required behavior in your progress justification, then continue without fixing it. Fixing out-of-surface pre-existing issues is scope drift.',
13
13
  ];
14
- export function buildScopePrompt(planDoc, progressText, options) {
14
+ export function buildScopePrompt(planDoc, progressText) {
15
15
  const spec = assertPromptBuilder('scope_coder', 'buildScopePrompt', PROMPT_MODULE_PATH);
16
16
  const primaryVariant = spec.variants.find((variant) => variant.kind === 'primary');
17
17
  if (!primaryVariant) {
@@ -25,7 +25,6 @@ export function buildScopePrompt(planDoc, progressText, options) {
25
25
  '2. Read any companion docs or required-context files explicitly referenced by that plan before starting work.',
26
26
  '3. Reset your instructions for this turn from the current contents of the plan, the inlined progress state below, and required context.',
27
27
  '',
28
- ...getUnattendedAutonomyLines(options?.unattended),
29
28
  'Then execute exactly one implementation scope.',
30
29
  'Do not start a second scope in this turn.',
31
30
  'Return only the structured execution envelope requested by the provider schema.',
@@ -198,7 +197,6 @@ export function buildReviewerPrompt(args) {
198
197
  'Do not use `block_for_operator` solely to ask Neal to retire an already-complete parent objective inside that derived-plan case; use `advance_parent` only for that narrow empty-derived-scope case.',
199
198
  'Keep `block_for_operator` for ambiguous already-satisfied claims, missing evidence, missing operator decisions, or unsafe parent-completion claims.',
200
199
  'A non-empty or uncertain current diff is the normal case, not a reason to escalate: handle it by accepting, requesting a bounded revision, or splitting the plan rather than choosing `block_for_operator`.',
201
- ...getUnattendedAutonomyLines(args.unattended),
202
200
  'Use `meaningfulProgressRationale` to explain the convergence judgment against the parent objective and recent accepted-scope history. Do not use it to restate correctness findings.',
203
201
  '',
204
202
  'Coder progress justification for this scope:',
@@ -1,4 +1,4 @@
1
- import { AUTONOMY_BLOCKED, AUTONOMY_DONE, getCanonicalPlanContractLines, getDerivedPlanSectionContractLines, getProtocolMarkerArtifactProhibitionLines, getTerminalMarkerArtifactBoundaryLines, getUnattendedAutonomyLines, } from './shared.js';
1
+ import { AUTONOMY_BLOCKED, AUTONOMY_DONE, getCanonicalPlanContractLines, getDerivedPlanSectionContractLines, getProtocolMarkerArtifactProhibitionLines, getTerminalMarkerArtifactBoundaryLines, } from './shared.js';
2
2
  import { assertPromptBuilder } from './assert-builder.js';
3
3
  import { getUserGuidanceLines } from './guidance.js';
4
4
  const PROMPT_MODULE_PATH = 'src/neal/prompts/planning.ts';
@@ -142,7 +142,6 @@ export function buildPlanningPrompt(planDoc, planDocument, options) {
142
142
  ...getPlanningPromptBaseLines(planDoc),
143
143
  ...getInlineCurrentPlanLines(planDocument),
144
144
  ...getAuthoredOneShotPlanningLines(options?.authoredOneShot),
145
- ...getUnattendedAutonomyLines(options?.unattended),
146
145
  ...getProtocolMarkerArtifactProhibitionLines(),
147
146
  ...getCanonicalPlanContractLines(),
148
147
  'If critical information is missing, do not invent it. Surface the concrete missing questions in your final response.',
@@ -221,7 +220,6 @@ export function buildPlanReviewerPrompt(args) {
221
220
  reviewHistoryLine,
222
221
  ...getAuthoredOneShotReviewerLines(args.authoredOneShot),
223
222
  ...getReviewerContextLines(args.reviewerContext),
224
- ...getUnattendedAutonomyLines(args.unattended),
225
223
  ...getUserGuidanceLines('reviewer'),
226
224
  '',
227
225
  planInspectionLine,
@@ -4,13 +4,6 @@ export const AUTONOMY_CHUNK_DONE = 'AUTONOMY_CHUNK_DONE';
4
4
  export const AUTONOMY_DONE = 'AUTONOMY_DONE';
5
5
  export const AUTONOMY_BLOCKED = 'AUTONOMY_BLOCKED';
6
6
  export const AUTONOMY_SPLIT_PLAN = 'AUTONOMY_SPLIT_PLAN';
7
- // Single autonomy line rendered into agent prompts when the run is unattended.
8
- // It tells the agent no operator can answer while keeping every verification
9
- // requirement intact (unattended means "no human to ask", not "skip checks").
10
- export const UNATTENDED_AUTONOMY_PROMPT_LINE = 'No operator is available to answer. Resolve this autonomously: do not escalate for operator guidance; make your best judgment and keep all verification requirements intact.';
11
- export function getUnattendedAutonomyLines(unattended) {
12
- return unattended ? [UNATTENDED_AUTONOMY_PROMPT_LINE] : [];
13
- }
14
7
  export function getCanonicalPlanContractLines() {
15
8
  return [
16
9
  'Choose exactly one execution shape: `one_shot`, `multi_scope`, or `multi_scope_unknown`.',