@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.
- 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 +18 -19
- 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
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.
|
|
5
|
-
|
|
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 `
|
|
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
|
|
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
|
|
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
|
|
45
|
-
- The `
|
|
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)
|
|
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 `
|
|
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
|
|
61
|
-
files outside the repository such as `~/.ssh` or cloud credentials
|
|
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
|
|
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
|
|
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
|
-
`
|
|
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
|
|
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 {
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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
|
|
24
|
+
export function isReviewerConsultantPhase(sourcePhase) {
|
|
25
25
|
return sourcePhase === 'reviewer_scope' || sourcePhase === 'reviewer_plan';
|
|
26
26
|
}
|
|
27
|
-
function
|
|
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
|
|
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
|
|
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
|
|
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
|
|
183
|
+
// OrchestrationState artifacts, so the consultant works for every reviewer
|
|
184
184
|
// provider including no-read providers. Throws for any source phase outside
|
|
185
|
-
// `
|
|
185
|
+
// `CONSULTANT_ELIGIBLE_SOURCE_PHASES`, so an ineligible phase can never silently
|
|
186
186
|
// reach an LLM round.
|
|
187
|
-
async function
|
|
188
|
-
if (
|
|
187
|
+
async function buildConsultantInlineContext(state, reason, sourcePhase) {
|
|
188
|
+
if (isReviewerConsultantPhase(sourcePhase)) {
|
|
189
189
|
return buildReviewerInlineContext(state);
|
|
190
190
|
}
|
|
191
|
-
if (
|
|
191
|
+
if (isCoderConsultantPhase(sourcePhase)) {
|
|
192
192
|
return buildCoderInlineContext(state, reason);
|
|
193
193
|
}
|
|
194
|
-
throw new Error(`
|
|
194
|
+
throw new Error(`Consultant cannot build context for ineligible source phase: ${String(sourcePhase)}`);
|
|
195
195
|
}
|
|
196
|
-
// Thin, read-only
|
|
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 `
|
|
202
|
+
// returns a `ConsultantVerdict`; `state.recentBlocks` is written elsewhere
|
|
203
203
|
// (the recovery chokepoint), never here.
|
|
204
|
-
export async function
|
|
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
|
|
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
|
|
215
|
-
const { verdict } = await
|
|
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 {
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
|
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 (
|
|
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 {
|
|
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 (
|
|
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,
|