@navels/neal 0.1.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 (120) 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 +264 -24
  7. package/dist/neal/agents/prompts.js +7 -15
  8. package/dist/neal/agents/rounds.js +51 -73
  9. package/dist/neal/agents/schemas.js +615 -502
  10. package/dist/neal/agents/structured-json.js +36 -0
  11. package/dist/neal/blocked-guidance.js +16 -9
  12. package/dist/neal/cli.js +2 -2
  13. package/dist/neal/commands/check.js +2 -2
  14. package/dist/neal/commands/compat.js +100 -36
  15. package/dist/neal/commands/interactive-activity.js +2 -2
  16. package/dist/neal/commands/recovery-guidance.js +20 -8
  17. package/dist/neal/commands/runtime.js +4 -2
  18. package/dist/neal/config.js +40 -7
  19. package/dist/neal/context/inline-review-context.js +13 -28
  20. package/dist/neal/context/reviewer-context.js +19 -14
  21. package/dist/neal/eval/reviewer-eval-manifest.js +86 -0
  22. package/dist/neal/eval/reviewer-eval.js +155 -0
  23. package/dist/neal/git.js +9 -3
  24. package/dist/neal/interactive-controls.js +5 -0
  25. package/dist/neal/orchestrator/completion.js +215 -113
  26. package/dist/neal/orchestrator/failures.js +7 -0
  27. package/dist/neal/orchestrator/notifications.js +9 -9
  28. package/dist/neal/orchestrator/phases/planning.js +124 -112
  29. package/dist/neal/orchestrator/phases/recovery.js +151 -115
  30. package/dist/neal/orchestrator/phases/review.js +28 -1
  31. package/dist/neal/orchestrator/split-plan.js +12 -11
  32. package/dist/neal/orchestrator/transitions.js +36 -71
  33. package/dist/neal/orchestrator.js +3 -0
  34. package/dist/neal/plan-doc.js +24 -1
  35. package/dist/neal/plan-queue.js +29 -1
  36. package/dist/neal/prompts/assert-builder.js +8 -1
  37. package/dist/neal/prompts/execute.js +11 -19
  38. package/dist/neal/prompts/planning.js +64 -55
  39. package/dist/neal/prompts/review-doctrine.js +13 -50
  40. package/dist/neal/prompts/specialized.js +28 -22
  41. package/dist/neal/prompts/specs.js +223 -17
  42. package/dist/neal/providers/agent-settings-isolation.js +35 -0
  43. package/dist/neal/providers/anthropic-claude.js +309 -248
  44. package/dist/neal/providers/detection.js +3 -7
  45. package/dist/neal/providers/git-config-isolation.js +83 -0
  46. package/dist/neal/providers/openai-codex.js +102 -204
  47. package/dist/neal/providers/{generic-agentic-tools.js → openai-compatible-tools.js} +11 -5
  48. package/dist/neal/providers/openai-compatible.js +1079 -403
  49. package/dist/neal/providers/openrouter-routing.js +68 -0
  50. package/dist/neal/providers/pricing.js +124 -0
  51. package/dist/neal/providers/rate-card.js +2275 -0
  52. package/dist/neal/providers/registry.js +6 -17
  53. package/dist/neal/providers/telemetry.js +4 -0
  54. package/dist/neal/providers/types.js +12 -0
  55. package/dist/neal/resume-decision.js +7 -6
  56. package/dist/neal/resume-planner.js +9 -0
  57. package/dist/neal/retrospective.js +36 -7
  58. package/dist/neal/review-debt.js +30 -0
  59. package/dist/neal/review-findings/provider.js +17 -29
  60. package/dist/neal/review.js +17 -1
  61. package/dist/neal/run-metrics.js +74 -9
  62. package/dist/neal/state-invariants.js +11 -3
  63. package/dist/neal/state-views.js +27 -5
  64. package/dist/neal/state.js +59 -15
  65. package/dist/neal/status.js +28 -1
  66. package/dist/neal/support.js +3 -3
  67. package/docs/README.md +22 -18
  68. package/docs/{ADJUDICATOR_INVENTORY.md → adjudicator-inventory.md} +58 -57
  69. package/docs/architecture.md +79 -83
  70. package/docs/automation.md +11 -11
  71. package/docs/compat.md +91 -61
  72. package/docs/compatible-models.md +145 -110
  73. package/docs/demo.md +15 -5
  74. package/docs/maintenance.md +74 -32
  75. package/docs/plan-format.md +61 -33
  76. package/docs/prompt-evals.md +96 -0
  77. package/docs/{PROMPT_SPECS.md → prompt-specs.md} +52 -48
  78. package/docs/providers.md +330 -283
  79. package/docs/release.md +43 -40
  80. package/docs/review-convergence.md +246 -0
  81. package/docs/state-machine.md +61 -59
  82. package/docs/storage.md +40 -31
  83. package/docs/troubleshooting.md +32 -29
  84. package/examples/compat/add-edit-verify/PLAN.md +2 -0
  85. package/examples/compat/add-edit-verify/broken.diff +2 -2
  86. package/examples/compat/add-edit-verify/good.diff +2 -2
  87. package/examples/compat/add-edit-verify/src/add.js +1 -1
  88. package/examples/compat/manifest.json +25 -29
  89. package/examples/compat/sum-grep-edit/PLAN.md +33 -0
  90. package/examples/compat/sum-grep-edit/broken.diff +12 -0
  91. package/examples/compat/sum-grep-edit/good.diff +12 -0
  92. package/examples/compat/sum-grep-edit/package.json +5 -0
  93. package/examples/compat/sum-grep-edit/src/numbers.js +9 -0
  94. package/examples/compat/sum-grep-edit/test/numbers.test.js +15 -0
  95. package/examples/issue-triage-js/README.md +0 -15
  96. package/examples/reviewer-eval/clean-doc/base/src/clamp.ts +3 -0
  97. package/examples/reviewer-eval/clean-doc/change.diff +9 -0
  98. package/examples/reviewer-eval/clean-extract/base/src/greet.ts +3 -0
  99. package/examples/reviewer-eval/clean-extract/change.diff +13 -0
  100. package/examples/reviewer-eval/dropped-error/base/src/load.ts +4 -0
  101. package/examples/reviewer-eval/dropped-error/change.diff +15 -0
  102. package/examples/reviewer-eval/manifest.json +45 -0
  103. package/examples/reviewer-eval/off-by-one/base/src/paginate.ts +5 -0
  104. package/examples/reviewer-eval/off-by-one/change.diff +11 -0
  105. package/{config.yml → neal.yml} +1 -1
  106. package/package.json +20 -15
  107. package/dist/neal/providers/generic-agentic.js +0 -1238
  108. package/docs/comparison.md +0 -105
  109. package/examples/compat/is-even-add-test/PLAN.md +0 -30
  110. package/examples/compat/is-even-add-test/broken.diff +0 -11
  111. package/examples/compat/is-even-add-test/good.diff +0 -11
  112. package/examples/compat/is-even-add-test/package.json +0 -5
  113. package/examples/compat/is-even-add-test/src/is-even.js +0 -3
  114. package/examples/compat/is-even-add-test/test/is-even.test.js +0 -9
  115. package/examples/compat/reverse-grep-edit/PLAN.md +0 -32
  116. package/examples/compat/reverse-grep-edit/broken.diff +0 -12
  117. package/examples/compat/reverse-grep-edit/good.diff +0 -12
  118. package/examples/compat/reverse-grep-edit/package.json +0 -5
  119. package/examples/compat/reverse-grep-edit/src/strings.js +0 -10
  120. package/examples/compat/reverse-grep-edit/test/strings.test.js +0 -15
