@navels/neal 0.2.0 → 0.3.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 (114) hide show
  1. package/README.md +206 -209
  2. package/SECURITY.md +16 -19
  3. package/dist/neal/adjudicator/{blocked-adjudicator.js → consultant.js} +21 -21
  4. package/dist/neal/adjudicator/execute.js +33 -33
  5. package/dist/neal/adjudicator/final-completion.js +2 -31
  6. package/dist/neal/adjudicator/planning.js +230 -38
  7. package/dist/neal/agents/prompts.js +6 -17
  8. package/dist/neal/agents/rounds.js +43 -73
  9. package/dist/neal/agents/schemas.js +47 -13
  10. package/dist/neal/blocked-guidance.js +16 -9
  11. package/dist/neal/cli.js +2 -2
  12. package/dist/neal/commands/check.js +2 -2
  13. package/dist/neal/commands/compat.js +100 -36
  14. package/dist/neal/commands/interactive-activity.js +2 -2
  15. package/dist/neal/commands/recovery-guidance.js +20 -8
  16. package/dist/neal/commands/runtime.js +4 -2
  17. package/dist/neal/config.js +16 -7
  18. package/dist/neal/context/inline-review-context.js +13 -28
  19. package/dist/neal/context/reviewer-context.js +19 -14
  20. package/dist/neal/eval/reviewer-eval-manifest.js +86 -0
  21. package/dist/neal/eval/reviewer-eval.js +155 -0
  22. package/dist/neal/interactive-controls.js +5 -0
  23. package/dist/neal/orchestrator/completion.js +49 -2
  24. package/dist/neal/orchestrator/failures.js +7 -0
  25. package/dist/neal/orchestrator/notifications.js +9 -9
  26. package/dist/neal/orchestrator/phases/planning.js +117 -80
  27. package/dist/neal/orchestrator/phases/recovery.js +151 -115
  28. package/dist/neal/orchestrator/phases/review.js +28 -1
  29. package/dist/neal/orchestrator/transitions.js +7 -1
  30. package/dist/neal/orchestrator.js +3 -0
  31. package/dist/neal/plan-queue.js +29 -1
  32. package/dist/neal/prompts/execute.js +7 -19
  33. package/dist/neal/prompts/planning.js +64 -55
  34. package/dist/neal/prompts/review-doctrine.js +13 -50
  35. package/dist/neal/prompts/specialized.js +7 -17
  36. package/dist/neal/prompts/specs.js +180 -32
  37. package/dist/neal/providers/agent-settings-isolation.js +35 -0
  38. package/dist/neal/providers/anthropic-claude.js +18 -1
  39. package/dist/neal/providers/detection.js +3 -7
  40. package/dist/neal/providers/git-config-isolation.js +83 -0
  41. package/dist/neal/providers/openai-codex.js +25 -3
  42. package/dist/neal/providers/{generic-agentic-tools.js → openai-compatible-tools.js} +11 -5
  43. package/dist/neal/providers/openai-compatible.js +1078 -430
  44. package/dist/neal/providers/openrouter-routing.js +68 -0
  45. package/dist/neal/providers/pricing.js +1 -1
  46. package/dist/neal/providers/rate-card.js +6 -32
  47. package/dist/neal/providers/registry.js +6 -17
  48. package/dist/neal/providers/types.js +12 -0
  49. package/dist/neal/resume-decision.js +7 -6
  50. package/dist/neal/resume-planner.js +9 -0
  51. package/dist/neal/retrospective.js +3 -3
  52. package/dist/neal/review-debt.js +30 -0
  53. package/dist/neal/review-findings/provider.js +17 -29
  54. package/dist/neal/review.js +17 -1
  55. package/dist/neal/state-invariants.js +11 -3
  56. package/dist/neal/state-views.js +27 -5
  57. package/dist/neal/state.js +59 -15
  58. package/dist/neal/status.js +28 -1
  59. package/dist/neal/support.js +3 -3
  60. package/docs/README.md +22 -18
  61. package/docs/{ADJUDICATOR_INVENTORY.md → adjudicator-inventory.md} +58 -57
  62. package/docs/architecture.md +79 -83
  63. package/docs/automation.md +11 -11
  64. package/docs/compat.md +91 -61
  65. package/docs/compatible-models.md +145 -121
  66. package/docs/demo.md +15 -5
  67. package/docs/maintenance.md +65 -34
  68. package/docs/plan-format.md +61 -33
  69. package/docs/prompt-evals.md +96 -0
  70. package/docs/{PROMPT_SPECS.md → prompt-specs.md} +52 -48
  71. package/docs/providers.md +279 -349
  72. package/docs/release.md +18 -19
  73. package/docs/review-convergence.md +246 -0
  74. package/docs/state-machine.md +61 -59
  75. package/docs/storage.md +40 -31
  76. package/docs/troubleshooting.md +32 -29
  77. package/examples/compat/add-edit-verify/PLAN.md +2 -0
  78. package/examples/compat/add-edit-verify/broken.diff +2 -2
  79. package/examples/compat/add-edit-verify/good.diff +2 -2
  80. package/examples/compat/add-edit-verify/src/add.js +1 -1
  81. package/examples/compat/manifest.json +25 -29
  82. package/examples/compat/sum-grep-edit/PLAN.md +33 -0
  83. package/examples/compat/sum-grep-edit/broken.diff +12 -0
  84. package/examples/compat/sum-grep-edit/good.diff +12 -0
  85. package/examples/compat/sum-grep-edit/package.json +5 -0
  86. package/examples/compat/sum-grep-edit/src/numbers.js +9 -0
  87. package/examples/compat/sum-grep-edit/test/numbers.test.js +15 -0
  88. package/examples/issue-triage-js/README.md +0 -15
  89. package/examples/reviewer-eval/clean-doc/base/src/clamp.ts +3 -0
  90. package/examples/reviewer-eval/clean-doc/change.diff +9 -0
  91. package/examples/reviewer-eval/clean-extract/base/src/greet.ts +3 -0
  92. package/examples/reviewer-eval/clean-extract/change.diff +13 -0
  93. package/examples/reviewer-eval/dropped-error/base/src/load.ts +4 -0
  94. package/examples/reviewer-eval/dropped-error/change.diff +15 -0
  95. package/examples/reviewer-eval/manifest.json +45 -0
  96. package/examples/reviewer-eval/off-by-one/base/src/paginate.ts +5 -0
  97. package/examples/reviewer-eval/off-by-one/change.diff +11 -0
  98. package/{config.yml → neal.yml} +1 -1
  99. package/package.json +15 -14
  100. package/dist/neal/providers/generic-agentic.js +0 -1256
  101. package/docs/comparison.md +0 -105
  102. package/docs/issue-pipeline.md +0 -124
  103. package/examples/compat/is-even-add-test/PLAN.md +0 -30
  104. package/examples/compat/is-even-add-test/broken.diff +0 -11
  105. package/examples/compat/is-even-add-test/good.diff +0 -11
  106. package/examples/compat/is-even-add-test/package.json +0 -5
  107. package/examples/compat/is-even-add-test/src/is-even.js +0 -3
  108. package/examples/compat/is-even-add-test/test/is-even.test.js +0 -9
  109. package/examples/compat/reverse-grep-edit/PLAN.md +0 -32
  110. package/examples/compat/reverse-grep-edit/broken.diff +0 -12
  111. package/examples/compat/reverse-grep-edit/good.diff +0 -12
  112. package/examples/compat/reverse-grep-edit/package.json +0 -5
  113. package/examples/compat/reverse-grep-edit/src/strings.js +0 -10
  114. package/examples/compat/reverse-grep-edit/test/strings.test.js +0 -15
