@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
package/SECURITY.md CHANGED
@@ -1,9 +1,8 @@
1
1
  # Security
2
2
 
3
3
  neal is a local planner/coder/reviewer loop that delegates work to
4
- provider-owned agent runtimes on your machine. This document describes neal's
5
- trust model so you can run it safely, and how to report a suspected
6
- vulnerability.
4
+ provider-owned agent runtimes on your machine. Here's neal's trust model so you
5
+ can run it safely, and how to report a suspected vulnerability.
7
6
 
8
7
  ## Trust Model
9
8
 
@@ -21,7 +20,7 @@ these providers:
21
20
  - The Claude coder runs with `permissionMode: bypassPermissions`.
22
21
  - The Codex coder runs with `approvalPolicy: never` and
23
22
  `sandboxMode: danger-full-access`.
24
- - The `generic-agentic` coder drives a neal-owned agentic loop whose `run` tool
23
+ - The `openai-compatible` coder drives a neal-owned agentic loop whose `run` tool
25
24
  is unsandboxed shell.
26
25
 
27
26
  Treat any provider acting in the coder or planner role as capable of running
@@ -33,20 +32,18 @@ have added external sandboxing yourself.
33
32
  The reviewer role is **read-only**, enforced in two layers:
34
33
 
35
34
  - **Declared capability.** Every provider definition must declare its reviewer
36
- (structured-advisor) capability with `write:false, shell:false`; the registry
35
+ (structured-advisor) capability with `write:false, shell:false`. The registry
37
36
  validates this at every provider resolution, so a writable reviewer
38
37
  definition cannot be handed out.
39
38
  - **Adapter wiring.** Each adapter enforces the declaration mechanically:
40
39
  - The Codex reviewer runs under `sandboxMode: read-only`, which Codex
41
- enforces with an OS-level sandbox (Seatbelt on macOS, Landlock on Linux)
42
- the strongest guarantee of the four.
40
+ enforces with an OS-level sandbox (Seatbelt on macOS, Landlock on Linux),
41
+ the strongest guarantee of the three.
43
42
  - The Claude reviewer is limited to the `Read`, `Grep`, and `Glob` tools at
44
- the SDK level; no write or shell tool exists in the reviewer session.
45
- - The `generic-agentic` reviewer is bound to a neal-owned read-only toolset
43
+ the SDK level. No write or shell tool exists in the reviewer session.
44
+ - The `openai-compatible` reviewer is bound to a neal-owned read-only toolset
46
45
  (`read_file`, `list_dir`, `grep`, and a read-only `git_diff` over a commit
47
- range); write and shell tools are absent from that toolset by construction.
48
- - The `openai-compatible` reviewer has no tools at all; it judges
49
- neal-inlined context.
46
+ range). Write and shell tools are absent from that toolset by construction.
50
47
 
51
48
  Both layers are pinned by tests, including a registry-driven conformance test
52
49
  (`test/reviewer-readonly-conformance.test.ts`) that fails if a provider is
@@ -55,10 +52,10 @@ Codex reviewer, enforcement is process-level (SDK and tool wiring), not an
55
52
  external sandbox, so it is only as strong as the adapter and the runtime under
56
53
  it.
57
54
 
58
- **Read-only is not read-jailed.** Only the `generic-agentic` reviewer restricts
55
+ **Read-only is not read-jailed.** Only the `openai-compatible` reviewer restricts
59
56
  *reads* to the repository (path-jailed, with symlink resolution). The Claude
60
- and Codex reviewers can read anything the invoking user can read including
61
- files outside the repository such as `~/.ssh` or cloud credentials and file
57
+ and Codex reviewers can read anything the invoking user can read (including
58
+ files outside the repository such as `~/.ssh` or cloud credentials), and file
62
59
  contents they read are sent to the provider's API. On sensitive machines,
63
60
  follow the conservative guidance in the README
