@navels/neal 0.3.3 → 0.4.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/dist/neal/adjudicator/execute.js +38 -0
- package/dist/neal/agents/structured-json.js +43 -2
- package/dist/neal/context/reviewer-context.js +14 -1
- package/dist/neal/git.js +8 -0
- package/dist/neal/orchestrator/phases/review.js +2 -1
- package/dist/neal/prompts/execute.js +31 -1
- package/dist/neal/prompts/specs.js +8 -1
- package/dist/neal/providers/anthropic-claude.js +4 -0
- package/dist/neal/review-findings/artifacts.js +41 -23
- package/dist/neal/review-findings/prompts.js +22 -3
- package/dist/neal/review-findings/provider.js +2 -2
- package/dist/neal/review-findings/run.js +8 -2
- package/docs/prompt-specs.md +21 -1
- package/docs/providers.md +59 -19
- package/docs/storage.md +1 -1
- package/package.json +8 -8
|
@@ -3,6 +3,7 @@ import { runCoderResponseRound, runReviewerRound } from '../agents.js';
|
|
|
3
3
|
import { readOnlyReviewerNeedsInlinedDiff } from '../context/inline-review-context.js';
|
|
4
4
|
import { buildAndPersistReviewerContextPacket } from '../context/reviewer-context.js';
|
|
5
5
|
import { getReviewStuckWindow } from '../config.js';
|
|
6
|
+
import { getDiffForRangePaths } from '../git.js';
|
|
6
7
|
import { EXECUTE_FINALIZATION_PHASE } from '../execute-finalization.js';
|
|
7
8
|
import { classifyAlreadySatisfiedTopLevelScopeAcceptance, classifyEmptyDerivedParentAdvance, getExecutionPlanPath, getParentScopeLabel, renderRecentAcceptedScopesSummary, } from '../scopes.js';
|
|
8
9
|
import { getScopeReviewerScratchDir } from '../storage-paths.js';
|
|
@@ -297,6 +298,11 @@ export async function runExecuteReviewerAdjudication(args) {
|
|
|
297
298
|
const inlinedRangeDiff = readOnlyReviewerNeedsInlinedDiff(args.state.agentConfig.reviewer)
|
|
298
299
|
? await args.getDiffForRange(args.state.cwd, args.state.baseCommit, headCommit)
|
|
299
300
|
: null;
|
|
301
|
+
const earlierScopeChanges = await collectEarlierScopeChanges({
|
|
302
|
+
state: args.state,
|
|
303
|
+
changedFiles,
|
|
304
|
+
getDiffForRangePaths: args.getDiffForRangePaths ?? getDiffForRangePaths,
|
|
305
|
+
});
|
|
300
306
|
const reviewerResult = await (args.runReviewerRound ?? runReviewerRound)({
|
|
301
307
|
reviewer: args.state.agentConfig.reviewer,
|
|
302
308
|
// Resume the reviewer's own session. The handle persists across review
|
|
@@ -322,6 +328,7 @@ export async function runExecuteReviewerAdjudication(args) {
|
|
|
322
328
|
scratchDir,
|
|
323
329
|
reviewerContext: await buildAndPersistReviewerContextPacket({ state: args.state }),
|
|
324
330
|
inlinedRangeDiff,
|
|
331
|
+
earlierScopeChanges,
|
|
325
332
|
unattended: args.state.unattended,
|
|
326
333
|
logger: args.logger,
|
|
327
334
|
});
|
|
@@ -334,6 +341,37 @@ export async function runExecuteReviewerAdjudication(args) {
|
|
|
334
341
|
reviewerResult,
|
|
335
342
|
};
|
|
336
343
|
}
|
|
344
|
+
// Files in the current scope's diff that an earlier accepted scope also
|
|
345
|
+
// changed, each paired with that earlier scope's diff restricted to the file.
|
|
346
|
+
// The reviewer session is the only long-lived memory across scopes, and its
|
|
347
|
+
// record of an earlier scope is the coder's summary; this turns "scope 3's
|
|
348
|
+
// file moved during scope 6" into something it reads instead of something it
|
|
349
|
+
// has to remember. Only accepted scopes count, and a scope replaced by a
|
|
350
|
+
// derived plan is skipped because its work was reset. Scopes with no
|
|
351
|
+
// recorded commit range are skipped.
|
|
352
|
+
export async function collectEarlierScopeChanges(args) {
|
|
353
|
+
const currentFiles = new Set(args.changedFiles);
|
|
354
|
+
const changes = [];
|
|
355
|
+
for (const scope of args.state.completedScopes) {
|
|
356
|
+
if (scope.result !== 'accepted' || scope.replacedByDerivedPlanPath || !scope.baseCommit || !scope.finalCommit) {
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
for (const file of scope.changedFiles) {
|
|
360
|
+
if (!currentFiles.has(file)) {
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
const diff = await args.getDiffForRangePaths(args.state.cwd, scope.baseCommit, scope.finalCommit, [file]);
|
|
364
|
+
changes.push({
|
|
365
|
+
file,
|
|
366
|
+
scopeNumber: scope.number,
|
|
367
|
+
baseCommit: scope.baseCommit,
|
|
368
|
+
finalCommit: scope.finalCommit,
|
|
369
|
+
diff,
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
return changes;
|
|
374
|
+
}
|
|
337
375
|
function classifyReviewerParentAdvance(args) {
|
|
338
376
|
if (args.meaningfulProgressAction !== 'advance_parent' && args.meaningfulProgressAction !== 'block_for_operator') {
|
|
339
377
|
return null;
|
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
import { NealProviderError } from '../providers/types.js';
|
|
2
2
|
const SUMMARY_MAX_LENGTH = 800;
|
|
3
|
-
|
|
3
|
+
// Bound on the original response echoed into a repair prompt. A real
|
|
4
|
+
// `neal review` payload with nine findings ended at ~14,400 chars, so the old
|
|
5
|
+
// 12,000 cap handed the repair model a JSON object cut off mid-finding and it
|
|
6
|
+
// invented a "truncated" warning and dropped a finding (#11). 60,000 leaves
|
|
7
|
+
// about 4x headroom over that payload while still bounding a runaway response.
|
|
8
|
+
const ORIGINAL_RESPONSE_MAX_LENGTH = 60000;
|
|
9
|
+
// Any fenced block, with or without a language label, that is the only
|
|
10
|
+
// non-whitespace content of a response. Accepted with raw-JSON tolerance
|
|
11
|
+
// because repair turns render "raw whole-response JSON object" as a ```json
|
|
12
|
+
// fence and that payload is otherwise complete.
|
|
13
|
+
const GENERIC_OPENING_FENCE_PATTERN = /^[ \t]*```[ \t]*[A-Za-z0-9_-]*[ \t]*$/;
|
|
4
14
|
const NEAL_JSON_OPENING_FENCE_PATTERN = /^[ \t]*```[ \t]*neal-json[ \t]*$/;
|
|
5
15
|
const CLOSING_FENCE_PATTERN = /^[ \t]*```[ \t]*$/;
|
|
6
16
|
export function buildStructuredJsonPrompt(basePrompt, protocol) {
|
|
@@ -67,12 +77,16 @@ export function guardStructuredJsonOutputFormatLines(lines, label) {
|
|
|
67
77
|
export function extractStructuredJsonPayload(assistantText) {
|
|
68
78
|
const blocks = findNealJsonBlocks(assistantText);
|
|
69
79
|
if (blocks.length > 1) {
|
|
80
|
+
// Still a failure, but hand the first block's JSON to the repair prompt so
|
|
81
|
+
// the repair model works from the real payload instead of a truncated
|
|
82
|
+
// transcript. The first block is the one the prompt contract asked for;
|
|
83
|
+
// later blocks are usually echoes or rewrites of it (#11).
|
|
70
84
|
return {
|
|
71
85
|
ok: false,
|
|
72
86
|
errorKind: 'multiple_control_blocks',
|
|
73
87
|
errorSummary: `Expected exactly one final neal-json control block, but found ${blocks.length}.`,
|
|
74
88
|
prose: assistantText.trim(),
|
|
75
|
-
rawJson:
|
|
89
|
+
rawJson: blocks[0].rawJson,
|
|
76
90
|
};
|
|
77
91
|
}
|
|
78
92
|
if (blocks.length === 1) {
|
|
@@ -96,6 +110,15 @@ export function extractStructuredJsonPayload(assistantText) {
|
|
|
96
110
|
});
|
|
97
111
|
}
|
|
98
112
|
const trimmed = assistantText.trim();
|
|
113
|
+
const loneFencedJson = extractLoneFencedJson(trimmed);
|
|
114
|
+
if (loneFencedJson !== null) {
|
|
115
|
+
return parseExtractedJson({
|
|
116
|
+
source: 'raw-json',
|
|
117
|
+
prose: '',
|
|
118
|
+
rawJson: loneFencedJson,
|
|
119
|
+
malformedSummaryPrefix: 'The fenced JSON response was invalid',
|
|
120
|
+
});
|
|
121
|
+
}
|
|
99
122
|
if (!looksLikeRawJsonObject(trimmed)) {
|
|
100
123
|
return {
|
|
101
124
|
ok: false,
|
|
@@ -524,6 +547,24 @@ function parseExtractedJson(args) {
|
|
|
524
547
|
function looksLikeRawJsonObject(trimmed) {
|
|
525
548
|
return trimmed.startsWith('{') || trimmed.startsWith('[');
|
|
526
549
|
}
|
|
550
|
+
// Returns the inner text when the whole (trimmed) response is exactly one
|
|
551
|
+
// fenced block: an opening fence line, content with no fence lines inside, and
|
|
552
|
+
// a closing fence as the last line. Null otherwise, including when anything
|
|
553
|
+
// sits outside the fence; that case keeps its existing error.
|
|
554
|
+
function extractLoneFencedJson(trimmed) {
|
|
555
|
+
const lines = trimmed.split(/\r?\n/);
|
|
556
|
+
if (lines.length < 3) {
|
|
557
|
+
return null;
|
|
558
|
+
}
|
|
559
|
+
if (!GENERIC_OPENING_FENCE_PATTERN.test(lines[0]) || !CLOSING_FENCE_PATTERN.test(lines[lines.length - 1])) {
|
|
560
|
+
return null;
|
|
561
|
+
}
|
|
562
|
+
const inner = lines.slice(1, -1);
|
|
563
|
+
if (inner.some((line) => /^[ \t]*```/.test(line))) {
|
|
564
|
+
return null;
|
|
565
|
+
}
|
|
566
|
+
return inner.join('\n');
|
|
567
|
+
}
|
|
527
568
|
function isJsonObject(value) {
|
|
528
569
|
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
529
570
|
}
|
|
@@ -5,6 +5,10 @@ export const REVIEWER_CONTEXT_JSON = 'REVIEWER_CONTEXT.json';
|
|
|
5
5
|
export const REVIEWER_CONTEXT_MARKDOWN = 'REVIEWER_CONTEXT.md';
|
|
6
6
|
const COMPLETED_SCOPE_LIMIT = 12;
|
|
7
7
|
const FINDING_LIMIT = 24;
|
|
8
|
+
// Files listed per completed scope. The full list stays in RUN_STATE.json;
|
|
9
|
+
// the packet carries enough for the reviewer to see which files an earlier
|
|
10
|
+
// scope owned, with an explicit count when the list is cut.
|
|
11
|
+
const COMPLETED_SCOPE_CHANGED_FILE_LIMIT = 20;
|
|
8
12
|
export async function buildAndPersistReviewerContextPacket(args) {
|
|
9
13
|
const packet = buildReviewerContextPacket(args);
|
|
10
14
|
await mkdir(args.state.runDir, { recursive: true });
|
|
@@ -23,6 +27,8 @@ export function buildReviewerContextPacket(args) {
|
|
|
23
27
|
marker: scope.marker,
|
|
24
28
|
finalCommit: scope.finalCommit,
|
|
25
29
|
summary: scope.summary ?? null,
|
|
30
|
+
changedFiles: scope.changedFiles.slice(0, COMPLETED_SCOPE_CHANGED_FILE_LIMIT),
|
|
31
|
+
changedFileCount: scope.changedFiles.length,
|
|
26
32
|
reviewRounds: scope.reviewRounds,
|
|
27
33
|
findings: scope.findings,
|
|
28
34
|
residualReviewDebt: scope.residualReviewDebt?.length ?? 0,
|
|
@@ -110,7 +116,7 @@ function buildReviewerContextCitations(state) {
|
|
|
110
116
|
}
|
|
111
117
|
export function renderReviewerContextMarkdown(packet) {
|
|
112
118
|
const scopeLines = packet.completedScopes.length
|
|
113
|
-
? packet.completedScopes.map((scope) => `- Scope ${scope.number}: ${scope.result}; marker=${scope.marker}; finalCommit=${scope.finalCommit ?? 'none'}; reviewRounds=${scope.reviewRounds}; findings=${scope.findings}; residualDebt=${scope.residualReviewDebt}; summary=${scope.summary ?? 'none'}`)
|
|
119
|
+
? packet.completedScopes.map((scope) => `- Scope ${scope.number}: ${scope.result}; marker=${scope.marker}; finalCommit=${scope.finalCommit ?? 'none'}; reviewRounds=${scope.reviewRounds}; findings=${scope.findings}; residualDebt=${scope.residualReviewDebt}; files=${formatCompletedScopeFiles(scope)}; summary=${scope.summary ?? 'none'}`)
|
|
114
120
|
: ['- none'];
|
|
115
121
|
const findingLines = packet.findings.length
|
|
116
122
|
? packet.findings.map((finding) => `- ${finding.canonicalId} (${finding.id}): ${finding.severity}/${finding.status}; source=${finding.source}; files=${finding.files.join(', ') || 'none'}; claim=${finding.claim}; requiredAction=${finding.requiredAction}`)
|
|
@@ -157,6 +163,13 @@ export function renderReviewerContextMarkdown(packet) {
|
|
|
157
163
|
...packet.citations.map((citation) => `- ${citation.label}: ${citation.path}`),
|
|
158
164
|
].join('\n');
|
|
159
165
|
}
|
|
166
|
+
function formatCompletedScopeFiles(scope) {
|
|
167
|
+
if (scope.changedFiles.length === 0) {
|
|
168
|
+
return 'none';
|
|
169
|
+
}
|
|
170
|
+
const omitted = scope.changedFileCount - scope.changedFiles.length;
|
|
171
|
+
return omitted > 0 ? `${scope.changedFiles.join(', ')} (+${omitted} more)` : scope.changedFiles.join(', ');
|
|
172
|
+
}
|
|
160
173
|
function runIdFromDir(runDir) {
|
|
161
174
|
const normalized = runDir.replace(/\/+$/, '');
|
|
162
175
|
return normalized.slice(normalized.lastIndexOf('/') + 1);
|
package/dist/neal/git.js
CHANGED
|
@@ -197,6 +197,14 @@ export async function getDiffForRange(cwd, base, head) {
|
|
|
197
197
|
}
|
|
198
198
|
return runGit(['diff', '--find-renames', `${base}..${head}`], cwd);
|
|
199
199
|
}
|
|
200
|
+
// Range diff restricted to the given paths. Fixed argv with a `--` separator,
|
|
201
|
+
// never a shell string, so a path can never be read as a revision or option.
|
|
202
|
+
export async function getDiffForRangePaths(cwd, base, head, paths) {
|
|
203
|
+
if (base === head || paths.length === 0) {
|
|
204
|
+
return '';
|
|
205
|
+
}
|
|
206
|
+
return runGit(['diff', '--find-renames', `${base}..${head}`, '--', ...paths], cwd);
|
|
207
|
+
}
|
|
200
208
|
export async function getChangedFilesForRange(cwd, base, head) {
|
|
201
209
|
if (base === head) {
|
|
202
210
|
return [];
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { ReviewerRoundError } from '../../agents.js';
|
|
2
2
|
import { runExecuteReviewerAdjudication, synthesizeExecuteReviewerState, } from '../../adjudicator/execute.js';
|
|
3
3
|
import { assertAdjudicationTransitionSignal } from '../../adjudicator/specs.js';
|
|
4
|
-
import { getChangedFilesForRange, getCommitRange, getDiffForRange, getDiffStatForRange, getHeadCommit, } from '../../git.js';
|
|
4
|
+
import { getChangedFilesForRange, getCommitRange, getDiffForRange, getDiffForRangePaths, getDiffStatForRange, getHeadCommit, } from '../../git.js';
|
|
5
5
|
import { writeDetail } from '../../diagnostic.js';
|
|
6
6
|
import { saveState } from '../../state.js';
|
|
7
7
|
import { writeExecutionArtifacts } from '../artifacts.js';
|
|
@@ -41,6 +41,7 @@ export async function runReviewPhase(state, statePath, logger) {
|
|
|
41
41
|
getDiffStatForRange,
|
|
42
42
|
getChangedFilesForRange,
|
|
43
43
|
getDiffForRange,
|
|
44
|
+
getDiffForRangePaths,
|
|
44
45
|
}));
|
|
45
46
|
reviewerSynthesis = synthesizeExecuteReviewerState({
|
|
46
47
|
state,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { renderInlinedRangeDiffSection } from '../context/inline-review-context.js';
|
|
1
|
+
import { renderInlinedRangeDiffSection, truncateInlineSectionBody } from '../context/inline-review-context.js';
|
|
2
2
|
import { AUTONOMY_BLOCKED, AUTONOMY_DONE, AUTONOMY_SCOPE_DONE, AUTONOMY_SPLIT_PLAN, buildProgressSection, getCanonicalPlanContractLines, getDerivedPlanSectionContractLines, getExecuteScopeProgressPayloadContractLines, getProtocolMarkerArtifactProhibitionLines, getStandalonePlanPayloadSourceOfTruthLines, getTerminalMarkerArtifactBoundaryLines, getUnattendedAutonomyLines, } from './shared.js';
|
|
3
3
|
import { assertPromptBuilder } from './assert-builder.js';
|
|
4
4
|
import { getUserGuidanceLines } from './guidance.js';
|
|
@@ -107,6 +107,11 @@ export function buildLegacyScopePrompt(planDoc, progressText) {
|
|
|
107
107
|
`- ${AUTONOMY_SPLIT_PLAN}`,
|
|
108
108
|
].join('\n');
|
|
109
109
|
}
|
|
110
|
+
export const EARLIER_SCOPE_CHANGES_SECTION_HEADING = '## Earlier-scope changes to files in this diff';
|
|
111
|
+
// Rendered for every execute-scope review, with or without an overlap: a
|
|
112
|
+
// tool-access reviewer can find earlier-scope history itself, and the rule
|
|
113
|
+
// about what that history means must not depend on whether Neal inlined it.
|
|
114
|
+
const EARLIER_SCOPE_PRESERVATION_LINE = "A change to a file that an earlier accepted scope signed off must preserve what that scope's review accepted. Weakening or removing a test, assertion, or check that an earlier scope introduced is a blocking finding unless the plan explicitly calls for it.";
|
|
110
115
|
export function buildReviewerPrompt(args) {
|
|
111
116
|
const spec = assertPromptBuilder('scope_reviewer', 'buildReviewerPrompt', PROMPT_MODULE_PATH);
|
|
112
117
|
const primaryVariant = spec.variants.find((variant) => variant.kind === 'primary');
|
|
@@ -166,6 +171,7 @@ export function buildReviewerPrompt(args) {
|
|
|
166
171
|
...getFindingQualityLines(),
|
|
167
172
|
...skepticismLines,
|
|
168
173
|
...regressionLines,
|
|
174
|
+
EARLIER_SCOPE_PRESERVATION_LINE,
|
|
169
175
|
...preexistingLines,
|
|
170
176
|
args.previousHeadCommit
|
|
171
177
|
? `Previous reviewer head was ${args.previousHeadCommit}. Focus especially on changes since that commit, while still considering the full current state.`
|
|
@@ -214,6 +220,30 @@ export function buildReviewerPrompt(args) {
|
|
|
214
220
|
}),
|
|
215
221
|
]
|
|
216
222
|
: []),
|
|
223
|
+
...(args.earlierScopeChanges && args.earlierScopeChanges.length > 0
|
|
224
|
+
? ['', renderEarlierScopeChangesSection(args.earlierScopeChanges)]
|
|
225
|
+
: []),
|
|
226
|
+
].join('\n');
|
|
227
|
+
}
|
|
228
|
+
// Earlier accepted scopes' per-file diffs for files the current diff touches
|
|
229
|
+
// again. The body shares the inlined-diff bound (truncateInlineSectionBody),
|
|
230
|
+
// which appends an explicit truncation marker instead of dropping content
|
|
231
|
+
// silently.
|
|
232
|
+
function renderEarlierScopeChangesSection(changes) {
|
|
233
|
+
const body = changes
|
|
234
|
+
.map((change) => [
|
|
235
|
+
`### ${change.file} (scope ${change.scopeNumber}, ${change.baseCommit}..${change.finalCommit})`,
|
|
236
|
+
'',
|
|
237
|
+
change.diff.trim() === '' ? '(empty diff)' : change.diff,
|
|
238
|
+
].join('\n'))
|
|
239
|
+
.join('\n\n');
|
|
240
|
+
return [
|
|
241
|
+
EARLIER_SCOPE_CHANGES_SECTION_HEADING,
|
|
242
|
+
'',
|
|
243
|
+
'The files below were changed by an earlier accepted scope in this run and appear again in the current diff. Each entry shows what that earlier scope did to the file, so what the current scope alters is something you read here rather than something you have to remember.',
|
|
244
|
+
'Check the current diff against each entry before accepting.',
|
|
245
|
+
'',
|
|
246
|
+
truncateInlineSectionBody(body),
|
|
217
247
|
].join('\n');
|
|
218
248
|
}
|
|
219
249
|
function getReviewerContextLines(reviewerContext) {
|
|
@@ -65,6 +65,7 @@ const SCOPE_REVIEWER_CONTEXT = context('ScopeReviewerPromptContext', [
|
|
|
65
65
|
field('recentHistorySummary', 'review_history', true, 'Accepted-scope history for the active parent objective.'),
|
|
66
66
|
field('reviewMarkdownPath', 'run_artifact', true, 'Review artifact that carries prior findings and coder responses.'),
|
|
67
67
|
field('scratchDir', 'run_artifact', true, 'Run-local reviewer scratch directory for temporary verification artifacts.'),
|
|
68
|
+
field('earlierScopeChanges', 'repository_state', false, 'Files in the current scope diff that an earlier accepted scope also changed, each with that scope number, commit range, and per-file diff. Computed from completedScopes in run state; omitted when there is no overlap.'),
|
|
68
69
|
field('accessMode', 'orchestrator_state', false, "Two-way reviewer doctrine access mode derived from the reviewer provider's structured-advisor tool access: 'tool-access' (inspect and execute) or 'read-only' (read tools only; no command execution, test runs, or scratch work). Defaults to 'tool-access' when absent."),
|
|
69
70
|
]);
|
|
70
71
|
const COMPLETION_CODER_CONTEXT = context('CompletionCoderPromptContext', [
|
|
@@ -383,7 +384,7 @@ export const PROMPT_SPECS = [
|
|
|
383
384
|
},
|
|
384
385
|
{
|
|
385
386
|
id: 'scope_reviewer',
|
|
386
|
-
version:
|
|
387
|
+
version: 3,
|
|
387
388
|
changelog: [
|
|
388
389
|
{
|
|
389
390
|
version: 1,
|
|
@@ -393,6 +394,10 @@ export const PROMPT_SPECS = [
|
|
|
393
394
|
version: 2,
|
|
394
395
|
renderSha: '431a75ad341a531535606af607187239a31d12cf921575eb17774b317ad639a0',
|
|
395
396
|
},
|
|
397
|
+
{
|
|
398
|
+
version: 3,
|
|
399
|
+
renderSha: '4b75fab01367f0e4e263bc7635f8753e8e73c08b3ca46cf6bb6e695a67da31a4',
|
|
400
|
+
},
|
|
396
401
|
],
|
|
397
402
|
role: 'reviewer',
|
|
398
403
|
purpose: 'Review execute-scope results for correctness, verification coverage, and meaningful progress toward the active parent objective.',
|
|
@@ -416,11 +421,13 @@ export const PROMPT_SPECS = [
|
|
|
416
421
|
field('reviewMarkdownPath', 'run_artifact', true, 'Review history artifact path.'),
|
|
417
422
|
field('progressJustification', 'review_history', true, 'Coder progress-justification payload.'),
|
|
418
423
|
field('scratchDir', 'run_artifact', true, 'Run-local scratch directory for reviewer verification artifacts.'),
|
|
424
|
+
field('earlierScopeChanges', 'repository_state', false, 'Earlier accepted scopes\' per-file diffs for files the current diff touches again; absent when there is no overlap.'),
|
|
419
425
|
]),
|
|
420
426
|
},
|
|
421
427
|
providerVariants: SHARED_PROVIDER_VARIANTS,
|
|
422
428
|
evaluationNotes: [
|
|
423
429
|
'Render tests should assert reviewer prompts include shared adversarial falsification, verification skepticism, concrete finding-quality doctrine, meaningful-progress instructions, and parent-objective history.',
|
|
430
|
+
'Render tests should assert the earlier-scope preservation line renders in every cell and the earlier-scope changes section renders only when an overlap is supplied.',
|
|
424
431
|
'Future fixture cases should cover cases where local correctness differs from parent-objective convergence.',
|
|
425
432
|
],
|
|
426
433
|
firstMigrationPriority: 2,
|
|
@@ -612,6 +612,7 @@ function buildClaudeCoreQueryOptions(spec) {
|
|
|
612
612
|
...(spec.model ? { model: spec.model } : {}),
|
|
613
613
|
...(spec.effort ? { effort: spec.effort } : {}),
|
|
614
614
|
tools: spec.tools,
|
|
615
|
+
...(spec.strictMcpConfig ? { strictMcpConfig: true } : {}),
|
|
615
616
|
...(spec.hooks ? { hooks: spec.hooks } : {}),
|
|
616
617
|
// Under compat qualification only, run Claude in the SDK's isolation mode
|
|
617
618
|
// (load no filesystem settings). Left unset, the SDK loads ~/.claude and any
|
|
@@ -658,6 +659,7 @@ function buildClaudeQueryOptions(args, defaultModel, claudeExecutablePath = getC
|
|
|
658
659
|
model: args.model ?? defaultModel,
|
|
659
660
|
effort: defaultEffort,
|
|
660
661
|
tools: ['Read', 'Grep', 'Glob'],
|
|
662
|
+
strictMcpConfig: true,
|
|
661
663
|
resumeHandle: args.resumeHandle,
|
|
662
664
|
claudeExecutablePath,
|
|
663
665
|
outputSchema: args.schema,
|
|
@@ -686,6 +688,7 @@ function buildClaudeJsonBlockQueryOptions(args, defaultModel, claudeExecutablePa
|
|
|
686
688
|
model: args.model ?? defaultModel,
|
|
687
689
|
effort: defaultEffort,
|
|
688
690
|
tools: ['Read', 'Grep', 'Glob'],
|
|
691
|
+
strictMcpConfig: true,
|
|
689
692
|
resumeHandle: args.resumeHandle,
|
|
690
693
|
claudeExecutablePath,
|
|
691
694
|
events: args.events,
|
|
@@ -707,6 +710,7 @@ function buildClaudeJsonBlockRepairQueryOptions(args, defaultModel, claudeExecut
|
|
|
707
710
|
model: args.model ?? defaultModel,
|
|
708
711
|
effort: defaultEffort,
|
|
709
712
|
tools: [],
|
|
713
|
+
strictMcpConfig: true,
|
|
710
714
|
claudeExecutablePath,
|
|
711
715
|
events: args.events,
|
|
712
716
|
stderrRole: 'structured-advisor',
|
|
@@ -108,45 +108,63 @@ export async function writeReviewFindingsFinal(paths, finalMarkdown, acceptedRou
|
|
|
108
108
|
'',
|
|
109
109
|
].join('\n'));
|
|
110
110
|
}
|
|
111
|
-
// The
|
|
112
|
-
// persisted
|
|
113
|
-
//
|
|
114
|
-
//
|
|
115
|
-
// X". Returns [] when no handles were
|
|
111
|
+
// The draft and reviewer turns run through provider SDKs (Claude Agent SDK,
|
|
112
|
+
// Codex SDK). Those sessions are persisted by each CLI but hidden from its
|
|
113
|
+
// interactive resume picker, so they are only resumable by id. Surface the ids
|
|
114
|
+
// with the right command per provider so an operator can reopen the full
|
|
115
|
+
// context and ask "why did it conclude X". Returns [] when no handles were
|
|
116
|
+
// captured (e.g. non-SDK/test providers).
|
|
116
117
|
export function formatReviewResumeSection(cwd, round) {
|
|
117
|
-
const resumable =
|
|
118
|
+
const resumable = collectResumableSessions(round);
|
|
118
119
|
if (resumable.length === 0) {
|
|
119
120
|
return [];
|
|
120
121
|
}
|
|
121
122
|
return [
|
|
122
123
|
'## Resume Sessions',
|
|
123
124
|
'',
|
|
124
|
-
`These reviewer
|
|
125
|
+
`These draft/reviewer turns ran through provider SDKs. Their sessions do not appear in the interactive resume pickers, but you can resume one by id from the reviewed directory (${cwd}):`,
|
|
125
126
|
'',
|
|
126
|
-
...resumable.map((
|
|
127
|
+
...resumable.map((session) => `- ${formatResumeSessionLine(session)}`),
|
|
127
128
|
];
|
|
128
129
|
}
|
|
129
130
|
// Compact one-line resume hint for stdout, preferring the reviewer session
|
|
130
|
-
// (the one most useful for interrogating the verdict)
|
|
131
|
-
//
|
|
131
|
+
// (the one most useful for interrogating the verdict) and skipping any session
|
|
132
|
+
// whose provider has no known resume command. Null when nothing is resumable.
|
|
132
133
|
export function formatReviewResumeStdoutLine(cwd, round) {
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
const handle = reviewer ?? draft;
|
|
136
|
-
if (!handle) {
|
|
134
|
+
const session = collectResumableSessions(round).find((candidate) => resumeCommand(candidate) !== null);
|
|
135
|
+
if (!session) {
|
|
137
136
|
return null;
|
|
138
137
|
}
|
|
139
|
-
|
|
140
|
-
|
|
138
|
+
return `Resume the ${session.role} session: (cd ${cwd} && ${resumeCommand(session)})`;
|
|
139
|
+
}
|
|
140
|
+
// CLI resume commands by provider id. A provider missing here still gets its
|
|
141
|
+
// session id listed, just without a command. openai-compatible never surfaces
|
|
142
|
+
// handles (no session resume), so it never reaches this table.
|
|
143
|
+
const RESUME_COMMANDS = {
|
|
144
|
+
'anthropic-claude': (handle) => `claude --resume ${handle}`,
|
|
145
|
+
'openai-codex': (handle) => `codex resume ${handle}`,
|
|
146
|
+
};
|
|
147
|
+
function resumeCommand(session) {
|
|
148
|
+
const build = session.provider ? RESUME_COMMANDS[session.provider] : undefined;
|
|
149
|
+
return build ? build(session.handle) : null;
|
|
150
|
+
}
|
|
151
|
+
function formatResumeSessionLine(session) {
|
|
152
|
+
const command = resumeCommand(session);
|
|
153
|
+
const who = session.provider ? `${session.label} (${session.provider})` : session.label;
|
|
154
|
+
if (command) {
|
|
155
|
+
return `${who}: \`${command}\``;
|
|
156
|
+
}
|
|
157
|
+
return `${who}: session id \`${session.handle}\` (no known resume command for this provider)`;
|
|
141
158
|
}
|
|
142
|
-
function
|
|
143
|
-
const
|
|
144
|
-
['Reviewer', round.reviewSessionHandle],
|
|
145
|
-
['Draft (coder)', round.draftSessionHandle],
|
|
159
|
+
function collectResumableSessions(round) {
|
|
160
|
+
const candidates = [
|
|
161
|
+
['reviewer', 'Reviewer', round.reviewSessionHandle, round.reviewSessionProvider],
|
|
162
|
+
['draft', 'Draft (coder)', round.draftSessionHandle, round.draftSessionProvider],
|
|
146
163
|
];
|
|
147
|
-
return
|
|
148
|
-
|
|
149
|
-
|
|
164
|
+
return candidates.flatMap(([role, label, handle, provider]) => {
|
|
165
|
+
const nonEmpty = nonEmptyHandle(handle);
|
|
166
|
+
return nonEmpty ? [{ role, label, handle: nonEmpty, provider: provider?.trim() ? provider : null }] : [];
|
|
167
|
+
});
|
|
150
168
|
}
|
|
151
169
|
function nonEmptyHandle(handle) {
|
|
152
170
|
return typeof handle === 'string' && handle.trim() !== '' ? handle : null;
|
|
@@ -1,4 +1,15 @@
|
|
|
1
1
|
const DIFF_PREVIEW_LIMIT = 12000;
|
|
2
|
+
// The accepted findings artifact is read by a human who was not part of the
|
|
3
|
+
// review and may not know the codebase, so both the draft fields and the
|
|
4
|
+
// reviewer's finalMarkdown must be written in plain language. Precision is not
|
|
5
|
+
// negotiable: paths, identifiers, numbers, and SHAs stay exact; only the
|
|
6
|
+
// wording around them gets simpler.
|
|
7
|
+
export const REVIEW_FINDINGS_PLAIN_LANGUAGE_RULES = [
|
|
8
|
+
'Write for a human reader who was not part of this review and may not know the codebase: short sentences, one idea per sentence, everyday words.',
|
|
9
|
+
'Do not use jargon, shorthand, or undefined abbreviations when a plain phrase says the same thing. When a technical term is necessary, say in plain words what it means for the reader the first time you use it.',
|
|
10
|
+
'Keep exact file paths, identifiers, numbers, commit SHAs, and command names. Plain wording must not drop, blur, or soften a technical fact.',
|
|
11
|
+
'State each claim as what happens and why it matters. State each requiredAction as a concrete step a maintainer can take without re-reading the diff.',
|
|
12
|
+
];
|
|
2
13
|
export const REVIEW_FINDINGS_READ_ONLY_RULES = [
|
|
3
14
|
'Do not mutate the repository.',
|
|
4
15
|
'Do not make commits, amend commits, rebase, reset, squash, or rewrite history.',
|
|
@@ -65,6 +76,10 @@ export function buildReviewFindingsDraftPrompt(context, draftContext = {}) {
|
|
|
65
76
|
'## Read-Only Rules',
|
|
66
77
|
'',
|
|
67
78
|
...REVIEW_FINDINGS_READ_ONLY_RULES.map((rule) => `- ${rule}`),
|
|
79
|
+
'',
|
|
80
|
+
'## Plain Language',
|
|
81
|
+
'',
|
|
82
|
+
...REVIEW_FINDINGS_PLAIN_LANGUAGE_RULES.map((rule) => `- ${rule}`),
|
|
68
83
|
...(reviewFindings.length > 0
|
|
69
84
|
? [
|
|
70
85
|
'',
|
|
@@ -86,19 +101,23 @@ export function buildReviewFindingsDraftPrompt(context, draftContext = {}) {
|
|
|
86
101
|
'',
|
|
87
102
|
renderContextSummary(context),
|
|
88
103
|
'',
|
|
89
|
-
'Return a summary, concrete findings, and warnings only. Each finding needs severity, files, claim, evidence, and requiredAction. Do not suggest that Neal applied fixes.',
|
|
104
|
+
'Return a summary, concrete findings, and warnings only. Each finding needs severity, files, claim, evidence, and requiredAction. Write the summary, every claim, evidence, requiredAction, and warning under the Plain Language rules above. Do not suggest that Neal applied fixes.',
|
|
90
105
|
].join('\n');
|
|
91
106
|
}
|
|
92
107
|
export function buildReviewFindingsReviewPrompt(context, draft, round = 1) {
|
|
93
108
|
return [
|
|
94
109
|
'# Neal Review Findings Review',
|
|
95
110
|
'',
|
|
96
|
-
`Review findings draft round ${round} for missing important findings, weak evidence, false positives, wrong severity, unclear required actions, and insufficient test or integration analysis.`,
|
|
111
|
+
`Review findings draft round ${round} for missing important findings, weak evidence, false positives, wrong severity, unclear required actions, wording that breaks the Plain Language rules below, and insufficient test or integration analysis.`,
|
|
97
112
|
'',
|
|
98
113
|
'## Read-Only Rules',
|
|
99
114
|
'',
|
|
100
115
|
...REVIEW_FINDINGS_READ_ONLY_RULES.map((rule) => `- ${rule}`),
|
|
101
116
|
'',
|
|
117
|
+
'## Plain Language',
|
|
118
|
+
'',
|
|
119
|
+
...REVIEW_FINDINGS_PLAIN_LANGUAGE_RULES.map((rule) => `- ${rule}`),
|
|
120
|
+
'',
|
|
102
121
|
'## Review Instruction',
|
|
103
122
|
'',
|
|
104
123
|
context.instruction,
|
|
@@ -111,7 +130,7 @@ export function buildReviewFindingsReviewPrompt(context, draft, round = 1) {
|
|
|
111
130
|
'',
|
|
112
131
|
renderDraftSummary(draft),
|
|
113
132
|
'',
|
|
114
|
-
'Return verdict=`accepted` only when the final findings artifact is ready. Return verdict=`revise` with concrete findings when another draft is required. Return verdict=`blocked` only when a safe read-only review cannot be produced. Keep accepted finalMarkdown read-only
|
|
133
|
+
'Return verdict=`accepted` only when the final findings artifact is ready. Return verdict=`revise` with concrete findings when another draft is required. Return verdict=`blocked` only when a safe read-only review cannot be produced. Keep accepted finalMarkdown read-only, artifact-ready, and written under the Plain Language rules above. Return empty strings for finalMarkdown or blockedReason when they do not apply.',
|
|
115
134
|
].join('\n');
|
|
116
135
|
}
|
|
117
136
|
function renderContextSummary(context) {
|
|
@@ -41,7 +41,7 @@ class AgentReviewFindingsProviderAdapter {
|
|
|
41
41
|
}),
|
|
42
42
|
logger: this.args.logger,
|
|
43
43
|
});
|
|
44
|
-
args.onSessionHandle?.(result.sessionHandle ?? null);
|
|
44
|
+
args.onSessionHandle?.(result.sessionHandle ?? null, this.args.agentConfig.coder.provider);
|
|
45
45
|
return validateReviewFindingsDraft(result.structured);
|
|
46
46
|
}
|
|
47
47
|
async reviewDraft(args) {
|
|
@@ -94,7 +94,7 @@ class AgentReviewFindingsProviderAdapter {
|
|
|
94
94
|
events,
|
|
95
95
|
}),
|
|
96
96
|
});
|
|
97
|
-
args.onSessionHandle?.(result.sessionHandle ?? null);
|
|
97
|
+
args.onSessionHandle?.(result.sessionHandle ?? null, this.args.agentConfig.reviewer.provider);
|
|
98
98
|
return validateReviewFindingsReview(result.structured);
|
|
99
99
|
}
|
|
100
100
|
}
|
|
@@ -74,14 +74,16 @@ export async function runNealReviewCli(args) {
|
|
|
74
74
|
maxRounds,
|
|
75
75
|
});
|
|
76
76
|
let draftSessionHandle = null;
|
|
77
|
+
let draftSessionProvider = null;
|
|
77
78
|
const draftResponse = await provider.draftFindings({
|
|
78
79
|
context,
|
|
79
80
|
round: roundNumber,
|
|
80
81
|
previousDraft,
|
|
81
82
|
reviewFindings,
|
|
82
83
|
prompt: draftPrompt,
|
|
83
|
-
onSessionHandle: (handle) => {
|
|
84
|
+
onSessionHandle: (handle, sessionProvider) => {
|
|
84
85
|
draftSessionHandle = handle;
|
|
86
|
+
draftSessionProvider = sessionProvider ?? null;
|
|
85
87
|
},
|
|
86
88
|
});
|
|
87
89
|
await assertReviewReadOnlyStateUnchanged(cwd, beforeState);
|
|
@@ -97,13 +99,15 @@ export async function runNealReviewCli(args) {
|
|
|
97
99
|
});
|
|
98
100
|
const reviewPrompt = buildReviewFindingsReviewPrompt(context, draft, roundNumber);
|
|
99
101
|
let reviewSessionHandle = null;
|
|
102
|
+
let reviewSessionProvider = null;
|
|
100
103
|
const reviewResponse = await provider.reviewDraft({
|
|
101
104
|
context,
|
|
102
105
|
round: roundNumber,
|
|
103
106
|
draft,
|
|
104
107
|
prompt: reviewPrompt,
|
|
105
|
-
onSessionHandle: (handle) => {
|
|
108
|
+
onSessionHandle: (handle, sessionProvider) => {
|
|
106
109
|
reviewSessionHandle = handle;
|
|
110
|
+
reviewSessionProvider = sessionProvider ?? null;
|
|
107
111
|
},
|
|
108
112
|
});
|
|
109
113
|
await assertReviewReadOnlyStateUnchanged(cwd, beforeState);
|
|
@@ -118,7 +122,9 @@ export async function runNealReviewCli(args) {
|
|
|
118
122
|
// Only record handles when captured, so artifacts stay byte-identical
|
|
119
123
|
// for non-SDK providers.
|
|
120
124
|
...(draftSessionHandle ? { draftSessionHandle } : {}),
|
|
125
|
+
...(draftSessionHandle && draftSessionProvider ? { draftSessionProvider } : {}),
|
|
121
126
|
...(reviewSessionHandle ? { reviewSessionHandle } : {}),
|
|
127
|
+
...(reviewSessionHandle && reviewSessionProvider ? { reviewSessionProvider } : {}),
|
|
122
128
|
};
|
|
123
129
|
rounds.push(loopRound);
|
|
124
130
|
await writeReviewFindingsEvent(paths, 'review.review_completed', {
|
package/docs/prompt-specs.md
CHANGED
|
@@ -56,7 +56,7 @@ All schema targets are `structured_json` with provider surface
|
|
|
56
56
|
| `plan_author` | `buildPlanningPrompt`, `buildCoderPlanResponsePrompt` (`reviewMode=plan`, `reviewMode=derived-plan`) | `runCoderPlanRound`, `runCoderPlanResponseRound` | Primary planning: `buildCoderPlanSchema` / `validateCoderPlanPayload`. Response rounds: `buildCoderPlanResponseSchema` / `validateCoderPlanResponsePayload`. | Primary planning routes new/resumed structured sessions by persisted `plannerSessionProtocol`. Legacy marker parsing is retained only for active `legacy_marker_v1` sessions. |
|
|
57
57
|
| `plan_reviewer` | `buildPlanReviewerPrompt` (`mode=plan`, `mode=derived-plan`) | `runPlanReviewerRound` | `buildPlanReviewerSchema` / `PlanReviewerPayload` | Execution-shape confirmation is part of the contract. Reviews material approach, scope, sequencing, and verification defects without turning the plan into an implementation inventory. |
|
|
58
58
|
| `scope_coder` | `buildScopePrompt`, `buildCoderResponsePrompt` | `runCoderScopeRound`, `runCoderResponseRound` | Primary execution: `buildCoderScopeSchema` / `validateCoderScopePayload`. Response rounds: `buildCoderResponseSchema` / `validateCoderResponsePayload`. | Primary execution routes new/resumed structured sessions by persisted `coderSessionProtocol`. Legacy marker and progress-payload parsing is retained only for active `legacy_marker_v1` sessions. Also carries an `adjacent`-status blocked-recovery `response` variant (see below). |
|
|
59
|
-
| `scope_reviewer` | `buildReviewerPrompt` | `runReviewerRound` | `buildReviewerSchema` / `ReviewerPayload` | Execute-scope review only. `neal review` external ranges use the separate read-only review-findings loop. Meaningful-progress remains a capability variant of `scope_reviewer`, not a new top-level id. Context includes a run-local `scratchDir`, but read-only reviewer prompts omit it. |
|
|
59
|
+
| `scope_reviewer` | `buildReviewerPrompt` | `runReviewerRound` | `buildReviewerSchema` / `ReviewerPayload` | Execute-scope review only. `neal review` external ranges use the separate read-only review-findings loop. Meaningful-progress remains a capability variant of `scope_reviewer`, not a new top-level id. Context includes a run-local `scratchDir`, but read-only reviewer prompts omit it. Context also includes `earlierScopeChanges` when the current diff touches a file an earlier accepted scope changed (see below). |
|
|
60
60
|
| `completion_coder` | `buildFinalCompletionSummaryPrompt` | `runCoderFinalCompletionSummaryRound` | `buildFinalCompletionSummarySchema` / `parseFinalCompletionSummaryPayload` | Structured advisor round, but still a coder-owned role/task. The completion packet includes aggregate review context when neal can compute it. |
|
|
61
61
|
| `completion_reviewer` | `buildFinalCompletionReviewerPrompt` | `runReviewerFinalCompletionRound` | `buildFinalCompletionReviewerSchema` / `parseFinalCompletionReviewerPayload` | Whole-plan aggregate review remains distinct from ordinary scope review and keeps its final-completion verdict schema. Context includes a run-local `scratchDir`, but read-only reviewer prompts omit it. |
|
|
62
62
|
| `consultant` | `buildConsultantPrompt` | `runConsultantRound` | `buildConsultantSchema` / `validateConsultantVerdictPayload` | Single no-read-safe variant for the read-only consultant. It judges entirely from neal-inlined context and its static instructions pass the shared no-read guard. |
|
|
@@ -125,6 +125,26 @@ When adding or changing a prompt spec:
|
|
|
125
125
|
|
|
126
126
|
Final completion has one additional context assembly rule: `buildFinalCompletionPacket()` includes `aggregateReviewContext` for the whole implementation range from `initialBaseCommit` to the resolved final commit. When the range can be read, the packet carries commit subjects, diff stat, and changed files. When it cannot, it carries an explicit `unavailableReason` so the reviewer treats the missing aggregate range as evidence to consider instead of silently accepting completion.
|
|
127
127
|
|
|
128
|
+
Execute-scope review has one more context assembly rule. The reviewer session
|
|
129
|
+
persists across scopes while the coder session resets, so the reviewer is the
|
|
130
|
+
only participant that remembers earlier scopes, and its record of each one is
|
|
131
|
+
the coder's own summary in the continuity packet. To keep that memory honest,
|
|
132
|
+
`runExecuteReviewerAdjudication` computes `earlierScopeChanges`: for every file
|
|
133
|
+
in the current scope diff that an earlier accepted scope also changed (from
|
|
134
|
+
`completedScopes[].changedFiles` in run state; blocked scopes and scopes
|
|
135
|
+
replaced by a derived plan are skipped), it collects that earlier scope's diff
|
|
136
|
+
restricted to the file (`git diff <scope.baseCommit>..<scope.finalCommit> --
|
|
137
|
+
<file>`, a fixed-argv helper in `src/neal/git.ts`). `buildReviewerPrompt`
|
|
138
|
+
renders them under "Earlier-scope changes to files in this diff", bounded by
|
|
139
|
+
the same inline-section limit as the inlined range diff, and renders nothing
|
|
140
|
+
when there is no overlap. The prompt always carries the matching doctrine line:
|
|
141
|
+
a change to a file an earlier accepted scope signed off must preserve what that
|
|
142
|
+
review accepted, and weakening or removing a test, assertion, or check an
|
|
143
|
+
earlier scope introduced is a blocking finding unless the plan calls for it.
|
|
144
|
+
The continuity packet lists each completed scope's changed files (capped per
|
|
145
|
+
scope, with a count of what was cut) so the reviewer can also see which files
|
|
146
|
+
an earlier scope owned.
|
|
147
|
+
|
|
128
148
|
Execute-scope and final-completion reviewer context includes a deterministic
|
|
129
149
|
run-local `scratchDir` under `.neal/runs/<run-id>/scratch/`. A `tool-access`
|
|
130
150
|
prompt tells the reviewer to use that directory for temporary verification
|
package/docs/providers.md
CHANGED
|
@@ -104,8 +104,10 @@ Current built-in capabilities are intentionally conservative:
|
|
|
104
104
|
|
|
105
105
|
The `coder` capability describes adapter paths, not a global promise that every
|
|
106
106
|
writer-run workflow is read-only: writer-run coder turns use provider SDKs with
|
|
107
|
-
broad local permissions.
|
|
108
|
-
|
|
107
|
+
broad local permissions. That includes the public `neal review` command's
|
|
108
|
+
draft turn, which runs on the coder capability with full tools and is
|
|
109
|
+
read-only by prompt rules and an after-the-fact worktree check, not by
|
|
110
|
+
mechanism (see [Review boundary](#review-boundary)).
|
|
109
111
|
|
|
110
112
|
### Coder tool policy enforcement
|
|
111
113
|
|
|
@@ -151,6 +153,35 @@ Two consequences follow from no reviewer holding shell access:
|
|
|
151
153
|
- Reviewers never mutate the checkout. A review produces a verdict and findings,
|
|
152
154
|
not edits.
|
|
153
155
|
|
|
156
|
+
### What the invariant does and does not cover
|
|
157
|
+
|
|
158
|
+
The invariant covers the tools neal itself hands the reviewer. Beyond that
|
|
159
|
+
it is best effort. What is enforced mechanically today, per provider:
|
|
160
|
+
|
|
161
|
+
- `anthropic-claude`: the SDK `tools` allowlist is exactly `Read`, `Grep`,
|
|
162
|
+
`Glob` (empty for the prompt-only repair turn), and `strictMcpConfig` is set
|
|
163
|
+
so the SDK ignores MCP servers from the operator's user settings, plugins,
|
|
164
|
+
and project `.mcp.json`. Without that flag those servers load into the
|
|
165
|
+
reviewer session on top of the allowlist, and they often include tools that
|
|
166
|
+
write (Jira edits, Drive file writes, a browser).
|
|
167
|
+
- `openai-codex`: every structured-advisor thread runs under the Codex
|
|
168
|
+
`read-only` sandbox. That sandbox covers shell and filesystem access. neal
|
|
169
|
+
does not restrict MCP servers configured in the operator's own Codex config,
|
|
170
|
+
and whether the sandbox applies to them is up to Codex, not neal.
|
|
171
|
+
- `openai-compatible`: the reviewer only ever sees neal's own read-only
|
|
172
|
+
toolset. There is no MCP or other tool path into that loop.
|
|
173
|
+
|
|
174
|
+
What is not covered, on any provider:
|
|
175
|
+
|
|
176
|
+
- The reviewer runs in the same checkout as the coder. There is no separate
|
|
177
|
+
worktree. Read-only comes from which tools the reviewer has, not from a
|
|
178
|
+
separate copy of the tree.
|
|
179
|
+
- Prompt instructions ("do not mutate the repository") are instructions to
|
|
180
|
+
the model, not enforcement. A read-only claim that rests only on prompt text
|
|
181
|
+
is best effort.
|
|
182
|
+
- Anything the provider adds on its own (its own configured tools,
|
|
183
|
+
extensions, or network access) is outside the invariant.
|
|
184
|
+
|
|
154
185
|
Usage reporting is `opportunistic`: providers emit usage only when the SDK
|
|
155
186
|
event or result supplies it.
|
|
156
187
|
|
|
@@ -311,9 +342,13 @@ neal's shared `neal-json-block-v1` runtime appends provider-neutral transport
|
|
|
311
342
|
instructions requiring optional useful prose followed by exactly one final
|
|
312
343
|
fenced `neal-json` JSON block. It then extracts, parses, validates, and repairs
|
|
313
344
|
that control object with the caller-supplied schema label, schema, validator,
|
|
314
|
-
and repair limit. Raw whole-response JSON objects
|
|
315
|
-
|
|
316
|
-
|
|
345
|
+
and repair limit. Raw whole-response JSON objects, and a single fenced JSON
|
|
346
|
+
object (any fence label) that is the whole response, are accepted only as a
|
|
347
|
+
compatibility tolerance for older mocks, pre-migration paths, and repair turns
|
|
348
|
+
that render the payload as a ```json fence. The prompt contract remains prose
|
|
349
|
+
plus one final `neal-json` block. When a response carries more than one
|
|
350
|
+
`neal-json` block it is still rejected, but the first block's JSON is passed
|
|
351
|
+
to the repair prompt so repair works from the real payload. State-facing
|
|
317
352
|
`structured_output_received` telemetry is emitted only after neal validation
|
|
318
353
|
succeeds.
|
|
319
354
|
|
|
@@ -782,17 +817,22 @@ Two distinct read-only guarantees apply here, and they should not be conflated:
|
|
|
782
817
|
- The provider-capability invariant (see
|
|
783
818
|
[The read-only reviewer invariant](#the-read-only-reviewer-invariant)):
|
|
784
819
|
every supported `structured-advisor` capability has `write: false` and
|
|
785
|
-
`shell: false`, so
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
820
|
+
`shell: false`, so the adjudication round cannot write or run shell by
|
|
821
|
+
capability. This covers the reviewer's adjudication round only. The draft
|
|
822
|
+
round runs on the coder capability with the coder's full tools (shell,
|
|
823
|
+
file writes, `gh`, the operator's MCP servers) because drafting needs to
|
|
824
|
+
read things like pull requests and Jira issues. For that round, read-only is
|
|
825
|
+
a prompt instruction, not a mechanism.
|
|
826
|
+
- The `neal review` command's artifact-boundary guard: the command snapshots
|
|
827
|
+
protected writer state (`.neal/current.json`, queue pointers, run-local
|
|
828
|
+
`RUN_STATE.json` files) and `git status` before the loop, compares them after
|
|
829
|
+
each round, and fails if anything outside its review artifacts changed. This
|
|
830
|
+
is detection after the fact. It does not prevent a change and does not
|
|
831
|
+
revert one. It is owned by the `neal review` command flow, not by a provider
|
|
832
|
+
capability, and a writer run does not perform it.
|
|
833
|
+
|
|
834
|
+
So the capability invariant guarantees the adjudication round never writes or
|
|
835
|
+
runs shell, the draft round is read-only as best effort, and `neal review`
|
|
836
|
+
detects (but cannot undo) a violation. Do not treat the
|
|
837
|
+
artifact-boundary guard as a provider capability, and do not assume a writer
|
|
838
|
+
run performs that post-round check.
|
package/docs/storage.md
CHANGED
|
@@ -86,7 +86,7 @@ neal operations and diagnostics.
|
|
|
86
86
|
| `.neal/runs/<run-id>/RUN_NARRATIVE.json` | Internal state | Narrative source data that neal may read to update the human narrative. It is not a public trace artifact. |
|
|
87
87
|
| `.neal/runs/<run-id>/REVIEW.md` | User-facing human artifact | Scope or plan review history and findings. |
|
|
88
88
|
| `.neal/runs/<run-id>/REVIEW-<commit>.md` | User-facing human artifact | Archived review history for an accepted scope commit. |
|
|
89
|
-
| `.neal/runs/<run-id>/REVIEWER_CONTEXT.md` | Support/debug artifact | Bounded reviewer-continuity packet rendered for inspection. |
|
|
89
|
+
| `.neal/runs/<run-id>/REVIEWER_CONTEXT.md` | Support/debug artifact | Bounded reviewer-continuity packet rendered for inspection: completed scopes with their changed files, findings, inherited plan-review debt, and citations. |
|
|
90
90
|
| `.neal/runs/<run-id>/REVIEWER_CONTEXT.json` | Support/debug artifact | Machine-readable reviewer-continuity packet without the rendered prompt markdown. |
|
|
91
91
|
| `.neal/runs/<run-id>/RECOVERY.md` | User-facing human artifact | Interactive blocked-recovery transcript/history for a run. |
|
|
92
92
|
| `.neal/runs/<run-id>/FINAL_COMPLETION_REVIEW.md` | User-facing human artifact | Whole-plan final completion review. |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@navels/neal",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "A source-first multi-agent CLI for planning, executing, reviewing, and resuming scoped code changes.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"publishConfig": {
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
},
|
|
9
9
|
"author": "Lee Nave",
|
|
10
10
|
"type": "module",
|
|
11
|
-
"packageManager": "pnpm@11.
|
|
11
|
+
"packageManager": "pnpm@11.21.0",
|
|
12
12
|
"homepage": "https://github.com/navels/neal#readme",
|
|
13
13
|
"repository": {
|
|
14
14
|
"type": "git",
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
],
|
|
42
42
|
"engines": {
|
|
43
43
|
"node": ">=24.19.0",
|
|
44
|
-
"pnpm": ">=11.
|
|
44
|
+
"pnpm": ">=11.21.0"
|
|
45
45
|
},
|
|
46
46
|
"scripts": {
|
|
47
47
|
"build": "rm -rf dist && node node_modules/typescript-7/bin/tsc -p tsconfig.json && chmod +x dist/neal/index.js",
|
|
@@ -55,10 +55,10 @@
|
|
|
55
55
|
"typecheck": "node node_modules/typescript-7/bin/tsc --noEmit -p tsconfig.json && node node_modules/typescript-7/bin/tsc -p tsconfig.test.json"
|
|
56
56
|
},
|
|
57
57
|
"dependencies": {
|
|
58
|
-
"@ai-sdk/openai-compatible": "3.0.
|
|
59
|
-
"@anthropic-ai/claude-agent-sdk": "0.3.
|
|
58
|
+
"@ai-sdk/openai-compatible": "3.0.30",
|
|
59
|
+
"@anthropic-ai/claude-agent-sdk": "0.3.234",
|
|
60
60
|
"@openai/codex-sdk": "0.147.0",
|
|
61
|
-
"ai": "7.0.
|
|
61
|
+
"ai": "7.0.65",
|
|
62
62
|
"dotenv": "^17.4.2",
|
|
63
63
|
"yaml": "^2.9.0",
|
|
64
64
|
"zod": "4.4.3"
|
|
@@ -67,9 +67,9 @@
|
|
|
67
67
|
"@eslint/js": "^10.0.1",
|
|
68
68
|
"@types/node": "^24.13.3",
|
|
69
69
|
"eslint": "^10.8.1",
|
|
70
|
-
"tsx": "^4.23.
|
|
70
|
+
"tsx": "^4.23.12",
|
|
71
71
|
"typescript": "^6.0.3",
|
|
72
72
|
"typescript-7": "npm:typescript@^7.0.2",
|
|
73
|
-
"typescript-eslint": "^8.
|
|
73
|
+
"typescript-eslint": "^8.67.0"
|
|
74
74
|
}
|
|
75
75
|
}
|