@@ -1,16 +1,18 @@
1
1
  import { resolve } from 'node:path';
2
2
  import { runCoderPlanResponseRound, runPlanReviewerRound } from '../agents.js';
3
- import { createInlineSection, readTextForInlineSection, reviewerNeedsInlineContext, } from '../context/inline-review-context.js';
3
+ import { readTextForInlineSection } from '../context/inline-review-context.js';
4
4
  import { buildAndPersistReviewerContextPacket } from '../context/reviewer-context.js';
5
5
  import { getProviderDefinition } from '../providers/registry.js';
6
6
  import { getDerivedPlanView } from '../state-views.js';
7
+ import { findCanonicalId, getNextCanonicalIndex, getOpenBlockingCanonicalSet, getReopenedCanonical, hasRepeatedUnresolvedBlockingCanonicalsForWindow, isOpenBlockingFinding, isOpenNonBlockingFinding, } from './execute.js';
8
+ import { toPlanReviewDebt } from '../review-debt.js';
7
9
  import { getAdjudicationSpec } from './specs.js';
8
10
  export function isDerivedPlanReviewState(state) {
9
11
  return getDerivedPlanView(state)?.reviewActive ?? false;
10
12
  }
11
13
  // The planner role runs through the coder adapter, so its session-resume capability comes from
12
14
  // the provider's coder capabilities. Providers that declare `supportsSessionResume: false`
13
- // (openai-compatible, generic-agentic) never persist a planner session handle — a null handle is
15
+ // (openai-compatible) never persist a planner session handle — a null handle is
14
16
  // their expected steady state, so top-level plan-refinement responses must start a fresh planner
15
17
  // session for them instead of treating the missing handle as corrupted state. Resume-capable
16
18
  // planner providers keep the hard invariant: a missing handle there is a genuine violation.
@@ -39,18 +41,263 @@ export function resolvePlanningAdjudicationContext(state) {
39
41
  derivedFromScopeNumber: derivedPlanReview && derivedPlan ? derivedPlan.parentScopeNumber : null,
40
42
  };
41
43
  }