64
61
  [Safety Notes](README.md#safety-notes): run neal in a disposable checkout,
@@ -68,17 +65,17 @@ branch, worktree, container, or VM.
68
65
  judges findings with the read-only reviewer adapter, but the agent that
69
66
  *drafts* candidate findings runs on the coder provider with coder privileges.
70
67
  neal verifies after every drafting call that the worktree and neal's own run
71
- state are byte-unchanged and fails the review otherwise detection, not
68
+ state are byte-unchanged and fails the review otherwise: detection, not
72
69
  prevention. See [docs/providers.md](docs/providers.md) for the exact guarantee
73
70
  split.
74
71
 
75
72
  ### Untrusted or unknown models
76
73
 
77
- Running an unknown or untrusted model in the coder role for example via
74
+ Running an unknown or untrusted model in the coder role (for example via
78
75
  `neal compat` against an arbitrary OpenAI-compatible / OpenRouter slug on the
79
- `generic-agentic` provider grants that model **coder-level unsandboxed shell
76
+ `openai-compatible` provider) grants that model **coder-level unsandboxed shell
80
77
  access** in the working directory. Run untrusted models inside a container or
81
- disposable sandbox; do not point a coder-role untrusted model at a repository or
78
+ disposable sandbox. Do not point a coder-role untrusted model at a repository or
82
79
  machine you care about.
83
80
 
84
81
  See [docs/providers.md](docs/providers.md) for the per-provider permission
@@ -1,30 +1,30 @@
1
1
  import { resolve } from 'node:path';
2
- import { runBlockedAdjudicatorRound } from '../agents/rounds.js';
2
+ import { runConsultantRound } from '../agents/rounds.js';
3
3
  import { createInlineSection, readTextForInlineSection, } from '../context/inline-review-context.js';
4
4
  import { getChangedFilesForRange, getHeadCommit } from '../git.js';
5
5
  import { getExecutionPlanPath } from '../scopes.js';
6
6
  // Maximum number of most-recent reviewer rounds inlined as snapshots for the
7
- // adjudicator. The deadlock signal is dominated by the latest rounds, so a small
7
+ // consultant. The deadlock signal is dominated by the latest rounds, so a small
8
8
  // window keeps the prompt bounded while still showing how the disagreement
9
9
  // evolved.
10
10
  const RECENT_ROUND_SNAPSHOT_LIMIT = 5;
11
- // The source phases the generalized adjudicator triages: a reviewer `review_stuck`
11
+ // The source phases the generalized consultant triages: a reviewer `review_stuck`
12
12
  // deadlock (`reviewer_scope`/`reviewer_plan`) and a coder-blocked signal
13
13
  // (`coder_scope`/`coder_response`/`coder_optional_response`, which after the
14
14
  // split-plan reroute also carries the invalid-payload block). Every other accepted
15
15
  // recovery source phase is ineligible and keeps today's generic recovery behavior;
16
16
  // the recovery chokepoint enforces this gate.
17
- export const ADJUDICATOR_ELIGIBLE_SOURCE_PHASES = new Set([
17
+ export const CONSULTANT_ELIGIBLE_SOURCE_PHASES = new Set([
18
18
  'reviewer_scope',
19
19
  'reviewer_plan',
20
20
  'coder_scope',
21
21
  'coder_response',
22
22
  'coder_optional_response',
23
23
  ]);
24
- export function isReviewerAdjudicatorPhase(sourcePhase) {
24
+ export function isReviewerConsultantPhase(sourcePhase) {
25
25
  return sourcePhase === 'reviewer_scope' || sourcePhase === 'reviewer_plan';
26
26
  }
27
- function isCoderAdjudicatorPhase(sourcePhase) {
27
+ function isCoderConsultantPhase(sourcePhase) {
28
28
  return (sourcePhase === 'coder_scope' ||
29
29
  sourcePhase === 'coder_response' ||
30
30
  sourcePhase === 'coder_optional_response');
@@ -54,7 +54,7 @@ function normalizeBlockerKey(input, cwd) {
54
54
  // blocked round's commits BEFORE the recovery chokepoint runs, so the tail hash
55
55
  // alone captures "the coder committed new work since the recorded block".
56
56
  // Deliberately NOT evidence: recovery turns/history (they accrue mechanically on
57
- // every adjudicated block, so every repeat would escape and the guard would never
57
+ // every triaged block, so every repeat would escape and the guard would never
58
58
  // fire) and LLM free-text summaries/rationales (never byte-stable across rounds,
59
59
  // which would defeat the guard the same way).
60
60
  function commitTrailEvidenceFingerprint(state) {
@@ -65,7 +65,7 @@ function commitTrailEvidenceFingerprint(state) {
65
65
  // different underlying causes, so the candidate also carries the commit-trail
66
66
  // evidence fingerprint: when the coder has committed new work since a prior
67
67
  // identical block, `findRepeatedRecentBlock` treats the block as carrying new
68
- // evidence and the adjudicator runs instead of short-circuiting.
68
+ // evidence and the consultant runs instead of short-circuiting.
69
69
  export function buildRecentBlockCandidate(state, reason, sourcePhase) {
70
70
  const blocker = reason.trim();
71
71
  return {
@@ -83,7 +83,7 @@ export function buildRecentBlockCandidate(state, reason, sourcePhase) {
83
83
  // (a non-empty `evidenceFingerprint` that differs from the prior one — i.e. the
84
84
  // coder has committed new work since the recorded block), it is NOT a repeat —
85
85
  // the underlying situation has changed even though the blocker text has not — so
86
- // `null` is returned and the adjudicator gets to look again.
86
+ // `null` is returned and the consultant gets to look again.
87
87
  export function findRepeatedRecentBlock(recentBlocks, candidate) {
88
88
  for (const prior of [...recentBlocks].reverse()) {
89
89
  if (prior.scopeNumber !== candidate.scopeNumber) {
@@ -180,39 +180,39 @@ async function buildCoderInlineContext(state, reason) {
180
180
  };
181
181
  }
182
182
  // Always supplies a non-null InlineReviewerContext built entirely from in-memory
183
- // OrchestrationState artifacts, so the adjudicator works for every reviewer
183
+ // OrchestrationState artifacts, so the consultant works for every reviewer
184
184
  // provider including no-read providers. Throws for any source phase outside
185
- // `ADJUDICATOR_ELIGIBLE_SOURCE_PHASES`, so an ineligible phase can never silently
185
+ // `CONSULTANT_ELIGIBLE_SOURCE_PHASES`, so an ineligible phase can never silently
186
186
  // reach an LLM round.
187
- async function buildAdjudicatorInlineContext(state, reason, sourcePhase) {
188
- if (isReviewerAdjudicatorPhase(sourcePhase)) {
187
+ async function buildConsultantInlineContext(state, reason, sourcePhase) {
188
+ if (isReviewerConsultantPhase(sourcePhase)) {
189
189
  return buildReviewerInlineContext(state);
190
190
  }
191
- if (isCoderAdjudicatorPhase(sourcePhase)) {
191
+ if (isCoderConsultantPhase(sourcePhase)) {
192
192
  return buildCoderInlineContext(state, reason);
193
193
  }
194
- throw new Error(`Blocked adjudicator cannot build context for ineligible source phase: ${String(sourcePhase)}`);
194
+ throw new Error(`Consultant cannot build context for ineligible source phase: ${String(sourcePhase)}`);
195
195
  }
196
- // Thin, read-only adjudicator for every triaged block class. It first applies the
196
+ // Thin, read-only consultant for every triaged block class. It first applies the
197
197
  // pure anti-thrash guard against `state.recentBlocks`: if this block repeats a
198
198
  // recent block for the same scope identity + sourcePhase + normalized key with no
199
199
  // new evidence, it returns a non-recoverable verdict WITHOUT running an LLM round.
200
200
  // Otherwise it assembles the InlineReviewerContext for the source phase and runs
201
201
  // the reviewer round. The module performs NO writes and NO commits — it only
202
- // returns a `BlockedAdjudicatorVerdict`; `state.recentBlocks` is written elsewhere
202
+ // returns a `ConsultantVerdict`; `state.recentBlocks` is written elsewhere
203
203
  // (the recovery chokepoint), never here.
204
- export async function runBlockedAdjudicator(state, reason, sourcePhase, logger) {
204
+ export async function runConsultant(state, reason, sourcePhase, logger) {
205
205
  const candidate = buildRecentBlockCandidate(state, reason, sourcePhase);
206
206
  if (findRepeatedRecentBlock(state.recentBlocks, candidate)) {
207
207
  return {
208
208
  recoverable: false,
209
209
  triageCategory: 'impossible_task',
210
210
  resolutionDirective: '',
211
- rationale: 'This blocker repeats a recent block for the same scope with no new evidence; the adjudicator short-circuited to avoid thrashing without re-running a reviewer round.',
211
+ rationale: 'This blocker repeats a recent block for the same scope with no new evidence; the consultant short-circuited to avoid thrashing without re-running a reviewer round.',
212
212
  };
213
213
  }
214
- const inlineContext = await buildAdjudicatorInlineContext(state, reason, sourcePhase);
215
- const { verdict } = await runBlockedAdjudicatorRound({
214
+ const inlineContext = await buildConsultantInlineContext(state, reason, sourcePhase);
215
+ const { verdict } = await runConsultantRound({
216
216
  reviewer: state.agentConfig.reviewer,
217
217
  cwd: state.cwd,
218
218
  blockedReason: reason,
@@ -1,7 +1,6 @@
1
1
  import { mkdir } from 'node:fs/promises';
2
- import { resolve } from 'node:path';
3
2
  import { runCoderResponseRound, runReviewerRound } from '../agents.js';
4
- import { createInlineSection, readOnlyReviewerNeedsInlinedDiff, readTextForInlineSection, reviewerNeedsInlineContext, } from '../context/inline-review-context.js';
3
+ import { readOnlyReviewerNeedsInlinedDiff } from '../context/inline-review-context.js';
5
4
  import { buildAndPersistReviewerContextPacket } from '../context/reviewer-context.js';
6
5
  import { getReviewStuckWindow } from '../config.js';
7
6
  import { EXECUTE_FINALIZATION_PHASE } from '../execute-finalization.js';
@@ -132,11 +131,16 @@ function canonicalSetsEqual(left, right) {
132
131
  }
133
132
  return true;
134
133
  }
135
- export function hasRepeatedUnresolvedBlockingCanonicals(args) {
134
+ // Pure convergence helper: it takes the already-resolved review-stuck window so
135
+ // a side-effect-free caller (the plan-review round core) can resolve config once
136
+ // outside and pass the value in. The cwd-taking wrapper below preserves the
137
+ // existing public behavior — execute-review keeps calling it — and now resolves
138
+ // the window a single time before delegating, so no path reads config twice.
139
+ export function hasRepeatedUnresolvedBlockingCanonicalsForWindow(args) {
136
140
  if (args.currentOpenBlockingCanonicals.size === 0) {
137
141
  return false;
138
142
  }
139
- const reviewStuckWindow = getReviewStuckWindow(args.cwd);
143
+ const { reviewStuckWindow } = args;
140
144
  const snapshots = [
141
145
  ...args.rounds.map((round) => round.openBlockingCanonicalIds ? new Set(round.openBlockingCanonicalIds) : null),
142
146
  args.currentOpenBlockingCanonicals,
@@ -147,7 +151,7 @@ export function hasRepeatedUnresolvedBlockingCanonicals(args) {
147
151
  const recentSnapshots = snapshots.slice(-reviewStuckWindow);
148
152
  if (recentSnapshots.some((snapshot) => snapshot === null)) {
149
153
  // Older round records predate canonical-set snapshots; fall back to count-based stuckness.
150
- return hasRepeatedNonReduction(args.rounds, args.currentOpenBlockingCanonicals.size, args.cwd);
154
+ return hasRepeatedNonReductionForWindow(args.rounds, args.currentOpenBlockingCanonicals.size, reviewStuckWindow);
151
155
  }
152
156
  for (let index = 1; index < recentSnapshots.length; index += 1) {
153
157
  if (!canonicalSetsEqual(recentSnapshots[index], recentSnapshots[index - 1])) {
@@ -156,9 +160,15 @@ export function hasRepeatedUnresolvedBlockingCanonicals(args) {
156
160
  }
157
161
  return true;
158
162
  }
159
- export function hasRepeatedNonReduction(rounds, currentCount, cwd) {
163
+ export function hasRepeatedUnresolvedBlockingCanonicals(args) {
164
+ return hasRepeatedUnresolvedBlockingCanonicalsForWindow({
165
+ rounds: args.rounds,
166
+ currentOpenBlockingCanonicals: args.currentOpenBlockingCanonicals,
167
+ reviewStuckWindow: getReviewStuckWindow(args.cwd),
168
+ });
169
+ }
170
+ export function hasRepeatedNonReductionForWindow(rounds, currentCount, reviewStuckWindow) {
160
171
  const counts = [...rounds.map((round) => round.openBlockingCanonicalCount), currentCount];
161
- const reviewStuckWindow = getReviewStuckWindow(cwd);
162
172
  if (counts.length < reviewStuckWindow || currentCount <= 0) {
163
173
  return false;
164
174
  }
@@ -170,6 +180,9 @@ export function hasRepeatedNonReduction(rounds, currentCount, cwd) {
170
180
  }
171
181
  return true;
172
182
  }
183
+ export function hasRepeatedNonReduction(rounds, currentCount, cwd) {
184
+ return hasRepeatedNonReductionForWindow(rounds, currentCount, getReviewStuckWindow(cwd));
185
+ }
173
186
  export function getReopenedCanonical(findings) {
174
187
  const roundsByCanonical = new Map();
175
188
  for (const finding of findings) {
@@ -261,18 +274,6 @@ function validateExecuteResponseCoverage(args) {
261
274
  throw new Error(`Coder ${args.mode} response did not disposition every open finding: ${[...missingIds].join(', ')}`);
262
275
  }
263
276
  }
264
- async function buildExecuteReviewerInlineContext(args) {
265
- const diff = await args.getDiffForRange(args.state.cwd, args.baseCommit, args.headCommit);
266
- const planContent = await readTextForInlineSection(resolve(args.state.cwd, args.planDoc));
267
- const reviewHistory = await readTextForInlineSection(args.state.reviewMarkdownPath);
268
- return {
269
- sections: [
270
- createInlineSection(`Full diff for commit range ${args.baseCommit}..${args.headCommit}`, diff || '(empty diff)'),
271
- createInlineSection(`Plan document content (${args.planDoc})`, planContent || '(plan document content unavailable)'),
272
- createInlineSection('Prior review history (REVIEW.md content)', reviewHistory || '(no prior review history)'),
273
- ],
274
- };
275
- }
276
277
  export async function runExecuteReviewerAdjudication(args) {
277
278
  if (!args.state.baseCommit) {
278
279
  throw new Error('Cannot run execute reviewer adjudication without baseCommit.');
@@ -288,27 +289,20 @@ export async function runExecuteReviewerAdjudication(args) {
288
289
  const changedFiles = await args.getChangedFilesForRange(args.state.cwd, args.state.baseCommit, headCommit);
289
290
  const scratchDir = getScopeReviewerScratchDir(args.state.runDir, context.parentScopeLabel, context.round);
290
291
  await mkdir(scratchDir, { recursive: true });
291
- // Full diffs are collected only for reviewers without repository read
292
- // access; read-capable reviewers inspect the range with their own tools.
293
- const inlineContext = reviewerNeedsInlineContext(args.state.agentConfig.reviewer)
294
- ? await buildExecuteReviewerInlineContext({
295
- state: args.state,
296
- planDoc: context.planDoc,
297
- baseCommit: args.state.baseCommit,
298
- headCommit,
299
- getDiffForRange: args.getDiffForRange,
300
- })
301
- : null;
302
292
  // Read-only reviewers with read tools but no commit-range diff tool (native
303
293
  // Claude/Codex) get the commit-range diff inlined directly so they can see
304
294
  // exactly what changed without shell access; read-only reviewers that expose
305
- // their own commit-range diff tool (generic-agentic) inspect the range with
295
+ // their own commit-range diff tool (openai-compatible) inspect the range with
306
296
  // that tool instead.
307
297
  const inlinedRangeDiff = readOnlyReviewerNeedsInlinedDiff(args.state.agentConfig.reviewer)
308
298
  ? await args.getDiffForRange(args.state.cwd, args.state.baseCommit, headCommit)
309
299
  : null;
310
300
  const reviewerResult = await (args.runReviewerRound ?? runReviewerRound)({
311
301
  reviewer: args.state.agentConfig.reviewer,
302
+ // Resume the reviewer's own session from the previous round of this
303
+ // scope's review engagement (null on round 1: scope-boundary resets clear
304
+ // it). Sessionless providers are filtered at the round layer.
305
+ resumeHandle: args.state.reviewerSessionHandle,
312
306
  cwd: args.state.cwd,
313
307
  planDoc: context.planDoc,
314
308
  baseCommit: args.state.baseCommit,
@@ -324,7 +318,6 @@ export async function runExecuteReviewerAdjudication(args) {
324
318
  recentHistorySummary: context.recentHistorySummary,
325
319
  scratchDir,
326
320
  reviewerContext: await buildAndPersistReviewerContextPacket({ state: args.state }),
327
- inlineContext,
328
321
  inlinedRangeDiff,
329
322
  unattended: args.state.unattended,
330
323
  logger: args.logger,
@@ -498,11 +491,18 @@ export function synthesizeExecuteReviewerState(args) {
498
491
  const openBlockingCanonicalSet = getOpenBlockingCanonicalSet(mergedFindings);
499
492
  const openBlockingCanonicalIds = [...openBlockingCanonicalSet].sort();
500
493
  const openBlockingCanonicalCount = openBlockingCanonicalSet.size;
494
+ // Two stall shapes route to the consultant: the identical canonical set
495
+ // persisting across the window (a hard deadlock on the same findings), and
496
+ // the open-blocking COUNT failing to decrease across the window while the
497
+ // canonicals rotate — the one-novel-finding-per-round trickle, where each
498
+ // round retires a canonical and mints a new one so the set-based detector
499
+ // never fires (observed live: 6 flat rounds of 1 novel finding each, 11
500
+ // rounds total on one scope, zero re-opens).
501
501
  const stalledBlockingCount = hasRepeatedUnresolvedBlockingCanonicals({
502
502
  rounds: args.state.rounds,
503
503
  currentOpenBlockingCanonicals: openBlockingCanonicalSet,
504
504
  cwd: args.state.cwd,
505
- });
505
+ }) || hasRepeatedNonReduction(args.state.rounds, openBlockingCanonicalCount, args.state.cwd);
506
506
  const reopenedCanonical = getReopenedCanonical(mergedFindings);
507
507
  const shouldBlockForConvergence = Boolean(reopenedCanonical || stalledBlockingCount);
508
508
  const disposition = resolveExecuteReviewDisposition({
@@ -1,7 +1,6 @@
1
1
  import { mkdir } from 'node:fs/promises';
2
- import { resolve } from 'node:path';
3
2
  import { runCoderFinalCompletionSummaryRound, runReviewerFinalCompletionRound, } from '../agents.js';
4
- import { createInlineSection, readOnlyReviewerNeedsInlinedDiff, readTextForInlineSection, reviewerNeedsInlineContext, } from '../context/inline-review-context.js';
3
+ import { readOnlyReviewerNeedsInlinedDiff } from '../context/inline-review-context.js';
5
4
  import { buildAndPersistReviewerContextPacket } from '../context/reviewer-context.js';
6
5
  import { getDiffForRange } from '../git.js';
7
6
  import { getFinalCompletionView } from '../state-views.js';
@@ -35,23 +34,6 @@ export async function runFinalCompletionSummaryAdjudication(args) {
35
34
  summary,
36
35
  };
37
36
  }
38
- async function buildFinalCompletionReviewerInlineContext(args) {
39
- const aggregate = args.packet.aggregateReviewContext;
40
- const sections = [];
41
- if (aggregate.unavailableReason !== null || !aggregate.baseCommit || !aggregate.headCommit) {
42
- // Inline the evidence-gap statement instead of a diff so the no-read
43
- // reviewer treats the missing aggregate range as a completion-review
44
- // evidence gap rather than proof of correctness.
45
- sections.push(createInlineSection('Aggregate review evidence gap', `The aggregate diff for this completion review is unavailable: ${aggregate.unavailableReason ?? 'no aggregate commit range was resolved'}. Treat this as a completion-review evidence gap rather than proof that the aggregate implementation is correct.`));
46
- }
47
- else {
48
- const diff = await args.getDiffForRange(args.state.cwd, aggregate.baseCommit, aggregate.headCommit);
49
- sections.push(createInlineSection(`Full aggregate diff for range ${aggregate.range ?? `${aggregate.baseCommit}..${aggregate.headCommit}`}`, diff || '(empty diff)'));
50
- }
51
- const planContent = await readTextForInlineSection(resolve(args.state.cwd, args.state.planDoc));
52
- sections.push(createInlineSection(`Plan document content (${args.state.planDoc})`, planContent || '(plan document content unavailable)'));
53
- return { sections };
54
- }
55
37
  export async function runFinalCompletionReviewerAdjudication(args) {
56
38
  const finalCompletion = getFinalCompletionView(args.state);
57
39
  if (!finalCompletion?.summary) {
@@ -63,20 +45,10 @@ export async function runFinalCompletionReviewerAdjudication(args) {
63
45
  });
64
46
  const scratchDir = getFinalCompletionReviewerScratchDir(args.state.runDir);
65
47
  await mkdir(scratchDir, { recursive: true });
66
- // The aggregate diff is collected only for reviewers without repository read
67
- // access; read-capable reviewers inspect the aggregate range with their own
68
- // tools.
69
- const inlineContext = reviewerNeedsInlineContext(args.state.agentConfig.reviewer)
70
- ? await buildFinalCompletionReviewerInlineContext({
71
- state: args.state,
72
- packet: context.packet,
73
- getDiffForRange: args.getDiffForRange ?? getDiffForRange,
74
- })
75
- : null;
76
48
  // Read-only reviewers with read tools but no commit-range diff tool (native
77
49
  // Claude/Codex) get the aggregate commit-range diff inlined directly when the
78
50
  // aggregate range is available; read-only reviewers that expose their own
79
- // commit-range diff tool (generic-agentic) inspect the range with that tool.
51
+ // commit-range diff tool (openai-compatible) inspect the range with that tool.
80
52
  const aggregate = context.packet.aggregateReviewContext;
81
53
  const inlinedRangeDiff = readOnlyReviewerNeedsInlinedDiff(args.state.agentConfig.reviewer) &&
82
54
  aggregate.unavailableReason === null &&
@@ -92,7 +64,6 @@ export async function runFinalCompletionReviewerAdjudication(args) {
92
64
  summary: finalCompletion.summary,
93
65
  scratchDir,
94
66
  reviewerContext: await buildAndPersistReviewerContextPacket({ state: args.state }),
95
- inlineContext,
96
67
  inlinedRangeDiff,
97
68
  unattended: args.state.unattended,
98
69
  logger: args.logger,