@navels/neal 0.2.0 → 0.3.1
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.
- package/README.md +206 -209
- package/SECURITY.md +16 -19
- package/dist/neal/adjudicator/{blocked-adjudicator.js → consultant.js} +21 -21
- package/dist/neal/adjudicator/execute.js +33 -33
- package/dist/neal/adjudicator/final-completion.js +2 -31
- package/dist/neal/adjudicator/planning.js +230 -38
- package/dist/neal/agents/prompts.js +6 -17
- package/dist/neal/agents/rounds.js +43 -73
- package/dist/neal/agents/schemas.js +47 -13
- package/dist/neal/blocked-guidance.js +16 -9
- package/dist/neal/cli.js +2 -2
- package/dist/neal/commands/check.js +2 -2
- package/dist/neal/commands/compat.js +100 -36
- package/dist/neal/commands/interactive-activity.js +2 -2
- package/dist/neal/commands/recovery-guidance.js +20 -8
- package/dist/neal/commands/runtime.js +4 -2
- package/dist/neal/config.js +16 -7
- package/dist/neal/context/inline-review-context.js +13 -28
- package/dist/neal/context/reviewer-context.js +19 -14
- package/dist/neal/eval/reviewer-eval-manifest.js +86 -0
- package/dist/neal/eval/reviewer-eval.js +155 -0
- package/dist/neal/interactive-controls.js +5 -0
- package/dist/neal/orchestrator/completion.js +49 -2
- package/dist/neal/orchestrator/failures.js +7 -0
- package/dist/neal/orchestrator/notifications.js +9 -9
- package/dist/neal/orchestrator/phases/planning.js +117 -80
- package/dist/neal/orchestrator/phases/recovery.js +151 -115
- package/dist/neal/orchestrator/phases/review.js +28 -1
- package/dist/neal/orchestrator/transitions.js +7 -1
- package/dist/neal/orchestrator.js +3 -0
- package/dist/neal/plan-queue.js +29 -1
- package/dist/neal/prompts/execute.js +7 -19
- package/dist/neal/prompts/planning.js +64 -55
- package/dist/neal/prompts/review-doctrine.js +13 -50
- package/dist/neal/prompts/specialized.js +7 -17
- package/dist/neal/prompts/specs.js +180 -32
- package/dist/neal/providers/agent-settings-isolation.js +35 -0
- package/dist/neal/providers/anthropic-claude.js +18 -1
- package/dist/neal/providers/detection.js +3 -7
- package/dist/neal/providers/git-config-isolation.js +83 -0
- package/dist/neal/providers/openai-codex.js +25 -3
- package/dist/neal/providers/{generic-agentic-tools.js → openai-compatible-tools.js} +11 -5
- package/dist/neal/providers/openai-compatible.js +1078 -430
- package/dist/neal/providers/openrouter-routing.js +68 -0
- package/dist/neal/providers/pricing.js +1 -1
- package/dist/neal/providers/rate-card.js +6 -32
- package/dist/neal/providers/registry.js +6 -17
- package/dist/neal/providers/types.js +12 -0
- package/dist/neal/resume-decision.js +7 -6
- package/dist/neal/resume-planner.js +9 -0
- package/dist/neal/retrospective.js +3 -3
- package/dist/neal/review-debt.js +30 -0
- package/dist/neal/review-findings/provider.js +17 -29
- package/dist/neal/review.js +17 -1
- package/dist/neal/state-invariants.js +11 -3
- package/dist/neal/state-views.js +27 -5
- package/dist/neal/state.js +59 -15
- package/dist/neal/status.js +28 -1
- package/dist/neal/support.js +3 -3
- package/docs/README.md +22 -18
- package/docs/{ADJUDICATOR_INVENTORY.md → adjudicator-inventory.md} +58 -57
- package/docs/architecture.md +79 -83
- package/docs/automation.md +11 -11
- package/docs/compat.md +91 -61
- package/docs/compatible-models.md +145 -121
- package/docs/demo.md +15 -5
- package/docs/maintenance.md +65 -34
- package/docs/plan-format.md +61 -33
- package/docs/prompt-evals.md +96 -0
- package/docs/{PROMPT_SPECS.md → prompt-specs.md} +52 -48
- package/docs/providers.md +279 -349
- package/docs/release.md +63 -81
- package/docs/review-convergence.md +246 -0
- package/docs/state-machine.md +61 -59
- package/docs/storage.md +40 -31
- package/docs/troubleshooting.md +32 -29
- package/examples/compat/add-edit-verify/PLAN.md +2 -0
- package/examples/compat/add-edit-verify/broken.diff +2 -2
- package/examples/compat/add-edit-verify/good.diff +2 -2
- package/examples/compat/add-edit-verify/src/add.js +1 -1
- package/examples/compat/manifest.json +25 -29
- package/examples/compat/sum-grep-edit/PLAN.md +33 -0
- package/examples/compat/sum-grep-edit/broken.diff +12 -0
- package/examples/compat/sum-grep-edit/good.diff +12 -0
- package/examples/compat/sum-grep-edit/package.json +5 -0
- package/examples/compat/sum-grep-edit/src/numbers.js +9 -0
- package/examples/compat/sum-grep-edit/test/numbers.test.js +15 -0
- package/examples/issue-triage-js/README.md +0 -15
- package/examples/reviewer-eval/clean-doc/base/src/clamp.ts +3 -0
- package/examples/reviewer-eval/clean-doc/change.diff +9 -0
- package/examples/reviewer-eval/clean-extract/base/src/greet.ts +3 -0
- package/examples/reviewer-eval/clean-extract/change.diff +13 -0
- package/examples/reviewer-eval/dropped-error/base/src/load.ts +4 -0
- package/examples/reviewer-eval/dropped-error/change.diff +15 -0
- package/examples/reviewer-eval/manifest.json +45 -0
- package/examples/reviewer-eval/off-by-one/base/src/paginate.ts +5 -0
- package/examples/reviewer-eval/off-by-one/change.diff +11 -0
- package/{config.yml → neal.yml} +1 -1
- package/package.json +15 -14
- package/dist/neal/providers/generic-agentic.js +0 -1256
- package/docs/comparison.md +0 -105
- package/docs/issue-pipeline.md +0 -124
- package/examples/compat/is-even-add-test/PLAN.md +0 -30
- package/examples/compat/is-even-add-test/broken.diff +0 -11
- package/examples/compat/is-even-add-test/good.diff +0 -11
- package/examples/compat/is-even-add-test/package.json +0 -5
- package/examples/compat/is-even-add-test/src/is-even.js +0 -3
- package/examples/compat/is-even-add-test/test/is-even.test.js +0 -9
- package/examples/compat/reverse-grep-edit/PLAN.md +0 -32
- package/examples/compat/reverse-grep-edit/broken.diff +0 -12
- package/examples/compat/reverse-grep-edit/good.diff +0 -12
- package/examples/compat/reverse-grep-edit/package.json +0 -5
- package/examples/compat/reverse-grep-edit/src/strings.js +0 -10
- 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 {
|
|
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,
|
|
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
|
|
52
|
-
// `
|
|
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
|
|
66
|
-
function
|
|
67
|
-
if (!
|
|
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 (
|
|
70
|
+
if (isReviewerConsultantPhase(sourcePhase)) {
|
|
71
71
|
return reason.startsWith('review_stuck:');
|
|
72
72
|
}
|
|
73
73
|
return true;
|
|
74
74
|
}
|
|
75
|
-
// Whether the per-scope
|
|
76
|
-
// false when the disable knob is 0 or `
|
|
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 `
|
|
79
|
-
// the generic recovery path byte-for-byte: a disabled or exhausted
|
|
80
|
-
// must be indistinguishable from the
|
|
81
|
-
// The single uniform budget is shared by both run modes — an invocation
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
|
|
87
|
-
|
|
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.
|
|
92
|
+
return state.consultantAttemptCount < maxAttempts;
|
|
92
93
|
}
|
|
93
94
|
// Unattended interception for every eligible block class. Runs the read-only
|
|
94
|
-
//
|
|
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 (`
|
|
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
|
|
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
|
-
//
|
|
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
|
|
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
|
-
//
|
|
107
|
+
// consultant actually ran.
|
|
107
108
|
async function maybeResolveBlockedUnattended(state, statePath, reason, sourcePhase, nextRecovery, logger) {
|
|
108
|
-
if (!
|
|
109
|
+
if (!isConsultantEligibleBlock(reason, sourcePhase)) {
|
|
109
110
|
return null;
|
|
110
111
|
}
|
|
111
|
-
if (!
|
|
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('
|
|
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
|
|
127
|
+
verdict = await runConsultant(state, reason, sourcePhase, logger);
|
|
127
128
|
}
|
|
128
129
|
catch (error) {
|
|
129
|
-
// An
|
|
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
|
|
132
|
-
await logger?.event('
|
|
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
|
|
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 `
|
|
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
|
|
154
|
-
// one unit of the shared per-scope budget (`
|
|
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 `
|
|
157
|
+
// threaded `recentBlocks`. Only the fallback paths where `runConsultant`
|
|
157
158
|
// was never invoked leave the budget untouched.
|
|
158
|
-
await logger?.event('
|
|
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
|
-
|
|
165
|
+
consultantAttemptCount: state.consultantAttemptCount + 1,
|
|
165
166
|
});
|
|
166
|
-
return failUnattendedRecoveryTerminally({ ...state, recentBlocks,
|
|
167
|
+
return failUnattendedRecoveryTerminally({ ...state, recentBlocks, consultantAttemptCount: state.consultantAttemptCount + 1 }, statePath, 'terminal_block', state.coderSessionHandle, logger);
|
|
167
168
|
}
|
|
168
|
-
await logger?.event('
|
|
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
|
-
|
|
178
|
+
consultantAttemptCount: state.consultantAttemptCount + 1,
|
|
178
179
|
});
|
|
179
|
-
//
|
|
180
|
-
// the
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
|
226
|
-
//
|
|
227
|
-
// generic observable surface: zero
|
|
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
|
|
230
|
-
async function
|
|
231
|
-
if (!
|
|
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 (!
|
|
212
|
+
if (!isConsultantBudgetAvailable(state)) {
|
|
235
213
|
return null;
|
|
236
214
|
}
|
|
237
215
|
let verdict;
|
|
238
216
|
try {
|
|
239
|
-
verdict = await
|
|
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
|
-
// `
|
|
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('
|
|
225
|
+
await logger?.event('consultant.start', {
|
|
248
226
|
scopeNumber: state.currentScopeNumber,
|
|
249
227
|
sourcePhase,
|
|
250
228
|
blockedReason: reason,
|
|
251
229
|
});
|
|
252
|
-
await logger?.event('
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
317
|
+
// budget, turn cap, or consultant error falls through to the existing generic
|
|
300
318
|
// behavior unchanged.
|
|
301
|
-
const
|
|
302
|
-
if (
|
|
303
|
-
return
|
|
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
|
|
345
|
-
//
|
|
346
|
-
//
|
|
347
|
-
//
|
|
348
|
-
//
|
|
349
|
-
|
|
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,
|
|
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 ? {
|
|
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.
|
|
375
|
-
// attended
|
|
376
|
-
// auto-
|
|
377
|
-
//
|
|
378
|
-
//
|
|
379
|
-
//
|
|
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
|
|
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
|
-
|
|
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);
|
package/dist/neal/plan-queue.js
CHANGED
|
@@ -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');
|