@@ -1,12 +1,12 @@
1
1
  import { CoderRoundError, runBlockedRecoveryCoderRound, } from '../../agents.js';
2
- import { ADJUDICATOR_ELIGIBLE_SOURCE_PHASES, buildRecentBlockCandidate, isReviewerAdjudicatorPhase, runBlockedAdjudicator, upsertRecentBlock, } from '../../adjudicator/blocked-adjudicator.js';
2
+ import { CONSULTANT_ELIGIBLE_SOURCE_PHASES, buildRecentBlockCandidate, isReviewerConsultantPhase, runConsultant, upsertRecentBlock, } from '../../adjudicator/consultant.js';
3
3
  import { UNATTENDED_AUTO_RESUME_GUIDANCE } from '../../blocked-guidance.js';
4
- import { getInteractiveBlockedRecoveryMaxTurns, getReviewStuckArbiterMaxAttempts } from '../../config.js';
4
+ import { getInteractiveBlockedRecoveryMaxTurns, getConsultantMaxAttempts } from '../../config.js';
5
5
  import { EXECUTE_FINALIZATION_PHASE } from '../../execute-finalization.js';
6
6
  import { hasPendingOperatorGuidance } from '../../run-status.js';
7
7
  import { getExecutionPlanPath } from '../../scopes.js';
8
8
  import { loadState, saveState } from '../../state.js';
9
- import { isActivePendingDerivedPlanReview } from '../../state-views.js';
9
+ import { getInteractiveRecoveryView, isActivePendingDerivedPlanReview } from '../../state-views.js';
10
10
  import { writeExecutionArtifacts } from '../artifacts.js';