42
- async function buildPlanReviewerInlineContext(args) {
43
- const planContent = await readTextForInlineSection(resolve(args.state.cwd, args.reviewedPlanPath));
44
- const sections = [
45
- createInlineSection(`Reviewed plan document content (${args.reviewedPlanPath})`, planContent || '(plan document content unavailable)'),
46
- ];
47
- if (args.context.reviewMode === 'derived-plan' && args.context.parentPlanDoc) {
48
- const parentPlanContent = await readTextForInlineSection(resolve(args.state.cwd, args.context.parentPlanDoc));
49
- sections.push(createInlineSection(`Parent plan document content (${args.context.parentPlanDoc})`, parentPlanContent || '(parent plan document content unavailable)'));
44
+ // Mirrors resolveExecuteReviewDisposition in adjudicator/execute.ts: one resolver derives every
45
+ // disposition-dependent value runPlanReviewPhase persists (plus the transition signal it asserts)
46
+ // from the same condition set, so the five outcomes can never drift apart.
47
+ // The plan-review acceptance transition, reused by clean acceptance and by the
48
+ // accept-with-recorded-reservations landing so both emit identical transition
49
+ // signals (`accept_plan` for top-level plan review, `accept_derived_plan` for a
50
+ // derived-plan review) no adjudication-spec change is needed.
51
+ function planReviewAcceptanceDisposition(derivedPlanReview, currentDerivedPlanStatus) {
52
+ if (derivedPlanReview) {
53
+ return {
54
+ planningSignal: 'accept_derived_plan',
55
+ phase: 'awaiting_derived_plan_execution',
56
+ status: 'running',
57
+ derivedPlanStatus: 'accepted',
58
+ blockedFromPhase: null,
59
+ };
50
60
  }
51
- const reviewHistory = await readTextForInlineSection(resolve(args.state.cwd, args.reviewMarkdownPath));
52
- sections.push(createInlineSection('Prior review history (REVIEW.md content)', reviewHistory || '(no prior review history)'));
53
- return { sections };
61
+ return {
62
+ planningSignal: 'accept_plan',
63
+ phase: 'done',
64
+ status: 'done',
65
+ derivedPlanStatus: currentDerivedPlanStatus,
66
+ blockedFromPhase: null,
67
+ };
68
+ }
69
+ export function resolvePlanReviewDisposition(args) {
70
+ if (args.shouldBlockForConvergence) {
71
+ return {
72
+ planningSignal: 'block_for_operator',
73
+ phase: 'blocked',
74
+ status: 'blocked',
75
+ derivedPlanStatus: args.currentDerivedPlanStatus,
76
+ blockedFromPhase: 'reviewer_plan',
77
+ };
78
+ }
79
+ // Round-forcing blocking findings keep their precedence over the
80
+ // accept-with-debt landing: any open plan-correctness / non-convertible /
81
+ // repeat blocker still forces a revision round (or blocks at the cap), even
82
+ // when convertible hardening findings are also present. Because roundForcing
83
+ // and landAcceptedWithDebt are mutually exclusive, no guard is needed here.
84
+ if (args.hasRoundForcingBlockingFindings) {
85
+ return {
86
+ planningSignal: args.reachedMaxRounds ? 'block_for_operator' : 'request_revision',
87
+ phase: args.reachedMaxRounds ? 'blocked' : 'coder_plan_response',
88
+ status: args.reachedMaxRounds ? 'blocked' : 'running',
89
+ derivedPlanStatus: args.currentDerivedPlanStatus,
90
+ blockedFromPhase: args.reachedMaxRounds ? 'reviewer_plan' : null,
91
+ };
92
+ }
93
+ if (args.landAcceptedWithDebt) {
94
+ return planReviewAcceptanceDisposition(args.derivedPlanReview, args.currentDerivedPlanStatus);
95
+ }
96
+ if (args.hasOpenNonBlockingFindings) {
97
+ return {
98
+ planningSignal: 'optional_revision',
99
+ phase: 'coder_plan_optional_response',
100
+ status: 'running',
101
+ derivedPlanStatus: args.currentDerivedPlanStatus,
102
+ blockedFromPhase: null,
103
+ };
104
+ }
105
+ return planReviewAcceptanceDisposition(args.derivedPlanReview, args.currentDerivedPlanStatus);
106
+ }
107
+ // Pure, class- and novelty-aware partition of the current round's open blocking
108
+ // findings, shared by the arrival-time conversion and the disposition-time
109
+ // landing. The debt-convertibility predicate depends on whether we are at the
110
+ // round cap:
111
+ // Pre-cap (atCap === false): verification_hardening (an explicit match — an
112
+ // absent/undefined class never converts) AND first-occurrence of the canonical
113
+ // (its minimum round across all merged findings equals the current round) AND
114
+ // round >= threshold. Everything else is round-forcing: plan_correctness or any
115
+ // non-hardening/absent class, OR any repeat occurrence of an existing canonical
116
+ // (which still forces a revision round), OR below threshold.
117
+ // At the cap (atCap === true): there are no rounds left to force, so the
118
+ // predicate is class-only — every open verification_hardening finding converts
119
+ // to debt regardless of first-occurrence or threshold, and only a
120
+ // plan_correctness / non-hardening / absent-class finding stays round-forcing
121
+ // (which terminal-blocks the run). A re-litigated-but-not-reopened hardening
122
+ // point therefore converts at the cap rather than failing the run.
123
+ // The explicit-hardening gate is the system-wide fail-safe: an unclassified
124
+ // finding is always round-forcing (blocking). The 3-round reopen and stall
125
+ // terminal blocks are handled separately by shouldBlockForConvergence, which
126
+ // keeps precedence over this partition, so a re-opened or stalled hardening
127
+ // finding — even at the cap — keeps blocking rather than converting.
128
+ export function classifyPlanReviewConvergence(args) {
129
+ const minRoundByCanonical = new Map();
130
+ for (const finding of args.mergedFindings) {
131
+ const existing = minRoundByCanonical.get(finding.canonicalId);
132
+ if (existing === undefined || finding.round < existing) {
133
+ minRoundByCanonical.set(finding.canonicalId, finding.round);
134
+ }
135
+ }
136
+ const debtConvertible = [];
137
+ const roundForcing = [];
138
+ for (const finding of args.mergedFindings.filter(isOpenBlockingFinding)) {
139
+ const isHardening = finding.findingClass === 'verification_hardening';
140
+ const firstOccurrence = minRoundByCanonical.get(finding.canonicalId) === args.round;
141
+ const convertible = args.atCap
142
+ ? isHardening
143
+ : isHardening && firstOccurrence && args.round >= args.debtRoundThreshold;
144
+ if (convertible) {
145
+ debtConvertible.push(finding);
146
+ }
147
+ else {
148
+ roundForcing.push(finding);
149
+ }
150
+ }
151
+ return {
152
+ debtConvertible,
153
+ roundForcing,
154
+ landAcceptedWithDebt: roundForcing.length === 0 && debtConvertible.length > 0,
155
+ };
156
+ }
157
+ // A derived-plan review is split-plan recovery; frame its block reasons so an
158
+ // operator sees that the recovery (not the top-level plan) failed to converge.
159
+ // `derivedPlanReview` is exactly `isDerivedPlanReviewState(state)` (both read
160
+ // `getDerivedPlanView(state)?.reviewActive`), so callers pass the boolean they
161
+ // already resolved from the planning adjudication context.
162
+ export function getDerivedPlanBlockedReason(derivedPlanReview, reason) {
163
+ if (!derivedPlanReview) {
164
+ return reason;
165
+ }
166
+ return `split-plan recovery failed to converge: ${reason}`;
167
+ }
168
+ // The single pure gating/disposition policy for a plan-review round, shared by
169
+ // the runtime (synthesizePlanReviewRound, which assigns canonicals from the
170
+ // reviewer's raw inputs) and the replay harness (which supplies findings with
171
+ // trusted, recorded canonical IDs). Given the current round's already-assigned
172
+ // findings plus the prior findings on state.findings, it derives the merged set,
173
+ // open sets, convergence signals, disposition, blockReason, and round record. It
174
+ // performs no file, git, agent, or configuration IO; the shared stall helper is
175
+ // invoked through its window-taking pure variant.
176
+ export function synthesizePlanReviewRoundFromFindings(args) {
177
+ const { findings } = args;
178
+ const rawMergedFindings = [...args.state.findings, ...findings];
179
+ const reachedMaxRounds = args.round >= args.roundLimit;
180
+ const rawOpenBlockingCanonicalSet = getOpenBlockingCanonicalSet(rawMergedFindings);
181
+ // The convergence block (reopen / stall) is derived from the pre-conversion
182
+ // merged set and keeps precedence over the accept-with-debt landing.
183
+ const stalledBlockingCount = hasRepeatedUnresolvedBlockingCanonicalsForWindow({
184
+ rounds: args.state.rounds,
185
+ currentOpenBlockingCanonicals: rawOpenBlockingCanonicalSet,
186
+ reviewStuckWindow: args.reviewStuckWindow,
187
+ });
188
+ const reopenedCanonical = getReopenedCanonical(rawMergedFindings);
189
+ const shouldBlockForConvergence = Boolean(reopenedCanonical || stalledBlockingCount);
190
+ // Class- and novelty-aware partition of the open blocking findings. Pre-cap, a
191
+ // first-occurrence past-threshold hardening finding is debt-convertible; at the
192
+ // cap (reachedMaxRounds) every open hardening finding is convertible regardless
193
+ // of first-occurrence, so a re-litigated-but-not-reopened hardening point
194
+ // converts instead of failing the run. When every open blocking finding is
195
+ // convertible (and none is round-forcing), the round lands accepted-with-debt
196
+ // and those findings convert to deferred plan-review debt so they leave the
197
+ // open set.
198
+ const convergence = classifyPlanReviewConvergence({
199
+ mergedFindings: rawMergedFindings,
200
+ round: args.round,
201
+ debtRoundThreshold: args.debtRoundThreshold,
202
+ atCap: reachedMaxRounds,
203
+ });
204
+ // Drive the blocking gate off the merged open round-forcing set, not the
205
+ // current reviewer payload. An empty reviewer round over a prior still-open
206
+ // blocker must keep forcing a revision (or block at the cap), never accept.
207
+ const hasRoundForcingBlockingFindings = convergence.roundForcing.length > 0;
208
+ const disposition = resolvePlanReviewDisposition({
209
+ shouldBlockForConvergence,
210
+ hasRoundForcingBlockingFindings,
211
+ landAcceptedWithDebt: !shouldBlockForConvergence && convergence.landAcceptedWithDebt,
212
+ reachedMaxRounds,
213
+ hasOpenNonBlockingFindings: rawMergedFindings.some(isOpenNonBlockingFinding),
214
+ derivedPlanReview: args.derivedPlanReview,
215
+ currentDerivedPlanStatus: args.currentDerivedPlanStatus,
216
+ });
217
+ // Arrival-time debt conversion (Scope 4): pre-cap, a first-occurrence
218
+ // past-threshold hardening finding banks as deferred plan-review debt the moment
219
+ // it arrives, even when a co-occurring plan_correctness / repeat finding forces a
220
+ // revision this round — so novelty-bounded hardening asks never extend the round
221
+ // count. At the cap the convertible set is class-based (every open hardening
222
+ // finding), but it only banks when the round actually lands accepted-with-debt: a
223
+ // co-occurring plan_correctness finding terminal-blocks instead of converting.
224
+ // The convergence block (reopen/stall) keeps precedence: when it fires nothing
225
+ // converts, so a re-opened or stalled hardening finding keeps blocking. The
226
+ // open-blocking round record then reflects the post-flip set.
227
+ const shouldConvertDebt = !shouldBlockForConvergence &&
228
+ (reachedMaxRounds ? convergence.landAcceptedWithDebt : convergence.debtConvertible.length > 0);
229
+ const convertedIds = new Set(shouldConvertDebt ? convergence.debtConvertible.map((finding) => finding.id) : []);
230
+ const mergedFindings = convertedIds.size
231
+ ? rawMergedFindings.map((finding) => convertedIds.has(finding.id) ? { ...finding, status: 'deferred' } : finding)
232
+ : rawMergedFindings;
233
+ const openBlockingCanonicalSet = convertedIds.size
234
+ ? getOpenBlockingCanonicalSet(mergedFindings)
235
+ : rawOpenBlockingCanonicalSet;
236
+ const openBlockingCanonicalIds = [...openBlockingCanonicalSet].sort();
237
+ const openBlockingCanonicalCount = openBlockingCanonicalSet.size;
238
+ const blockReason = reopenedCanonical
239
+ ? getDerivedPlanBlockedReason(args.derivedPlanReview, `review_stuck: blocking finding ${reopenedCanonical} reopened across multiple reviewer rounds`)
240
+ : stalledBlockingCount
241
+ ? getDerivedPlanBlockedReason(args.derivedPlanReview, `review_stuck: blocking findings did not decrease across ${args.reviewStuckWindow} consecutive reviewer rounds`)
242
+ : reachedMaxRounds && hasRoundForcingBlockingFindings
243
+ ? getDerivedPlanBlockedReason(args.derivedPlanReview, `reached max review rounds (${args.roundLimit}) with blocking findings still open`)
244
+ : null;
245
+ return {
246
+ findings,
247
+ mergedFindings,
248
+ disposition,
249
+ blockReason,
250
+ openBlockingCanonicalCount,
251
+ planReviewDebt: toPlanReviewDebt(mergedFindings),
252
+ roundRecord: {
253
+ round: args.round,
254
+ reviewerSessionHandle: args.reviewerSessionHandle,
255
+ reviewedPlanPath: args.reviewedPlanPath,
256
+ normalizationApplied: args.normalizationApplied,
257
+ normalizationOperations: args.normalizationOperations,
258
+ normalizationScopeLabelMappings: args.normalizationScopeLabelMappings,
259
+ commitRange: args.commitRange,
260
+ openBlockingCanonicalCount,
261
+ openBlockingCanonicalIds,
262
+ findings: findings.map((finding) => finding.id),
263
+ },
264
+ };
265
+ }
266
+ // Runtime plan-review round core, mirroring synthesizeExecuteReviewerState in
267
+ // adjudicator/execute.ts: it assigns canonical IDs to the reviewer's raw finding
268
+ // inputs (via findCanonicalId), then routes them through the shared
269
+ // synthesizePlanReviewRoundFromFindings policy above, so runtime and replay can
270
+ // never drift apart. Config is resolved by the caller (reviewStuckWindow) so this
271
+ // stays IO-free.
272
+ export function synthesizePlanReviewRound(args) {
273
+ let nextCanonicalIndex = getNextCanonicalIndex(args.state.findings);
274
+ const findings = args.findingInputs.map((finding, index) => {
275
+ const canonicalId = findCanonicalId(args.state.findings, finding) ?? `C${nextCanonicalIndex++}`;
276
+ return {
277
+ ...finding,
278
+ id: `R${args.round}-F${index + 1}`,
279
+ canonicalId,
280
+ status: 'open',
281
+ coderDisposition: null,
282
+ coderCommit: null,
283
+ };
284
+ });
285
+ return synthesizePlanReviewRoundFromFindings({
286
+ state: args.state,
287
+ round: args.round,
288
+ roundLimit: args.roundLimit,
289
+ reviewStuckWindow: args.reviewStuckWindow,
290
+ debtRoundThreshold: args.debtRoundThreshold,
291
+ derivedPlanReview: args.derivedPlanReview,
292
+ currentDerivedPlanStatus: args.currentDerivedPlanStatus,
293
+ reviewerSessionHandle: args.reviewerSessionHandle,
294
+ reviewedPlanPath: args.reviewedPlanPath,
295
+ normalizationApplied: args.normalizationApplied,
296
+ normalizationOperations: args.normalizationOperations,
297
+ normalizationScopeLabelMappings: args.normalizationScopeLabelMappings,
298
+ commitRange: args.commitRange,
299
+ findings,
300
+ });
54
301
  }
55
302
  export async function runPlanningReviewerAdjudication(args) {
56
303
  const context = resolvePlanningAdjudicationContext(args.state);
@@ -58,22 +305,16 @@ export async function runPlanningReviewerAdjudication(args) {
58
305
  planPath: context.reviewTargetPath,
59
306
  normalizedPlanPath: args.normalizedPlanPath,
60
307
  });
61
- // Plan and review-history contents are inlined only for reviewers without
62
- // repository read access; read-capable reviewers inspect them directly.
63
- const inlineContext = reviewerNeedsInlineContext(args.state.agentConfig.reviewer)
64
- ? await buildPlanReviewerInlineContext({
65
- state: args.state,
66
- context,
67
- reviewedPlanPath: preparedReview.reviewedPlanPath,
68
- reviewMarkdownPath: args.reviewMarkdownPath,
69
- })
70
- : null;
71
308
  const reviewedPlanContent = await readTextForInlineSection(resolve(args.state.cwd, preparedReview.reviewedPlanPath));
72
309
  const parentPlanContent = context.reviewMode === 'derived-plan' && context.parentPlanDoc
73
310
  ? await readTextForInlineSection(resolve(args.state.cwd, context.parentPlanDoc))
74
311
  : null;
75
312
  const reviewerResult = await (args.runReviewerRound ?? runPlanReviewerRound)({
76
313
  reviewer: args.state.agentConfig.reviewer,
314
+ // Resume the reviewer's own session from the previous refinement round
315
+ // (null on round 1: fresh runs and split-plan recovery both clear it).
316
+ // Sessionless providers are filtered at the round layer.
317
+ resumeHandle: args.state.reviewerSessionHandle,
77
318
  cwd: args.state.cwd,
78
319
  planDoc: preparedReview.reviewedPlanPath,
79
320
  round: args.round,
@@ -84,7 +325,6 @@ export async function runPlanningReviewerAdjudication(args) {
84
325
  reviewerContext: await buildAndPersistReviewerContextPacket({ state: args.state }),
85
326
  reviewedPlanContent,
86
327
  parentPlanContent,
87
- inlineContext,
88
328
  unattended: args.state.unattended,
89
329
  // Reinforce an author-declared `one_shot` only for the top-level authored plan; derived
90
330
  // plans declare and own their own shape and are never clamped.
@@ -1,7 +1,7 @@
1
1
  import { assertNoReadPromptInstructionText, renderInlineReviewerContext, } from '../context/inline-review-context.js';
2
- import { renderReviewerContextMarkdown } from '../context/reviewer-context.js';
3
2
  import { AUTONOMY_BLOCKED as SHARED_AUTONOMY_BLOCKED, AUTONOMY_CHUNK_DONE as SHARED_AUTONOMY_CHUNK_DONE, AUTONOMY_DONE as SHARED_AUTONOMY_DONE, AUTONOMY_SCOPE_DONE as SHARED_AUTONOMY_SCOPE_DONE, AUTONOMY_SPLIT_PLAN as SHARED_AUTONOMY_SPLIT_PLAN, buildProgressSection, getCanonicalPlanContractLines, getProtocolMarkerArtifactProhibitionLines, getStandalonePlanPayloadSourceOfTruthLines, } from '../prompts/shared.js';
4
3
  import { getUserGuidanceLines } from '../prompts/guidance.js';
4
+ import { assertPromptBuilder, resolvePrimaryVariant } from '../prompts/assert-builder.js';
5
5
  export { buildCoderResponsePrompt, buildLegacyScopePrompt, buildReviewerPrompt, buildScopePrompt } from '../prompts/execute.js';
6
6
  export { buildCoderPlanResponsePrompt, buildLegacyPlanningPrompt, buildPlanReviewerPrompt, buildPlanningPrompt, } from '../prompts/planning.js';
7
7
  export { buildFinalCompletionReviewerPrompt, buildFinalCompletionSummaryPrompt, } from '../prompts/specialized.js';
@@ -10,16 +10,18 @@ export const AUTONOMY_CHUNK_DONE = SHARED_AUTONOMY_CHUNK_DONE;
10
10
  export const AUTONOMY_DONE = SHARED_AUTONOMY_DONE;
11
11
  export const AUTONOMY_SCOPE_DONE = SHARED_AUTONOMY_SCOPE_DONE;
12
12
  export const AUTONOMY_SPLIT_PLAN = SHARED_AUTONOMY_SPLIT_PLAN;
13
- // Single no-read-safe prompt variant for the read-only blocked-run adjudicator.
13
+ // Single no-read-safe prompt variant for the read-only blocked-run consultant.
14
14
  // It generalizes across every triaged block class (a coder-blocked signal or a
15
15
  // reviewer `review_stuck` deadlock): it takes a NON-null InlineReviewerContext
16
16
  // (the round runner narrows before calling it) and judges entirely from whatever
17
17
  // sections that context supplies, making no assumptions about which sections are
18
18
  // present. The static Neal-authored instructions must never reference
19
19
  // repository/file/shell access, so they pass through the shared no-read guard.
20
- export function buildBlockedAdjudicatorPrompt(args) {
20
+ export function buildConsultantPrompt(args) {
21
+ const spec = assertPromptBuilder('consultant', 'buildConsultantPrompt', 'src/neal/agents/prompts.ts');
22
+ resolvePrimaryVariant(spec, 'consultant');
21
23
  const staticInstructionLines = [
22
- 'You are a read-only adjudicator for a blocked neal run. The run has stopped because a coder or reviewer turn reported a blocker it could not resolve on its own.',
24
+ 'You are a read-only consultant for a blocked neal run. The run has stopped because a coder or reviewer turn reported a blocker it could not resolve on its own.',
23
25
  'Judge entirely from the inlined context sections below. You make no changes, run nothing, and look at nothing outside this prompt.',
24
26
  'Decide whether the block is an autonomously recoverable misunderstanding that can be resolved WITHIN the existing scope, or a genuine wall that must escalate to a human.',
25
27
  'Return `recoverable:true` with `triageCategory:"misunderstanding"` and a concrete, in-scope `resolutionDirective` ONLY when ALL of these hold:',
@@ -34,7 +36,7 @@ export function buildBlockedAdjudicatorPrompt(args) {
34
36
  'If the inlined context lists specific blocking findings, set `targetCanonicalIds` to the canonical ids your verdict addresses; otherwise leave it empty.',
35
37
  'Always provide a non-empty `rationale` explaining the triage.',
36
38
  ];
37
- assertNoReadPromptInstructionText(staticInstructionLines.join('\n'), 'buildBlockedAdjudicatorPrompt');
39
+ assertNoReadPromptInstructionText(staticInstructionLines.join('\n'), 'buildConsultantPrompt');
38
40
  return [
39
41
  ...staticInstructionLines,
40
42
  '',
@@ -44,16 +46,6 @@ export function buildBlockedAdjudicatorPrompt(args) {
44
46
  renderInlineReviewerContext(args.inlineContext),
45
47
  ].join('\n');
46
48
  }
47
- function getReviewerContextLines(reviewerContext, mode = 'tool-access') {
48
- if (!reviewerContext) {
49
- return [];
50
- }
51
- return [
52
- '',
53
- 'Bounded current-run continuity context:',
54
- mode === 'inline' ? renderReviewerContextMarkdown(reviewerContext, { mode: 'inline' }) : reviewerContext.promptMarkdown,
55
- ];
56
- }
57
49
  export function buildBlockedRecoveryCoderPrompt(args) {
58
50
  const allowReplacement = args.allowReplacement ?? true;
59
51
  const actionLines = [
@@ -2,12 +2,12 @@ import { readFile, writeFile } from 'node:fs/promises';
2
2
  import { getAgentTurnRetryLimit, getAgentTurnStartupTimeoutMs, getApiRetryLimit, getInactivityTimeoutMs, } from '../config.js';
3
3
  import { runWithAgentTurnLiveness } from '../providers/liveness.js';
4
4
  import { normalizeExecutionShapeDeclaration } from '../plan-validation.js';
5
- import { getCoderAdapter, getStructuredAdvisorAdapter } from '../providers/registry.js';
5
+ import { getCoderAdapter, getProviderDefinition, getStructuredAdvisorAdapter } from '../providers/registry.js';
6
6
  import { createProviderTelemetrySink } from '../providers/telemetry.js';
7
7
  import { isNealProviderError, NealProviderError } from '../providers/types.js';
8
- import { getReviewerDoctrineAccessMode, reviewerNeedsInlineContext, } from '../context/inline-review-context.js';
9
- import { AUTONOMY_BLOCKED, AUTONOMY_CHUNK_DONE, AUTONOMY_DONE, AUTONOMY_SCOPE_DONE, AUTONOMY_SPLIT_PLAN, buildBlockedRecoveryCoderPrompt, buildCoderResponsePrompt, buildCoderPlanResponsePrompt, buildFinalCompletionReviewerPrompt, buildFinalCompletionSummaryPrompt, buildLegacyScopePrompt, buildLegacyPlanningPrompt, buildPlanReviewerPrompt, buildPlanningPrompt, buildReviewerPrompt, buildBlockedAdjudicatorPrompt, buildScopePrompt, } from './prompts.js';
10
- import { buildCoderBlockedRecoveryDispositionSchema, buildCoderPlanSchema, buildCoderScopeSchema, buildCoderPlanResponseSchema, buildCoderResponseSchema, buildBlockedAdjudicatorSchema, buildFinalCompletionSummarySchema, buildFinalCompletionReviewerSchema, buildPlanReviewerSchema, buildReviewerSchema, parseExecuteScopeProgressPayload, parseFinalCompletionReviewerPayload, parseFinalCompletionSummaryPayload, stripExecuteScopeProgressPayload, validateCoderBlockedRecoveryDispositionPayload, validateCoderPlanPayload, validateCoderPlanResponsePayload, validateCoderResponsePayload, validateCoderScopePayload, validatePlanReviewerPayload, validateBlockedAdjudicatorVerdictPayload, validateReviewerPayload, } from './schemas.js';
8
+ import { getReviewerDoctrineAccessMode, } from '../context/inline-review-context.js';
9
+ import { AUTONOMY_BLOCKED, AUTONOMY_CHUNK_DONE, AUTONOMY_DONE, AUTONOMY_SCOPE_DONE, AUTONOMY_SPLIT_PLAN, buildBlockedRecoveryCoderPrompt, buildCoderResponsePrompt, buildCoderPlanResponsePrompt, buildFinalCompletionReviewerPrompt, buildFinalCompletionSummaryPrompt, buildLegacyScopePrompt, buildLegacyPlanningPrompt, buildPlanReviewerPrompt, buildPlanningPrompt, buildReviewerPrompt, buildConsultantPrompt, buildScopePrompt, } from './prompts.js';
10
+ import { buildCoderBlockedRecoveryDispositionSchema, buildCoderPlanSchema, buildCoderScopeSchema, buildCoderPlanResponseSchema, buildCoderResponseSchema, buildConsultantSchema, buildFinalCompletionSummarySchema, buildFinalCompletionReviewerSchema, buildPlanReviewerSchema, buildReviewerSchema, parseExecuteScopeProgressPayload, parseFinalCompletionReviewerPayload, parseFinalCompletionSummaryPayload, stripExecuteScopeProgressPayload, validateCoderBlockedRecoveryDispositionPayload, validateCoderPlanPayload, validateCoderPlanResponsePayload, validateCoderResponsePayload, validateCoderScopePayload, validatePlanReviewerPayload, validateConsultantVerdictPayload, validateReviewerPayload, } from './schemas.js';
11
11
  import { runCoderStructuredPrompt, translateCoderProviderError } from './structured-coder.js';
12
12
  export class ReviewerRoundError extends Error {
13
13
  sessionHandle;
@@ -110,37 +110,20 @@ function createStructuredAdvisorProviderEventSink(args) {
110
110
  cwd: args.cwd,
111
111
  });
112
112
  }
113
- // Fail-fast guard for reviewer rounds whose prompts still contain
114
- // repository/tool-access instructions. A reviewer provider without read tool
115
- // access must never receive such a prompt: either the round supplies
116
- // Neal-inlined reviewer context (inlineContextSupplied), or the round must
117
- // reject the configuration with a clear error instead of silently sending
118
- // instructions the provider cannot follow. Every reviewer round label now
119
- // supports inline context, but a call site that fails to supply it for a
120
- // no-read reviewer must still fail fast here.
121
- //
122
- // The guard throws a ReviewerRoundError backed by a non-retryable
123
- // NealProviderError with sessionHandle: null so it flows through the same
124
- // orchestrator reviewer failure path as any other reviewer round failure
125
- // (persisted failed state, written artifacts, logged phase errors).
126
- function assertReviewerRoundSupportsNoReadReviewer(args) {
127
- if (args.inlineContextSupplied || !reviewerNeedsInlineContext(args.reviewer)) {
128
- return;
113
+ // Resolves the session handle a reviewer round may resume: the previous
114
+ // round's handle when the provider's structured-advisor capability declares
115
+ // session resume, null otherwise (sessionless providers like
116
+ // openai-compatible must never receive one openai-compatible treats a
117
+ // non-null resume handle as corrupted state).
118
+ // Reviewer continuity within a review engagement is the original design:
119
+ // a continuing reviewer converges like a human-shuttled review chat, while
120
+ // a cold-started reviewer re-audits from scratch every round.
121
+ export function resolveReviewerResumeHandle(reviewer, resumeHandle) {
122
+ if (!resumeHandle) {
123
+ return null;
129
124
  }
130
- throw new ReviewerRoundError(new NealProviderError({
131
- message: [
132
- `Reviewer provider ${JSON.stringify(args.reviewer.provider)} has no repository read access,`,
133
- `and the ${args.label} reviewer round did not receive Neal-inlined reviewer context,`,
134
- 'so its prompt would contain repository-access instructions the provider cannot follow.',
135
- 'Configure a read-capable reviewer provider (agent.reviewer.provider) for runs that reach',
136
- `${args.label} rounds until this round supports inline reviewer context.`,
137
- ].join(' '),
138
- provider: args.reviewer.provider,
139
- role: 'structured-advisor',
140
- sessionHandle: null,
141
- kind: 'provider_failed',
142
- retryable: false,
143
- }));
125
+ const capabilities = getProviderDefinition(reviewer.provider).capabilities['structured-advisor'];
126
+ return capabilities.supported && capabilities.supportsSessionResume ? resumeHandle : null;
144
127
  }
145
128
  async function runReviewerStructuredRound(args) {
146
129
  try {
@@ -150,8 +133,9 @@ async function runReviewerStructuredRound(args) {
150
133
  role: 'structured-advisor',
151
134
  label: args.label,
152
135
  startupTimeoutMs: Math.min(getAgentTurnStartupTimeoutMs(args.cwd), getInactivityTimeoutMs(args.cwd)),
153
- // Structured-advisor turns retry locally: they are non-writing by policy
154
- // and do not resume sessions at this call site.
136
+ // Structured-advisor turns retry locally: they are non-writing by
137
+ // policy, and a liveness retry re-runs the round from the same resume
138
+ // handle (the prior round's session), never a partially-written one.
155
139
  retryLimit: getAgentTurnRetryLimit(args.cwd),
156
140
  logger: args.logger,
157
141
  baseSink: createStructuredAdvisorProviderEventSink({
@@ -166,6 +150,7 @@ async function runReviewerStructuredRound(args) {
166
150
  prompt: args.prompt,
167
151
  schema: args.schema,
168
152
  structuredJsonProtocol: args.structuredJsonProtocol,
153
+ resumeHandle: resolveReviewerResumeHandle(args.reviewer, args.resumeHandle),
169
154
  // Liveness retry sits outside the adapter-owned apiRetryLimit; it
170
155
  // does not replace provider API retry.
171
156
  ...getStructuredAdvisorRuntimeOptions(args.cwd),
@@ -183,14 +168,10 @@ async function runReviewerStructuredRound(args) {
183
168
  }
184
169
  }
185
170
  export async function runReviewerRound(args) {
186
- assertReviewerRoundSupportsNoReadReviewer({
187
- reviewer: args.reviewer,
188
- label: 'review',
189
- inlineContextSupplied: Boolean(args.inlineContext),
190
- });
191
171
  const schema = buildReviewerSchema();
192
172
  const { sessionHandle, structured } = await runReviewerStructuredRound({
193
173
  reviewer: args.reviewer,
174
+ resumeHandle: args.resumeHandle,
194
175
  label: 'review',
195
176
  cwd: args.cwd,
196
177
  // The doctrine access mode comes from the reviewer provider's declared
@@ -224,14 +205,10 @@ export async function runReviewerRound(args) {
224
205
  };
225
206
  }
226
207
  export async function runPlanReviewerRound(args) {
227
- assertReviewerRoundSupportsNoReadReviewer({
228
- reviewer: args.reviewer,
229
- label: 'plan-review',
230
- inlineContextSupplied: Boolean(args.inlineContext),
231
- });
232
208
  const schema = buildPlanReviewerSchema();
233
209
  const { sessionHandle, structured } = await runReviewerStructuredRound({
234
210
  reviewer: args.reviewer,
211
+ resumeHandle: args.resumeHandle,
235
212
  label: 'plan-review',
236
213
  cwd: args.cwd,
237
214
  // The doctrine access mode comes from the reviewer provider's declared
@@ -253,6 +230,9 @@ export async function runPlanReviewerRound(args) {
253
230
  round: args.round,
254
231
  source: 'reviewer',
255
232
  severity: finding.severity,
233
+ // validatePlanReviewerPayload normalizes an absent class to plan_correctness,
234
+ // so a plan-review finding always carries a concrete class by this point.
235
+ findingClass: finding.findingClass,
256
236
  files: finding.files,
257
237
  claim: finding.claim,
258
238
  requiredAction: finding.requiredAction,
@@ -260,25 +240,19 @@ export async function runPlanReviewerRound(args) {
260
240
  })),
261
241
  };
262
242
  }
263
- // Read-only, inline-only round for the review_stuck arbiter. It runs through the
243
+ // Read-only, inline-only round for the review_stuck consultant. It runs through the
264
244
  // same runReviewerStructuredRound plumbing the support / final-completion
265
245
  // reviewers use (read-only tools only, zero commits, zero file edits). Because
266
- // the arbiter judges entirely from Neal-inlined in-memory artifacts (plan, open
246
+ // the consultant judges entirely from Neal-inlined in-memory artifacts (plan, open
267
247
  // blocking findings, reviewer-round snapshots), it has a single no-read-safe
268
- // prompt variant and therefore requires a non-null inlineContext. The guard
269
- // fires for a no-read reviewer paired with a null context before any adapter
270
- // contact; the explicit null check below additionally covers a read-capable
271
- // reviewer that was handed a null context (the adjudicator always passes a
272
- // non-null context in production).
273
- export async function runBlockedAdjudicatorRound(args) {
274
- assertReviewerRoundSupportsNoReadReviewer({
275
- reviewer: args.reviewer,
276
- label: 'review-stuck-arbiter',
277
- inlineContextSupplied: Boolean(args.inlineContext),
278
- });
248
+ // prompt variant and therefore requires a non-null inlineContext. The explicit
249
+ // null check below rejects a caller that hands the round a null context before
250
+ // any adapter contact (the consultant always passes a non-null context in
251
+ // production).
252
+ export async function runConsultantRound(args) {
279
253
  if (!args.inlineContext) {
280
254
  throw new ReviewerRoundError(new NealProviderError({
281
- message: 'review-stuck-arbiter round requires Neal-inlined reviewer context',
255
+ message: 'consultant round requires Neal-inlined reviewer context',
282
256
  provider: args.reviewer.provider,
283
257
  role: 'structured-advisor',
284
258
  sessionHandle: null,
@@ -286,26 +260,26 @@ export async function runBlockedAdjudicatorRound(args) {
286
260
  retryable: false,
287
261
  }));
288
262
  }
289
- const schema = buildBlockedAdjudicatorSchema();
263
+ const schema = buildConsultantSchema();
290
264
  const { sessionHandle, structured } = await runReviewerStructuredRound({
291
265
  reviewer: args.reviewer,
292
- label: 'review-stuck-arbiter',
266
+ label: 'consultant',
293
267
  cwd: args.cwd,
294
- prompt: buildBlockedAdjudicatorPrompt({
268
+ prompt: buildConsultantPrompt({
295
269
  blockedReason: args.blockedReason,
296
270
  inlineContext: args.inlineContext,
297
271
  }),
298
272
  schema,
299
273
  structuredJsonProtocol: buildStructuredJsonProtocolSpec({
300
- schemaLabel: 'blocked_adjudicator_payload',
274
+ schemaLabel: 'consultant_payload',
301
275
  schema,
302
- validator: validateBlockedAdjudicatorVerdictPayload,
276
+ validator: validateConsultantVerdictPayload,
303
277
  }),
304
278
  logger: args.logger,
305
279
  });
306
280
  return {
307
281
  sessionHandle,
308
- verdict: validateBlockedAdjudicatorVerdictPayload(structured),
282
+ verdict: validateConsultantVerdictPayload(structured),
309
283
  };
310
284
  }
311
285
  export async function runCoderFinalCompletionSummaryRound(args) {
@@ -317,8 +291,9 @@ export async function runCoderFinalCompletionSummaryRound(args) {
317
291
  role: 'structured-advisor',
318
292
  label: 'final-completion',
319
293
  startupTimeoutMs: Math.min(getAgentTurnStartupTimeoutMs(args.cwd), getInactivityTimeoutMs(args.cwd)),
320
- // Structured-advisor turns retry locally: they are non-writing by policy
321
- // and do not resume sessions at this call site.
294
+ // Structured-advisor turns retry locally: they are non-writing by
295
+ // policy, and a liveness retry re-runs the round from the same resume
296
+ // handle (the prior round's session), never a partially-written one.
322
297
  retryLimit: getAgentTurnRetryLimit(args.cwd),
323
298
  logger: args.logger,
324
299
  baseSink: createStructuredAdvisorProviderEventSink({
@@ -352,11 +327,6 @@ export async function runCoderFinalCompletionSummaryRound(args) {
352
327
  }
353
328
  }
354
329
  export async function runReviewerFinalCompletionRound(args) {
355
- assertReviewerRoundSupportsNoReadReviewer({
356
- reviewer: args.reviewer,
357
- label: 'final-completion',
358
- inlineContextSupplied: Boolean(args.inlineContext),
359
- });
360
330
  const schema = buildFinalCompletionReviewerSchema();
361
331
  const { sessionHandle, structured } = await runReviewerStructuredRound({
362
332
  reviewer: args.reviewer,
@@ -373,6 +343,14 @@ export async function runReviewerFinalCompletionRound(args) {
373
343
  }),
374
344
  logger: args.logger,
375
345
  });
346
+ // Trust-boundary policy: rounds-level validation calls like this one are
347
+ // the authoritative trust boundary for agent payloads, not redundant dead
348
+ // code. The json-block protocol validator never runs on native
349
+ // structured-output paths, and cross-field rules are enforced only here on
350
+ // those paths; test/orchestrator.test.ts deliberately drives this wrap
351
+ // (FinalCompletionReviewerVerdictError, subtype
352
+ // final_completion_verdict_invalid) with a non-conforming fake adapter.
353
+ // Do not remove rounds-level validation calls.
376
354
  let verdict;
377
355
  try {
378
356
  verdict = parseFinalCompletionReviewerPayload(structured);