11
11
  import { isCoderTimeoutError, shouldNotifyFailure } from '../failures.js';
12
12
  import { flushDerivedPlanNotifications, notifyBlocked } from '../notifications.js';
@@ -48,8 +48,8 @@ function getInteractiveBlockedRecoverySourcePhase(phase) {
48
48
  function isInteractiveBlockedRecoveryTopLevelMode(state) {
49
49
  return state.topLevelMode === 'execute';
50
50
  }
51
- // The generalized adjudicator triages two block classes whose source phase is in
52
- // `ADJUDICATOR_ELIGIBLE_SOURCE_PHASES`:
51
+ // The generalized consultant triages two block classes whose source phase is in
52
+ // `CONSULTANT_ELIGIBLE_SOURCE_PHASES`:
53
53
  // - a coder-blocked signal (`coder_scope`/`coder_response`/`coder_optional_response`,
54
54
  // which also carries the rerouted split-plan invalid-payload block): the coder
55
55
  // emits free-text blockers with no structural prefix, so ANY coder block on these
@@ -62,53 +62,54 @@ function isInteractiveBlockedRecoveryTopLevelMode(state) {
62
62
  // Every other accepted source phase (`coder_plan`, `coder_plan_response`,
63
63
  // `coder_plan_optional_response`, `awaiting_derived_plan_execution`,
64
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)) {
65
+ // generic recovery behavior with zero consultant invocations.
66
+ function isConsultantEligibleBlock(reason, sourcePhase) {
67
+ if (!CONSULTANT_ELIGIBLE_SOURCE_PHASES.has(sourcePhase)) {
68
68
  return false;
69
69
  }
70
- if (isReviewerAdjudicatorPhase(sourcePhase)) {
70
+ if (isReviewerConsultantPhase(sourcePhase)) {
71
71
  return reason.startsWith('review_stuck:');
72
72
  }
73
73
  return true;
74
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
75
+ // Whether the per-scope consultant budget allows another invocation. Returns
76
+ // false when the disable knob is 0 or `consultantAttemptCount` has reached the
77
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);
78
+ // budget-exhausted cases emit NO `consultant.*` events so they preserve
79
+ // the generic recovery path byte-for-byte: a disabled or exhausted consultant
80
+ // 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.
87
+ function isConsultantBudgetAvailable(state) {
88
+ const maxAttempts = getConsultantMaxAttempts(state.cwd);
88
89
  if (maxAttempts <= 0) {
89
90
  return false;
90
91
  }
91
- return state.reviewStuckArbiterCount < maxAttempts;
92
+ return state.consultantAttemptCount < maxAttempts;
92
93
  }
93
94
  // 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
+ // consultant (which may itself short-circuit on an anti-thrash repeat) and, on a
95
96
  // recoverable verdict with a concrete in-scope directive, enters interactive
96
97
  // recovery with that directive injected as the pending turn — bounded by a
97
- // SEPARATE counter (`reviewStuckArbiterCount`) that never touches
98
+ // SEPARATE counter (`consultantAttemptCount`) that never touches
98
99
  // `unattendedAutoResumeCount` or the recovery turn cap. A non-recoverable verdict
99
100
  // (including a thrash repeat) is finalized TERMINALLY rather than silently
100
- // auto-resumed. Every gate that prevents the adjudicator from running — an
101
+ // auto-resumed. Every gate that prevents the consultant from running — an
101
102
  // 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
+ // consultant error — returns null so the caller falls through to the existing
103
104
  // 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 consultant itself makes zero commits and zero file edits; this function is
105
106
  // the sole writer of `recentBlocks`, and only on the branches where the
106
- // adjudicator actually ran.
107
+ // consultant actually ran.
107
108
  async function maybeResolveBlockedUnattended(state, statePath, reason, sourcePhase, nextRecovery, logger) {
108
- if (!isAdjudicatorEligibleBlock(reason, sourcePhase)) {
109
+ if (!isConsultantEligibleBlock(reason, sourcePhase)) {
109
110
  return null;
110
111
  }
111
- if (!isAdjudicatorBudgetAvailable(state)) {
112
+ if (!isConsultantBudgetAvailable(state)) {
112
113
  return null;
113
114
  }
114
115
  // Never push past the recovery turn cap; if there is no room for a recovery
@@ -116,20 +117,20 @@ async function maybeResolveBlockedUnattended(state, statePath, reason, sourcePha
116
117
  if (nextRecovery.turns.length >= nextRecovery.maxTurns) {
117
118
  return null;
118
119
  }
119
- await logger?.event('review_stuck_arbiter.start', {
120
+ await logger?.event('consultant.start', {
120
121
  scopeNumber: state.currentScopeNumber,
121
122
  sourcePhase,
122
123
  blockedReason: reason,
123
124
  });
124
125
  let verdict;
125
126
  try {
126
- verdict = await runBlockedAdjudicator(state, reason, sourcePhase, logger);
127
+ verdict = await runConsultant(state, reason, sourcePhase, logger);
127
128
  }
128
129
  catch (error) {
129
- // An adjudicator failure must never crash the run or weaken existing recovery;
130
+ // An consultant failure must never crash the run or weaken existing recovery;
130
131
  // 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', {
132
+ // unchanged (the consultant did not complete for this block).
133
+ await logger?.event('consultant.declined', {
133
134
  scopeNumber: state.currentScopeNumber,
134
135
  sourcePhase,
135
136
  blockedReason: reason,
@@ -137,11 +138,11 @@ async function maybeResolveBlockedUnattended(state, statePath, reason, sourcePha
137
138
  });
138
139
  return null;
139
140
  }
140
- // The adjudicator ran (possibly short-circuiting internally on a thrash repeat),
141
+ // The consultant ran (possibly short-circuiting internally on a thrash repeat),
141
142
  // so this block is recorded in the anti-thrash window regardless of the verdict.
142
143
  // The candidate is built from the PRE-update array and written in this same
143
144
  // 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
+ // `state` snapshot `runConsultant` checked: the candidate's evidence
145
146
  // fingerprint is derived from `state.createdCommits`, and a divergent snapshot
146
147
  // would record a fingerprint the guard never compared against.
147
148
  const candidate = buildRecentBlockCandidate(state, reason, sourcePhase);
@@ -150,22 +151,22 @@ async function maybeResolveBlockedUnattended(state, statePath, reason, sourcePha
150
151
  if (!verdict.recoverable || !resolutionDirective) {
151
152
  // Genuine wall (recoverable:false) or a thrash repeat: an unattended run has
152
153
  // 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
154
+ // generic auto-resume. The consultant actually ran, so this branch consumes
155
+ // one unit of the shared per-scope budget (`consultantAttemptCount`) exactly
155
156
  // like the recoverable branch, and persists the anti-thrash record via the
156
- // threaded `recentBlocks`. Only the fallback paths where `runBlockedAdjudicator`
157
+ // threaded `recentBlocks`. Only the fallback paths where `runConsultant`
157
158
  // was never invoked leave the budget untouched.
158
- await logger?.event('review_stuck_arbiter.declined', {
159
+ await logger?.event('consultant.declined', {
159
160
  scopeNumber: state.currentScopeNumber,
160
161
  sourcePhase,
161
162
  blockedReason: reason,
162
163
  recoverable: verdict.recoverable,
163
164
  triageCategory: verdict.triageCategory,
164
- reviewStuckArbiterCount: state.reviewStuckArbiterCount + 1,
165
+ consultantAttemptCount: state.consultantAttemptCount + 1,
165
166
  });
166
- return failUnattendedRecoveryTerminally({ ...state, recentBlocks, reviewStuckArbiterCount: state.reviewStuckArbiterCount + 1 }, statePath, 'terminal_block', state.coderSessionHandle, logger);
167
+ return failUnattendedRecoveryTerminally({ ...state, recentBlocks, consultantAttemptCount: state.consultantAttemptCount + 1 }, statePath, 'terminal_block', state.coderSessionHandle, logger);
167
168
  }
168
- await logger?.event('review_stuck_arbiter.verdict', {
169
+ await logger?.event('consultant.verdict', {
169
170
  scopeNumber: state.currentScopeNumber,
170
171
  sourcePhase,
171
172
  blockedReason: reason,
@@ -174,92 +175,69 @@ async function maybeResolveBlockedUnattended(state, statePath, reason, sourcePha
174
175
  targetCanonicalIds: verdict.targetCanonicalIds,
175
176
  // Report the post-increment count this verdict is about to consume so the
176
177
  // verdict and the later `resolved` event agree on the budget figure.
177
- reviewStuckArbiterCount: state.reviewStuckArbiterCount + 1,
178
+ consultantAttemptCount: state.consultantAttemptCount + 1,
178
179
  });
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,
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,
207
186
  sourcePhase,
208
- blockedReason: reason,
209
- recoverable: verdict.recoverable,
210
- triageCategory: verdict.triageCategory,
211
- targetCanonicalIds: verdict.targetCanonicalIds,
212
- reviewStuckArbiterCount: resolvedState.reviewStuckArbiterCount,
187
+ nextRecovery,
188
+ recentBlocks,
189
+ resolutionDirective,
190
+ verdict,
191
+ logger,
213
192
  });
214
- return resolvedState;
215
193
  }
216
- // Attended interception for every eligible block class. The adjudicator NEVER
194
+ // Attended interception for every eligible block class. The consultant NEVER
217
195
  // auto-applies its verdict in attended mode; instead it triages read-only and the
218
196
  // verdict is returned as advice (plus the updated anti-thrash window) for the
219
197
  // caller to persist alongside the operator yield. Gated by the SAME eligibility +
220
198
  // 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
199
+ // consultant (ineligible phase, knob 0, exhausted budget, or an consultant
222
200
  // error) this returns null and the caller yields exactly as today with no advice,
223
201
  // no budget consumption, and `recentBlocks` unchanged.
224
202
  //
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
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
205
+ // generic observable surface: zero consultant events, no advice, and no
228
206
  // 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)) {
207
+ // once the consultant has actually produced a verdict for this block.
208
+ async function buildAttendedConsultantAdvice(state, reason, sourcePhase, logger) {
209
+ if (!isConsultantEligibleBlock(reason, sourcePhase)) {
232
210
  return null;
233
211
  }
234
- if (!isAdjudicatorBudgetAvailable(state)) {
212
+ if (!isConsultantBudgetAvailable(state)) {
235
213
  return null;
236
214
  }
237
215
  let verdict;
238
216
  try {
239
- verdict = await runBlockedAdjudicator(state, reason, sourcePhase, logger);
217
+ verdict = await runConsultant(state, reason, sourcePhase, logger);
240
218
  }
241
219
  catch {
242
220
  // 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
221
+ // `consultant.*` events so the fallback is indistinguishable from the
244
222
  // disabled/exhausted/ineligible generic yield.
245
223
  return null;
246
224
  }
247
- await logger?.event('review_stuck_arbiter.start', {
225
+ await logger?.event('consultant.start', {
248
226
  scopeNumber: state.currentScopeNumber,
249
227
  sourcePhase,
250
228
  blockedReason: reason,
251
229
  });
252
- await logger?.event('review_stuck_arbiter.verdict', {
230
+ await logger?.event('consultant.verdict', {
253
231
  scopeNumber: state.currentScopeNumber,
254
232
  sourcePhase,
255
233
  blockedReason: reason,
256
234
  recoverable: verdict.recoverable,
257
235
  triageCategory: verdict.triageCategory,
258
236
  targetCanonicalIds: verdict.targetCanonicalIds,
259
- reviewStuckArbiterCount: state.reviewStuckArbiterCount + 1,
237
+ consultantAttemptCount: state.consultantAttemptCount + 1,
260
238
  });
261
239
  // Same-snapshot rule as the unattended writer: the recorded candidate's
262
- // commit-trail evidence fingerprint must come from the `state` the adjudicator
240
+ // commit-trail evidence fingerprint must come from the `state` the consultant
263
241
  // just checked, never a fresher snapshot.
264
242
  const candidate = buildRecentBlockCandidate(state, reason, sourcePhase);
265
243
  const recentBlocks = upsertRecentBlock(state.recentBlocks, candidate);
@@ -270,7 +248,47 @@ async function buildAttendedAdjudicatorAdvice(state, reason, sourcePhase, logger
270
248
  resolutionDirective: verdict.resolutionDirective,
271
249
  rationale: verdict.rationale,
272
250
  };
273
- return { advice, recentBlocks };
251
+ return { advice, recentBlocks, verdict };
252
+ }
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.
260
+ async function applyRecoverableConsultantDirective(args) {
261
+ const { state, statePath, reason, sourcePhase, nextRecovery, recentBlocks, resolutionDirective, verdict, logger } = args;
262
+ const enteredState = await saveState(statePath, {
263
+ ...state,
264
+ recentBlocks,
265
+ phase: 'interactive_blocked_recovery',
266
+ status: 'running',
267
+ blockedFromPhase: state.blockedFromPhase ?? state.phase,
268
+ consultantAttemptCount: state.consultantAttemptCount + 1,
269
+ interactiveBlockedRecovery: {
270
+ ...nextRecovery,
271
+ sourcePhase,
272
+ blockedReason: reason,
273
+ },
274
+ });
275
+ await writeExecutionArtifacts(enteredState);
276
+ await logger?.event('interactive_blocked_recovery.entered', {
277
+ scopeNumber: enteredState.currentScopeNumber,
278
+ sourcePhase: enteredState.interactiveBlockedRecovery?.sourcePhase,
279
+ blockedReason: reason,
280
+ });
281
+ const resolvedState = await recordInteractiveBlockedRecoveryGuidance(statePath, resolutionDirective, logger);
282
+ await logger?.event('consultant.resolved', {
283
+ scopeNumber: resolvedState.currentScopeNumber,
284
+ sourcePhase,
285
+ blockedReason: reason,
286
+ recoverable: verdict.recoverable,
287
+ triageCategory: verdict.triageCategory,
288
+ targetCanonicalIds: verdict.targetCanonicalIds,
289
+ consultantAttemptCount: resolvedState.consultantAttemptCount,
290
+ });
291
+ return resolvedState;
274
292
  }
275
293
  export async function enterInteractiveBlockedRecovery(state, statePath, reason, logger) {
276
294
  if (!isInteractiveBlockedRecoveryTopLevelMode(state)) {
@@ -293,14 +311,14 @@ export async function enterInteractiveBlockedRecovery(state, statePath, reason,
293
311
  // the persisted counter and the recovery turn cap, never on guidance text.
294
312
  if (state.unattended) {
295
313
  // Before the generic auto-resume/terminal-fail decision, give the bounded
296
- // read-only adjudicator a chance to triage the block: autonomously resolve it
314
+ // read-only consultant a chance to triage the block: autonomously resolve it
297
315
  // with an in-scope directive (recoverable) or finalize terminally (a genuine
298
316
  // wall or thrash repeat). Any ineligible source phase, disabled/exhausted
299
- // budget, turn cap, or adjudicator error falls through to the existing generic
317
+ // budget, turn cap, or consultant error falls through to the existing generic
300
318
  // behavior unchanged.
301
- const adjudicatorResolved = await maybeResolveBlockedUnattended(state, statePath, reason, sourcePhase, nextRecovery, logger);
302
- if (adjudicatorResolved) {
303
- return adjudicatorResolved;
319
+ const consultantResolved = await maybeResolveBlockedUnattended(state, statePath, reason, sourcePhase, nextRecovery, logger);
320
+ if (consultantResolved) {
321
+ return consultantResolved;
304
322
  }
305
323
  const canAutoResume = state.unattendedAutoResumeCount < UNATTENDED_MAX_AUTO_RESUMES &&
306
324
  nextRecovery.turns.length < nextRecovery.maxTurns;
@@ -341,16 +359,33 @@ export async function enterInteractiveBlockedRecovery(state, statePath, reason,
341
359
  // loop then proceeds into runInteractiveBlockedRecoveryPhase to consume it.
342
360
  return recordInteractiveBlockedRecoveryGuidance(statePath, UNATTENDED_AUTO_RESUME_GUIDANCE, logger);
343
361
  }
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);
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
365
+ // (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);
369
+ if (advisory) {
370
+ const resolutionDirective = advisory.advice.resolutionDirective.trim();
371
+ if (advisory.advice.recoverable && resolutionDirective) {
372
+ return applyRecoverableConsultantDirective({
373
+ state,
374
+ statePath,
375
+ reason,
376
+ sourcePhase,
377
+ nextRecovery,
378
+ recentBlocks: advisory.recentBlocks,
379
+ resolutionDirective,
380
+ verdict: advisory.verdict,
381
+ logger,
382
+ });
383
+ }
384
+ }
350
385
  const nextState = await saveState(statePath, {
351
386
  ...state,
352
387
  ...(advisory
353
- ? { recentBlocks: advisory.recentBlocks, reviewStuckArbiterCount: state.reviewStuckArbiterCount + 1 }
388
+ ? { recentBlocks: advisory.recentBlocks, consultantAttemptCount: state.consultantAttemptCount + 1 }
354
389
  : {}),
355
390
  phase: 'interactive_blocked_recovery',
356
391
  status: 'running',
@@ -359,7 +394,7 @@ export async function enterInteractiveBlockedRecovery(state, statePath, reason,
359
394
  ...nextRecovery,
360
395
  sourcePhase,
361
396
  blockedReason: reason,
362
- ...(advisory ? { adjudicatorAdvice: advisory.advice } : {}),
397
+ ...(advisory ? { consultantAdvice: advisory.advice } : {}),
363
398
  },
364
399
  });
365
400
  await writeExecutionArtifacts(nextState);
@@ -371,14 +406,15 @@ export async function enterInteractiveBlockedRecovery(state, statePath, reason,
371
406
  return nextState;
372
407
  }
373
408
  // 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.
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.
380
416
  export function shouldNotifyInteractiveBlockedRecoveryEntry(state) {
381
- return !state.unattended && state.phase === 'interactive_blocked_recovery';
417
+ return !state.unattended && (getInteractiveRecoveryView(state)?.waitingForOperatorGuidance ?? false);
382
418
  }
383
419
  export async function recordInteractiveBlockedRecoveryGuidance(statePath, operatorGuidance, logger) {
384
420
  const trimmedGuidance = operatorGuidance.trim();
@@ -5,7 +5,7 @@ import { getChangedFilesForRange, getCommitRange, getDiffForRange, getDiffStatFo
5
5
  import { writeDetail } from '../../diagnostic.js';
6
6
  import { saveState } from '../../state.js';
7
7
  import { writeExecutionArtifacts } from '../artifacts.js';
8
- import { shouldNotifyFailure } from '../failures.js';
8
+ import { REVIEWER_CONTENT_REFUSED_BLOCK_REASON, shouldNotifyFailure } from '../failures.js';
9
9
  import { notifyBlocked } from '../notifications.js';
10
10
  import { enterInteractiveBlockedRecovery, shouldNotifyInteractiveBlockedRecoveryEntry } from './recovery.js';
11
11
  import { printReviewResult } from './shared.js';
@@ -52,6 +52,33 @@ export async function runReviewPhase(state, statePath, logger) {
52
52
  }
53
53
  catch (error) {
54
54
  if (error instanceof ReviewerRoundError) {
55
+ // A content-safety refusal is a distinct, terminal, non-coder-recoverable
56
+ // condition: end the run blocked (exit 2) with a durable actionable reason
57
+ // instead of a generic terminal failure. This must RETURN the terminal
58
+ // blocked state (phase:'blocked'/status:'blocked'), never throw — a thrown
59
+ // error over a persisted blocked state escapes executeRun, and the run loop
60
+ // only exits cleanly on a returned terminal phase.
61
+ if (error.kind === 'content_refused') {
62
+ const blockedState = await saveState(statePath, {
63
+ ...state,
64
+ reviewerSessionHandle: null,
65
+ phase: 'blocked',
66
+ status: 'blocked',
67
+ blockedFromPhase: null,
68
+ blockerReason: REVIEWER_CONTENT_REFUSED_BLOCK_REASON,
69
+ });
70
+ await writeExecutionArtifacts(blockedState);
71
+ await logger?.event('phase.error', {
72
+ phase: 'reviewer_scope',
73
+ round: state.rounds.length + 1,
74
+ sessionHandle: error.sessionHandle,
75
+ subtype: error.subtype,
76
+ errorKind: error.kind,
77
+ message: error.message,
78
+ });
79
+ await notifyBlocked(blockedState, REVIEWER_CONTENT_REFUSED_BLOCK_REASON, logger);
80
+ return blockedState;
81
+ }
55
82
  const failedState = await saveState(statePath, {
56
83
  ...state,
57
84
  reviewerSessionHandle: null,
@@ -8,8 +8,14 @@ export function createScopeBoundaryReset() {
8
8
  currentScopeMeaningfulProgressVerdict: null,
9
9
  rounds: [],
10
10
  recentBlocks: [],
11
- reviewStuckArbiterCount: 0,
11
+ consultantAttemptCount: 0,
12
12
  findings: [],
13
+ // planReviewDebt is a projection of the current findings, so clearing
14
+ // findings must clear it too (toPlanReviewDebt([]) === []); otherwise a new
15
+ // derived-plan negotiation would carry stale current-negotiation debt. The
16
+ // durable inheritedPlanReviewDebt is intentionally NOT reset here — it is
17
+ // write-once and survives scope boundaries.
18
+ planReviewDebt: [],
13
19
  createdCommits: [],
14
20
  };
15
21
  }
@@ -81,6 +81,9 @@ export async function initializeOrchestration(planDoc, cwd, agentConfig, topLeve
81
81
  reviewMarkdownPath: join(logger.runDir, 'REVIEW.md'),
82
82
  recoveryMarkdownPath: join(logger.runDir, 'RECOVERY.md'),
83
83
  maxRounds: getMaxReviewRounds(cwd),
84
+ // Only the `neal run` queue's execution stage supplies this; every other
85
+ // caller omits it and the durable inherited debt seeds to an empty array.
86
+ inheritedPlanReviewDebt: options?.inheritedPlanReviewDebt,
84
87
  };
85
88
  await mkdir(stateDir, { recursive: true });
86
89
  const baseCommit = await getHeadCommit(cwd);
@@ -9,7 +9,7 @@ import { writeExecutionArtifacts } from './orchestrator/artifacts.js';
9
9
  import { assertAgentConfigSupportsResume, assertAgentConfigSupportsWriterRun } from './providers/registry.js';
10
10
  import { writeCurrentRunPointer } from './run-registry.js';
11
11
  import { formatPublicRunStatus, getRunDisplayStatus } from './run-status.js';
12
- import { getRunStatePath, loadState, saveState } from './state.js';
12
+ import { getRunStatePath, hydrateResidualReviewDebtItem, loadState, saveState } from './state.js';
13
13
  import { QUEUE_LINK_FILE, QUEUE_STATE_FILE, QUEUE_SUMMARY_FILE, getCurrentPlanAndExecuteQueuePointerPath as getStorageCurrentPlanAndExecuteQueuePointerPath, getQueuesDir, } from './storage-paths.js';
14
14
  import { executeRun, withPreparedWriterRun, } from './commands/runtime.js';
15
15
  import { filterAllowedDirtyPathStatus, filterWrapperOwnedWorktreeStatus, parseWorktreeStatusLine, } from './worktree-status.js';
@@ -114,6 +114,7 @@ export async function createPlanAndExecuteQueue(args) {
114
114
  planningRunId: null,
115
115
  planningStatePath: null,
116
116
  acceptedPlanPath: null,
117
+ planReviewDebt: [],
117
118
  executionRunId: null,
118
119
  executionStatePath: null,
119
120
  activeStage: null,
@@ -496,6 +497,10 @@ async function runFreshPlanAndExecuteChild(args, onInitialized) {
496
497
  // initialized, the run state is the single source of truth for
497
498
  // this run — `neal resume` reads it back from there.
498
499
  autoSquashOnCompletion: args.squashOnCompletion,
500
+ // Only the execution child inherits the accepted plan's plan-review
501
+ // debt (into the durable inheritedPlanReviewDebt state field). The
502
+ // planning child recomputes its own planReviewDebt from scratch.
503
+ inheritedPlanReviewDebt: args.stage === 'execution' ? args.item.planReviewDebt : undefined,
499
504
  });
500
505
  markInitialized();
501
506
  assertAgentConfigSupportsWriterRun(loaded.state.agentConfig, { context: `${args.stage} queue child` });
@@ -581,6 +586,11 @@ async function completePlanningStage(state, itemIndex, finalState, deps) {
581
586
  ...item,
582
587
  status: 'planned',
583
588
  acceptedPlanPath,
589
+ // Carry the accepted plan's current-negotiation debt onto the queue item
590
+ // so the fresh execution child inherits it (both the in-process completion
591
+ // and the cross-process continuePlanAndExecuteQueueFromChildRun resume
592
+ // route through here).
593
+ planReviewDebt: finalState.planReviewDebt,
584
594
  activeStage: null,
585
595
  stopReason: null,
586
596
  }),
@@ -916,6 +926,10 @@ function parsePlanAndExecuteQueueItem(value, expectedIndex, cwd) {
916
926
  planningRunId: requireNullableString(item.planningRunId, 'Invalid queue state: malformed planningRunId'),
917
927
  planningStatePath,
918
928
  acceptedPlanPath,
929
+ // Legacy-tolerant: queue items persisted before this field existed default
930
+ // to an empty debt array. Present arrays are hydrated with the shared
931
+ // residual-debt validator so an older/newer field shape is caught.
932
+ planReviewDebt: parseQueueItemPlanReviewDebt(item.planReviewDebt),
919
933
  executionRunId: requireNullableString(item.executionRunId, 'Invalid queue state: malformed executionRunId'),
920
934
  executionStatePath,
921
935
  activeStage: requireNullableQueueChildStage(item.activeStage, 'Invalid queue state: malformed activeStage'),
@@ -924,6 +938,20 @@ function parsePlanAndExecuteQueueItem(value, expectedIndex, cwd) {
924
938
  stopReason: requireNullableString(item.stopReason, 'Invalid queue state: malformed item stopReason'),
925
939
  };
926
940
  }
941
+ function parseQueueItemPlanReviewDebt(value) {
942
+ // Legacy-tolerant ONLY for a genuinely absent field: queue items persisted
943
+ // before this field existed omit it entirely (undefined), and those default to
944
+ // an empty debt array. A PRESENT null (or any other non-array) is corrupted
945
+ // state that would silently erase accepted plan-review debt, so it is rejected
946
+ // as malformed rather than defaulted away.
947
+ if (value === undefined) {
948
+ return [];
949
+ }
950
+ if (!Array.isArray(value)) {
951
+ throw new Error('Invalid queue state: malformed planReviewDebt');
952
+ }
953
+ return value.map((item, index) => hydrateResidualReviewDebtItem(item, `queue item planReviewDebt[${index}]`));
954
+ }
927
955
  function parseCurrentPlanAndExecuteQueuePointer(value, cwd) {
928
956
  if (!value || typeof value !== 'object') {
929
957
  throw new Error('Invalid .neal/current-queue.json: expected object');