@navels/neal 0.5.0 → 0.6.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 +32 -6
- package/dist/neal/agents/prompts.js +10 -5
- package/dist/neal/commands/check.js +6 -0
- package/dist/neal/config.js +1 -1
- package/dist/neal/context/inline-review-context.js +116 -0
- package/dist/neal/final-completion.js +4 -7
- package/dist/neal/orchestrator/phases/planning.js +53 -8
- package/dist/neal/prompts/execute.js +24 -7
- package/dist/neal/prompts/guidance.js +8 -1
- package/dist/neal/prompts/planning.js +9 -3
- package/dist/neal/prompts/specialized.js +83 -20
- package/dist/neal/prompts/specs.js +15 -3
- package/dist/neal/providers/input-budget.js +80 -0
- package/dist/neal/providers/openai-codex.js +69 -1
- package/dist/neal/scopes.js +4 -4
- package/dist/neal/status.js +52 -1
- package/dist/neal/verification-events.js +49 -0
- package/docs/prompt-specs.md +78 -0
- package/docs/providers.md +19 -0
- package/neal.yml +1 -1
- package/package.json +2 -2
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { mkdir } from 'node:fs/promises';
|
|
2
2
|
import { runCoderResponseRound, runReviewerRound } from '../agents.js';
|
|
3
|
-
import { readOnlyReviewerNeedsInlinedDiff } from '../context/inline-review-context.js';
|
|
3
|
+
import { OPEN_FINDINGS_PROMPT_ITEM_LIMIT, readOnlyReviewerNeedsInlinedDiff, } from '../context/inline-review-context.js';
|
|
4
4
|
import { buildAndPersistReviewerContextPacket } from '../context/reviewer-context.js';
|
|
5
5
|
import { getReviewStuckWindow } from '../config.js';
|
|
6
6
|
import { getDiffForRangePaths } from '../git.js';
|
|
@@ -229,9 +229,19 @@ export function buildVerificationHint(state) {
|
|
|
229
229
|
'- Rerun full test suites only if your new changes materially invalidate that reviewed baseline or the plan explicitly requires new end-of-scope full-suite verification.',
|
|
230
230
|
].join('\n');
|
|
231
231
|
}
|
|
232
|
+
// The bounded finding set for one coder response round. Both the response
|
|
233
|
+
// prompt and validateExecuteResponseCoverage consume this same selection, so
|
|
234
|
+
// the coder is asked to disposition exactly the findings it can see. At most
|
|
235
|
+
// OPEN_FINDINGS_PROMPT_ITEM_LIMIT findings are presented per round; findings
|
|
236
|
+
// beyond the limit stay open and synthesizeExecuteResponseState keeps the
|
|
237
|
+
// response phase active, presenting the next batch immediately, until every
|
|
238
|
+
// open finding of the round's kind has been dispositioned.
|
|
232
239
|
export function getExecuteResponseOpenFindings(state, mode = 'blocking') {
|
|
233
240
|
const selector = mode === 'optional' ? isOpenNonBlockingFinding : isOpenBlockingFinding;
|
|
234
|
-
return state.findings
|
|
241
|
+
return state.findings
|
|
242
|
+
.filter(selector)
|
|
243
|
+
.slice(0, OPEN_FINDINGS_PROMPT_ITEM_LIMIT)
|
|
244
|
+
.map((finding) => ({
|
|
235
245
|
id: finding.id,
|
|
236
246
|
source: finding.source,
|
|
237
247
|
claim: finding.claim,
|
|
@@ -266,7 +276,7 @@ function validateExecuteResponseCoverage(args) {
|
|
|
266
276
|
throw new Error(`Coder ${args.mode} response returned duplicate finding dispositions: ${[...duplicateIds].join(', ')}`);
|
|
267
277
|
}
|
|
268
278
|
if (unknownIds.size > 0) {
|
|
269
|
-
throw new Error(`Coder ${args.mode} response returned dispositions for
|
|
279
|
+
throw new Error(`Coder ${args.mode} response returned dispositions for findings outside the presented open set: ${[...unknownIds].join(', ')}`);
|
|
270
280
|
}
|
|
271
281
|
if (args.response.payload.outcome !== 'responded') {
|
|
272
282
|
return;
|
|
@@ -526,7 +536,12 @@ export function synthesizeExecuteReviewerState(args) {
|
|
|
526
536
|
];
|
|
527
537
|
mergedFindings = [...args.state.findings, ...findings];
|
|
528
538
|
}
|
|
529
|
-
|
|
539
|
+
// Deliberately derived from the merged set as well as the current reviewer
|
|
540
|
+
// payload: a prior round's still-open blocker (for example one beyond the
|
|
541
|
+
// batched response round's presentation limit) must keep forcing a revision
|
|
542
|
+
// even when the current reviewer round is empty, so an empty round can never
|
|
543
|
+
// accept over an open blocking finding.
|
|
544
|
+
const hasBlockingFindings = findings.some((finding) => finding.severity === 'blocking') || mergedFindings.some(isOpenBlockingFinding);
|
|
530
545
|
const hasOpenNonBlockingFindings = mergedFindings.some(isOpenNonBlockingFinding);
|
|
531
546
|
const openBlockingCanonicalSet = getOpenBlockingCanonicalSet(mergedFindings);
|
|
532
547
|
const openBlockingCanonicalIds = [...openBlockingCanonicalSet].sort();
|
|
@@ -633,11 +648,22 @@ export function synthesizeExecuteResponseState(args) {
|
|
|
633
648
|
};
|
|
634
649
|
});
|
|
635
650
|
const outcome = args.response.payload.outcome;
|
|
651
|
+
// Response rounds batch: when open findings of this round's kind remain
|
|
652
|
+
// after the dispositions are applied (they were beyond the per-round
|
|
653
|
+
// presentation limit), stay in the same response phase so the next batch is
|
|
654
|
+
// presented immediately instead of spending a reviewer round per batch.
|
|
655
|
+
// Coverage validation guarantees the presented batch was fully
|
|
656
|
+
// dispositioned, so the backlog strictly shrinks and the loop terminates.
|
|
657
|
+
const hasNextResponseBatch = findings.some(mode === 'optional' ? isOpenNonBlockingFinding : isOpenBlockingFinding);
|
|
636
658
|
const nextPhase = outcome === 'blocked' || outcome === 'split_plan'
|
|
637
659
|
? 'blocked'
|
|
638
660
|
: mode === 'optional'
|
|
639
|
-
?
|
|
640
|
-
|
|
661
|
+
? hasNextResponseBatch
|
|
662
|
+
? 'coder_optional_response'
|
|
663
|
+
: EXECUTE_FINALIZATION_PHASE
|
|
664
|
+
: hasNextResponseBatch
|
|
665
|
+
? 'coder_response'
|
|
666
|
+
: 'reviewer_scope';
|
|
641
667
|
return {
|
|
642
668
|
findings,
|
|
643
669
|
nextPhase,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { assertNoReadPromptInstructionText, renderInlineReviewerContext, } from '../context/inline-review-context.js';
|
|
1
|
+
import { assertNoReadPromptInstructionText, boundFreeTextValues, renderInlineReviewerContext, truncateInlineSectionBody, } from '../context/inline-review-context.js';
|
|
2
2
|
import { AUTONOMY_BLOCKED as SHARED_AUTONOMY_BLOCKED, AUTONOMY_CHUNK_DONE as SHARED_AUTONOMY_CHUNK_DONE, AUTONOMY_DONE as SHARED_AUTONOMY_DONE, AUTONOMY_SCOPE_DONE as SHARED_AUTONOMY_SCOPE_DONE, AUTONOMY_SPLIT_PLAN as SHARED_AUTONOMY_SPLIT_PLAN, buildProgressSection, getCanonicalPlanContractLines, getProtocolMarkerArtifactProhibitionLines, getStandalonePlanPayloadSourceOfTruthLines, } from '../prompts/shared.js';
|
|
3
|
-
import { getUserGuidanceLines } from '../prompts/guidance.js';
|
|
3
|
+
import { getUserGuidanceLines, USER_GUIDANCE_MAX_CHARS } from '../prompts/guidance.js';
|
|
4
4
|
import { assertPromptBuilder, resolvePrimaryVariant } from '../prompts/assert-builder.js';
|
|
5
5
|
export { buildCoderResponsePrompt, buildLegacyScopePrompt, buildReviewerPrompt, buildScopePrompt } from '../prompts/execute.js';
|
|
6
6
|
export { buildCoderPlanResponsePrompt, buildLegacyPlanningPrompt, buildPlanReviewerPrompt, buildPlanningPrompt, } from '../prompts/planning.js';
|
|
@@ -41,7 +41,9 @@ export function buildConsultantPrompt(args) {
|
|
|
41
41
|
...staticInstructionLines,
|
|
42
42
|
'',
|
|
43
43
|
'Blocked reason:',
|
|
44
|
-
|
|
44
|
+
// Agent-authored free text shares the fixed agent free-text cap at render
|
|
45
|
+
// time; the persisted blocked reason keeps its full text.
|
|
46
|
+
boundFreeTextValues([args.blockedReason])[0],
|
|
45
47
|
'',
|
|
46
48
|
renderInlineReviewerContext(args.inlineContext),
|
|
47
49
|
].join('\n');
|
|
@@ -102,9 +104,12 @@ export function buildBlockedRecoveryCoderPrompt(args) {
|
|
|
102
104
|
'Do not treat operator guidance as authorization to skip verification, waive policy, or reinterpret the target beyond the current scope.',
|
|
103
105
|
'',
|
|
104
106
|
'Blocked recovery context:',
|
|
105
|
-
|
|
107
|
+
// The blocked reason is agent-authored free text and the guidance is
|
|
108
|
+
// operator-authored; each gets its class's render-time cap while the
|
|
109
|
+
// persisted values keep their full text.
|
|
110
|
+
`- Blocked reason: ${boundFreeTextValues([args.blockedReason])[0]}`,
|
|
106
111
|
`- Recovery turns used: ${args.turnsTaken} of ${args.maxTurns}`,
|
|
107
|
-
`- Latest operator guidance: ${args.operatorGuidance}`,
|
|
112
|
+
`- Latest operator guidance: ${truncateInlineSectionBody(args.operatorGuidance, USER_GUIDANCE_MAX_CHARS)}`,
|
|
108
113
|
'',
|
|
109
114
|
'Current progress state:',
|
|
110
115
|
buildProgressSection(args.progressText),
|
|
@@ -4,6 +4,7 @@ import { verifyNotification } from '../../notifier.js';
|
|
|
4
4
|
import { parseCheckArgs } from '../cli.js';
|
|
5
5
|
import { assertWriterProvidersConfigured, getAgentTurnRetryLimit, getAgentTurnStartupTimeoutMs, getApiRetryLimit, getFinalCompletionContinueExecutionMax, getInactivityTimeoutMs, getInteractiveBlockedRecoveryMaxTurns, getMaxReviewRounds, getNotifyBin, getPhaseHeartbeatMs, getReviewStuckWindow, } from '../config.js';
|
|
6
6
|
import { getNealDirGitIgnoreStatus } from '../git.js';
|
|
7
|
+
import { collectGuidanceDiagnostics, USER_GUIDANCE_MAX_CHARS } from '../prompts/guidance.js';
|
|
7
8
|
import { runWithAgentTurnLiveness } from '../providers/liveness.js';
|
|
8
9
|
import { getCoderAdapter, getProviderDefinition, getStructuredAdvisorAdapter, } from '../providers/registry.js';
|
|
9
10
|
import { sanitizeSensitiveText } from '../sensitive-text.js';
|
|
@@ -343,6 +344,11 @@ export async function runNealCheckCli(options = {}) {
|
|
|
343
344
|
writeLine(stdout, ` ${describeRole('coder', agentConfig.coder)}`);
|
|
344
345
|
writeLine(stdout, ` ${describeRole('reviewer', agentConfig.reviewer)}`);
|
|
345
346
|
writeLine(stdout, ` ${describeNotificationScript(notifyBin)}`);
|
|
347
|
+
for (const entry of collectGuidanceDiagnostics()) {
|
|
348
|
+
if (entry.chars > USER_GUIDANCE_MAX_CHARS) {
|
|
349
|
+
writeLine(stdout, ` warning: ${entry.role} guidance at ${entry.path} is ${entry.chars} characters; prompts inline the first ${USER_GUIDANCE_MAX_CHARS} and truncate the rest. Trim the file.`);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
346
352
|
writeLine(stdout, '');
|
|
347
353
|
// Native adapters drive their providers directly; any other writer provider is
|
|
348
354
|
// an openai-compatible model that should be qualified end-to-end with `neal compat`.
|
package/dist/neal/config.js
CHANGED
|
@@ -19,7 +19,7 @@ const DEFAULT_CONFIG = {
|
|
|
19
19
|
agent_turn_startup_timeout_ms: 300_000,
|
|
20
20
|
agent_turn_retry_limit: 1,
|
|
21
21
|
interactive_blocked_recovery_max_turns: 3,
|
|
22
|
-
final_completion_continue_execution_max:
|
|
22
|
+
final_completion_continue_execution_max: 3,
|
|
23
23
|
consultant_max_attempts: 1,
|
|
24
24
|
notify_bin: null,
|
|
25
25
|
},
|
|
@@ -4,6 +4,122 @@ import { getProviderDefinition, isRegisteredProviderId } from '../providers/regi
|
|
|
4
4
|
// truncation posture of truncateForPrompt in src/neal/agents/structured-json.ts:
|
|
5
5
|
// truncate with an explicit marker instead of silently dropping content.
|
|
6
6
|
export const INLINE_SECTION_MAX_CHARS = 200_000;
|
|
7
|
+
// Fixed aggregate character budget for the free-text values of one
|
|
8
|
+
// agent-authored payload embedded in another agent's prompt (review-finding
|
|
9
|
+
// text, progress justifications, completion summaries). The budget covers the
|
|
10
|
+
// total rendered free text including truncation markers, so the free-text
|
|
11
|
+
// contribution of a section never exceeds this constant. Control data (ids,
|
|
12
|
+
// severities, statuses, file paths) never rides this budget: callers render it
|
|
13
|
+
// separately and exactly via boundOpenFindingsForPrompt-style wrappers.
|
|
14
|
+
export const AGENT_FREE_TEXT_SECTION_MAX_CHARS = 20_000;
|
|
15
|
+
// Upper bound on one rendered truncation marker:
|
|
16
|
+
// '\n[truncated N character(s)]' with N at most 16 digits.
|
|
17
|
+
const TRUNCATION_MARKER_MAX_CHARS = 42;
|
|
18
|
+
// Maximum open findings presented to the coder per response round. Response
|
|
19
|
+
// selection (getExecuteResponseOpenFindings and the plan-review response
|
|
20
|
+
// phase) and prompt rendering share this bound, so the coder always sees
|
|
21
|
+
// exactly the finding set it must disposition; findings beyond the limit stay
|
|
22
|
+
// open and are presented in later response rounds until every one is handled.
|
|
23
|
+
// The limit also keeps boundFreeTextValues' cardinality precondition
|
|
24
|
+
// satisfied: 3 free-text values per finding times this limit stays far under
|
|
25
|
+
// AGENT_FREE_TEXT_SECTION_MAX_CHARS / TRUNCATION_MARKER_MAX_CHARS.
|
|
26
|
+
export const OPEN_FINDINGS_PROMPT_ITEM_LIMIT = 50;
|
|
27
|
+
function renderBoundedFreeTextValue(text, cap) {
|
|
28
|
+
if (text.length <= cap) {
|
|
29
|
+
return text;
|
|
30
|
+
}
|
|
31
|
+
if (cap <= 0) {
|
|
32
|
+
return `[truncated ${text.length} character(s)]`;
|
|
33
|
+
}
|
|
34
|
+
return `${text.slice(0, cap)}\n[truncated ${text.length - cap} character(s)]`;
|
|
35
|
+
}
|
|
36
|
+
// Largest equal per-value kept-character cap such that the values fit the
|
|
37
|
+
// kept-character budget (water-filling): short values keep their full text and
|
|
38
|
+
// long values share the remaining budget equally. Returns MAX_SAFE_INTEGER
|
|
39
|
+
// when everything already fits.
|
|
40
|
+
function computeFairValueCap(lengths, budget) {
|
|
41
|
+
const sorted = [...lengths].sort((left, right) => left - right);
|
|
42
|
+
let remaining = budget;
|
|
43
|
+
for (let index = 0; index < sorted.length; index += 1) {
|
|
44
|
+
const share = Math.floor(remaining / (sorted.length - index));
|
|
45
|
+
if (sorted[index] > share) {
|
|
46
|
+
return Math.max(share, 0);
|
|
47
|
+
}
|
|
48
|
+
remaining -= sorted[index];
|
|
49
|
+
}
|
|
50
|
+
return Number.MAX_SAFE_INTEGER;
|
|
51
|
+
}
|
|
52
|
+
// Bounds a fixed list of agent-authored free-text values to a fixed aggregate
|
|
53
|
+
// budget, truncation markers included: marker allowance is reserved up front,
|
|
54
|
+
// the kept characters are water-filled over the rest, so the total rendered
|
|
55
|
+
// length is always <= budget. Positions are preserved one-to-one with the
|
|
56
|
+
// input. Callers keep control data (ids, files, severities) out of this list
|
|
57
|
+
// and enforce the finite cardinality this validates. Never mutates the input.
|
|
58
|
+
export function boundFreeTextValues(texts, budget = AGENT_FREE_TEXT_SECTION_MAX_CHARS) {
|
|
59
|
+
if (texts.length * TRUNCATION_MARKER_MAX_CHARS > budget) {
|
|
60
|
+
throw new Error(`boundFreeTextValues received ${texts.length} free-text values; the aggregate budget of ${budget} characters supports at most ${Math.floor(budget / TRUNCATION_MARKER_MAX_CHARS)}. Bound the payload's item cardinality before rendering free text.`);
|
|
61
|
+
}
|
|
62
|
+
const keptBudget = budget - texts.length * TRUNCATION_MARKER_MAX_CHARS;
|
|
63
|
+
const cap = computeFairValueCap(texts.map((text) => text.length), keptBudget);
|
|
64
|
+
return texts.map((text) => renderBoundedFreeTextValue(text, cap));
|
|
65
|
+
}
|
|
66
|
+
// Render-only view of an open-findings list for prompt embedding. Every
|
|
67
|
+
// finding renders: id, source, and severity are copied exactly, each files
|
|
68
|
+
// path renders whole with the list length bounded via boundChangedFileList,
|
|
69
|
+
// and only the claim, requiredAction, and roundSummary free text shares the
|
|
70
|
+
// fixed aggregate budget. Callers must present at most
|
|
71
|
+
// OPEN_FINDINGS_PROMPT_ITEM_LIMIT findings — the same bounded set their
|
|
72
|
+
// response processing validates against — so this never drops a finding the
|
|
73
|
+
// coder is required to disposition; it throws on a larger list instead of
|
|
74
|
+
// silently diverging from the response contract. Never mutates the input.
|
|
75
|
+
export function boundOpenFindingsForPrompt(findings) {
|
|
76
|
+
if (findings.length > OPEN_FINDINGS_PROMPT_ITEM_LIMIT) {
|
|
77
|
+
throw new Error(`boundOpenFindingsForPrompt received ${findings.length} findings; response rounds present at most ${OPEN_FINDINGS_PROMPT_ITEM_LIMIT}. Bound the selection where the response set is chosen.`);
|
|
78
|
+
}
|
|
79
|
+
const boundedTexts = boundFreeTextValues(findings.flatMap((finding) => [finding.claim, finding.requiredAction, finding.roundSummary]));
|
|
80
|
+
return findings.map((finding, index) => ({
|
|
81
|
+
...finding,
|
|
82
|
+
claim: boundedTexts[index * 3],
|
|
83
|
+
requiredAction: boundedTexts[index * 3 + 1],
|
|
84
|
+
roundSummary: boundedTexts[index * 3 + 2],
|
|
85
|
+
files: boundChangedFileList(finding.files),
|
|
86
|
+
}));
|
|
87
|
+
}
|
|
88
|
+
// Maximum commit subjects rendered per commit-subject list that reaches a
|
|
89
|
+
// prompt (the aggregate completion range and the scope review's commits-in-scope
|
|
90
|
+
// list). Matches CHANGED_FILE_LIST_LIMIT so both run-scaling list bounds read
|
|
91
|
+
// the same way.
|
|
92
|
+
export const COMMIT_SUBJECT_LIST_LIMIT = 20;
|
|
93
|
+
// Character cap for one rendered git summary block that grows with run length
|
|
94
|
+
// (diff-stat output, one line per file). Applied via truncateInlineSectionBody,
|
|
95
|
+
// so an over-cap block carries an explicit truncation marker.
|
|
96
|
+
export const GIT_SUMMARY_SECTION_MAX_CHARS = 20_000;
|
|
97
|
+
// Bounds a commit-subject list for prompt rendering: the first
|
|
98
|
+
// COMMIT_SUBJECT_LIST_LIMIT subjects render with their text sharing the fixed
|
|
99
|
+
// aggregate free-text budget (subjects are largely agent-authored), and the
|
|
100
|
+
// rest collapse to an explicit "(+N more)" entry. The underlying arrays keep
|
|
101
|
+
// every subject for non-prompt consumers. Never mutates the input.
|
|
102
|
+
export function boundCommitSubjectList(subjects) {
|
|
103
|
+
const kept = boundFreeTextValues(subjects.slice(0, COMMIT_SUBJECT_LIST_LIMIT));
|
|
104
|
+
if (subjects.length <= COMMIT_SUBJECT_LIST_LIMIT) {
|
|
105
|
+
return kept;
|
|
106
|
+
}
|
|
107
|
+
return [...kept, `(+${subjects.length - COMMIT_SUBJECT_LIST_LIMIT} more)`];
|
|
108
|
+
}
|
|
109
|
+
// Maximum file paths rendered per changed-file list that reaches a prompt.
|
|
110
|
+
// Matches the reviewer continuity packet's per-scope bound
|
|
111
|
+
// (COMPLETED_SCOPE_CHANGED_FILE_LIMIT in src/neal/context/reviewer-context.ts).
|
|
112
|
+
export const CHANGED_FILE_LIST_LIMIT = 20;
|
|
113
|
+
// Bounds a changed-file list for prompt rendering: the first
|
|
114
|
+
// CHANGED_FILE_LIST_LIMIT entries render and the rest collapse to an explicit
|
|
115
|
+
// "(+N more)" marker entry. Callers join or JSON-embed the result; the
|
|
116
|
+
// underlying arrays in state and packets keep every path.
|
|
117
|
+
export function boundChangedFileList(files, limit = CHANGED_FILE_LIST_LIMIT) {
|
|
118
|
+
if (files.length <= limit) {
|
|
119
|
+
return [...files];
|
|
120
|
+
}
|
|
121
|
+
return [...files.slice(0, limit), `(+${files.length - limit} more)`];
|
|
122
|
+
}
|
|
7
123
|
// Canonical forbidden-phrase list for no-read prompts (today: the blocked-run
|
|
8
124
|
// consultant, which always judges from Neal-inlined in-memory context). A
|
|
9
125
|
// no-read prompt must contain no instruction that requires repository, file,
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { getFinalCompletionContinueExecutionMax } from './config.js';
|
|
4
|
+
import { boundChangedFileList } from './context/inline-review-context.js';
|
|
4
5
|
import { getChangedFilesForRange, getCommitRange, getCommitSubjects, getDiffStatForRange, } from './git.js';
|
|
5
6
|
import { toResidualReviewDebt } from './review-debt.js';
|
|
6
7
|
import { buildScopeAccountingSummary, getCurrentScopeLabel } from './scopes.js';
|
|
7
8
|
import { getDerivedPlanView, getFinalCompletionView } from './state-views.js';
|
|
8
|
-
import {
|
|
9
|
+
import { buildVerificationTally, extractVerificationCommandResults, } from './verification-events.js';
|
|
9
10
|
function getEventsPath(runDir) {
|
|
10
11
|
return join(runDir, 'events.ndjson');
|
|
11
12
|
}
|
|
@@ -27,7 +28,7 @@ function renderCompletedScopeSummary(scopes) {
|
|
|
27
28
|
return scopes
|
|
28
29
|
.map((scope) => {
|
|
29
30
|
const changedFiles = scope.changedFiles.length > 0
|
|
30
|
-
? `${scope.changedFiles.length} file(s): ${scope.changedFiles.join(', ')}`
|
|
31
|
+
? `${scope.changedFiles.length} file(s): ${boundChangedFileList(scope.changedFiles).join(', ')}`
|
|
31
32
|
: 'no changed files';
|
|
32
33
|
const commit = scope.finalCommit ?? 'pending';
|
|
33
34
|
const parent = scope.derivedFromParentScope ? ` | parent ${scope.derivedFromParentScope}` : '';
|
|
@@ -191,8 +192,6 @@ export async function buildFinalCompletionPacket(args) {
|
|
|
191
192
|
const finalCommit = terminalScope?.finalCommit ?? args.state.finalCommit;
|
|
192
193
|
const effectiveScopes = mergeCompletedScopesWithTerminalScope(args.state, terminalScope);
|
|
193
194
|
const allVerificationCommandResults = await loadVerificationCommandResults(args.state.runDir);
|
|
194
|
-
const verificationCommandResults = latestCommandResultPerCommand(allVerificationCommandResults);
|
|
195
|
-
const verificationCommands = verificationCommandResults.map((result) => result.command);
|
|
196
195
|
const scopeAccounting = buildScopeAccountingSummary(effectiveScopes);
|
|
197
196
|
const terminalChangedFiles = [...(terminalScope?.changedFiles ?? [])];
|
|
198
197
|
const planChangedFiles = uniqueFiles(effectiveScopes
|
|
@@ -219,9 +218,7 @@ export async function buildFinalCompletionPacket(args) {
|
|
|
219
218
|
planChangedFilesSummary: summarizeChangedFiles(planChangedFiles),
|
|
220
219
|
residualReviewDebt,
|
|
221
220
|
residualReviewDebtSummary: summarizeResidualReviewDebt(effectiveScopes),
|
|
222
|
-
|
|
223
|
-
verificationCommandResults,
|
|
224
|
-
verificationSummary: summarizeVerificationCommandResults(verificationCommandResults),
|
|
221
|
+
verificationTally: buildVerificationTally(allVerificationCommandResults),
|
|
225
222
|
lastNonEmptyImplementationScope: findLastNonEmptyImplementationScope(effectiveScopes, terminalScope, args.state),
|
|
226
223
|
continueExecutionCount: finalCompletion?.continueExecutionCount ?? 0,
|
|
227
224
|
continueExecutionMax: Math.max(0, getFinalCompletionContinueExecutionMax(args.state.cwd)),
|
|
@@ -5,6 +5,7 @@ import { isOpenBlockingFinding, isOpenNonBlockingFinding, mapDecisionToStatus, }
|
|
|
5
5
|
import { getDerivedPlanBlockedReason, isDerivedPlanReviewState, plannerProviderStartsFreshSessions, resolvePlanningAdjudicationContext, runPlanningResponseAdjudication, runPlanningReviewerAdjudication, synthesizePlanReviewRound, } from '../../adjudicator/planning.js';
|
|
6
6
|
import { assertAdjudicationTransitionSignal } from '../../adjudicator/specs.js';
|
|
7
7
|
import { getPlanReviewDebtRoundThreshold, getReviewStuckWindow } from '../../config.js';
|
|
8
|
+
import { OPEN_FINDINGS_PROMPT_ITEM_LIMIT } from '../../context/inline-review-context.js';
|
|
8
9
|
import { toPlanReviewDebt } from '../../review-debt.js';
|
|
9
10
|
import { writeDiagnostic } from '../../diagnostic.js';
|
|
10
11
|
import { getWorktreeStatus } from '../../git.js';
|
|
@@ -402,7 +403,14 @@ export async function runPlanningResponsePhase(state, statePath, phase, logger)
|
|
|
402
403
|
if (isPlanRefinementState(state)) {
|
|
403
404
|
writeDiagnostic(`${formatPlanRefinementRoundLine({ round: state.rounds.length + 1, maxRounds: state.maxRounds })}\n`, logger);
|
|
404
405
|
}
|
|
405
|
-
|
|
406
|
+
// The bounded finding set for this response round: the prompt and the
|
|
407
|
+
// disposition eligibility below consume this same selection. Findings
|
|
408
|
+
// beyond the per-round limit stay open; when the presented set is fully
|
|
409
|
+
// dispositioned, this phase stays active and presents the next batch (see
|
|
410
|
+
// hasNextResponseBatch below), so the cap never strands a finding.
|
|
411
|
+
const openFindings = state.findings
|
|
412
|
+
.filter(mode === 'optional' ? isOpenNonBlockingFinding : isOpenBlockingFinding)
|
|
413
|
+
.slice(0, OPEN_FINDINGS_PROMPT_ITEM_LIMIT);
|
|
406
414
|
// Recorded operator guidance must reach the planner. If a prior blocked response
|
|
407
415
|
// closed every finding, the guidance would otherwise be silently discarded here
|
|
408
416
|
// (finalizePlanReviewResponseWithoutOpenFindings clears pendingPlanReviewGuidance
|
|
@@ -459,6 +467,31 @@ export async function runPlanningResponsePhase(state, statePath, phase, logger)
|
|
|
459
467
|
// hardening finding cannot be silently un-banked by an out-of-band disposition)
|
|
460
468
|
// and matches the replay harness's open-blocking eligibility guard.
|
|
461
469
|
const openFindingIds = new Set(openFindings.map((finding) => finding.id));
|
|
470
|
+
// Optional responses must disposition every presented finding exactly once,
|
|
471
|
+
// matching execute optional response coverage: a partial optional response
|
|
472
|
+
// would otherwise land acceptance below while presented findings — and every
|
|
473
|
+
// overflow batch beyond the per-round presentation limit — were never
|
|
474
|
+
// resolved. Out-of-set ids keep their documented no-op tolerance.
|
|
475
|
+
if (mode === 'optional' && codex.payload.outcome === 'responded') {
|
|
476
|
+
const seenPresentedIds = new Set();
|
|
477
|
+
const duplicateIds = new Set();
|
|
478
|
+
for (const response of codex.payload.responses) {
|
|
479
|
+
if (!openFindingIds.has(response.id)) {
|
|
480
|
+
continue;
|
|
481
|
+
}
|
|
482
|
+
if (seenPresentedIds.has(response.id)) {
|
|
483
|
+
duplicateIds.add(response.id);
|
|
484
|
+
}
|
|
485
|
+
seenPresentedIds.add(response.id);
|
|
486
|
+
}
|
|
487
|
+
if (duplicateIds.size > 0) {
|
|
488
|
+
throw new Error(`Planner optional response returned duplicate finding dispositions: ${[...duplicateIds].join(', ')}`);
|
|
489
|
+
}
|
|
490
|
+
const missingIds = openFindings.map((finding) => finding.id).filter((id) => !seenPresentedIds.has(id));
|
|
491
|
+
if (missingIds.length > 0) {
|
|
492
|
+
throw new Error(`Planner optional response did not disposition every presented finding: ${missingIds.join(', ')}`);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
462
495
|
const findings = state.findings.map((finding) => {
|
|
463
496
|
if (!openFindingIds.has(finding.id)) {
|
|
464
497
|
return finding;
|
|
@@ -474,6 +507,16 @@ export async function runPlanningResponsePhase(state, statePath, phase, logger)
|
|
|
474
507
|
coderCommit: null,
|
|
475
508
|
};
|
|
476
509
|
});
|
|
510
|
+
// Response rounds batch: when the presented set was fully dispositioned but
|
|
511
|
+
// open findings of this round's kind remain (they were beyond the per-round
|
|
512
|
+
// presentation limit), stay in this response phase so the next batch is
|
|
513
|
+
// presented immediately instead of spending a plan-review round per batch.
|
|
514
|
+
// The full-disposition requirement guarantees the backlog strictly shrinks;
|
|
515
|
+
// a partially-skipped presented set falls through to the reviewer so the
|
|
516
|
+
// existing convergence machinery judges it.
|
|
517
|
+
const openSelector = mode === 'optional' ? isOpenNonBlockingFinding : isOpenBlockingFinding;
|
|
518
|
+
const presentedStillOpen = findings.some((finding) => openFindingIds.has(finding.id) && openSelector(finding));
|
|
519
|
+
const hasNextResponseBatch = !presentedStillOpen && findings.some(openSelector);
|
|
477
520
|
const nextState = await saveState(statePath, {
|
|
478
521
|
...state,
|
|
479
522
|
plannerSessionHandle: codex.sessionHandle,
|
|
@@ -485,17 +528,19 @@ export async function runPlanningResponsePhase(state, statePath, phase, logger)
|
|
|
485
528
|
planReviewDebt: toPlanReviewDebt(findings),
|
|
486
529
|
phase: dirtyWorktreeBlocker || codex.payload.outcome === 'blocked'
|
|
487
530
|
? 'blocked'
|
|
488
|
-
:
|
|
489
|
-
?
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
531
|
+
: hasNextResponseBatch
|
|
532
|
+
? phase
|
|
533
|
+
: mode === 'optional'
|
|
534
|
+
? derivedPlanReview
|
|
535
|
+
? 'awaiting_derived_plan_execution'
|
|
536
|
+
: 'done'
|
|
537
|
+
: 'reviewer_plan',
|
|
493
538
|
status: dirtyWorktreeBlocker || codex.payload.outcome === 'blocked'
|
|
494
539
|
? 'blocked'
|
|
495
|
-
: mode === 'optional' && !derivedPlanReview
|
|
540
|
+
: mode === 'optional' && !derivedPlanReview && !hasNextResponseBatch
|
|
496
541
|
? 'done'
|
|
497
542
|
: 'running',
|
|
498
|
-
derivedPlanStatus: mode === 'optional' && codex.payload.outcome !== 'blocked' && derivedPlanReview
|
|
543
|
+
derivedPlanStatus: mode === 'optional' && codex.payload.outcome !== 'blocked' && derivedPlanReview && !hasNextResponseBatch
|
|
499
544
|
? 'accepted'
|
|
500
545
|
: state.derivedPlanStatus,
|
|
501
546
|
blockedFromPhase: dirtyWorktreeBlocker || codex.payload.outcome === 'blocked' ? phase : null,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { renderInlinedRangeDiffSection, truncateInlineSectionBody } from '../context/inline-review-context.js';
|
|
1
|
+
import { AGENT_FREE_TEXT_SECTION_MAX_CHARS, boundChangedFileList, boundCommitSubjectList, boundFreeTextValues, boundOpenFindingsForPrompt, GIT_SUMMARY_SECTION_MAX_CHARS, 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, } from './shared.js';
|
|
3
3
|
import { assertPromptBuilder } from './assert-builder.js';
|
|
4
4
|
import { getUserGuidanceLines } from './guidance.js';
|
|
@@ -107,6 +107,23 @@ export function buildLegacyScopePrompt(planDoc, progressText) {
|
|
|
107
107
|
].join('\n');
|
|
108
108
|
}
|
|
109
109
|
export const EARLIER_SCOPE_CHANGES_SECTION_HEADING = '## Earlier-scope changes to files in this diff';
|
|
110
|
+
// Render-only view of the coder's progress justification: the four free-text
|
|
111
|
+
// fields share the fixed aggregate free-text budget; the stored payload keeps
|
|
112
|
+
// its full text.
|
|
113
|
+
function boundProgressJustificationForPrompt(justification) {
|
|
114
|
+
const bounded = boundFreeTextValues([
|
|
115
|
+
justification.milestoneTargeted,
|
|
116
|
+
justification.newEvidence,
|
|
117
|
+
justification.whyNotRedundant,
|
|
118
|
+
justification.nextStepUnlocked,
|
|
119
|
+
]);
|
|
120
|
+
return {
|
|
121
|
+
milestoneTargeted: bounded[0],
|
|
122
|
+
newEvidence: bounded[1],
|
|
123
|
+
whyNotRedundant: bounded[2],
|
|
124
|
+
nextStepUnlocked: bounded[3],
|
|
125
|
+
};
|
|
126
|
+
}
|
|
110
127
|
// Rendered for every execute-scope review, with or without an overlap: a
|
|
111
128
|
// tool-access reviewer can find earlier-scope history itself, and the rule
|
|
112
129
|
// about what that history means must not depend on whether Neal inlined it.
|
|
@@ -124,8 +141,8 @@ export function buildReviewerPrompt(args) {
|
|
|
124
141
|
// collected" (null/undefined) so an empty diff still rides the inlined channel
|
|
125
142
|
// instead of falling back to git_diff-tool phrasing the reviewer cannot use.
|
|
126
143
|
const rangeDiffInlined = accessMode === 'read-only' && args.inlinedRangeDiff !== null && args.inlinedRangeDiff !== undefined;
|
|
127
|
-
const changedFilesText = args.changedFiles.length > 0 ? args.changedFiles.join('\n') : '(no changed files)';
|
|
128
|
-
const commitsText = args.commits.length > 0 ? args.commits.join('\n') : '(no commits recorded)';
|
|
144
|
+
const changedFilesText = args.changedFiles.length > 0 ? boundChangedFileList(args.changedFiles).join('\n') : '(no changed files)';
|
|
145
|
+
const commitsText = args.commits.length > 0 ? boundCommitSubjectList(args.commits).join('\n') : '(no commits recorded)';
|
|
129
146
|
const falsificationLines = getCodeReviewFalsificationLines({
|
|
130
147
|
rangeLabel: 'commit range',
|
|
131
148
|
gitInspectionExamples: `Use git commands against the repository, for example: git diff ${args.baseCommit}..${args.headCommit}, git show --stat ${args.headCommit}, and targeted path diffs or file reads for changed files.`,
|
|
@@ -181,7 +198,7 @@ export function buildReviewerPrompt(args) {
|
|
|
181
198
|
commitsText,
|
|
182
199
|
'',
|
|
183
200
|
'Diff stat:',
|
|
184
|
-
args.diffStat
|
|
201
|
+
args.diffStat ? truncateInlineSectionBody(args.diffStat, GIT_SUMMARY_SECTION_MAX_CHARS) : '(no diff stat)',
|
|
185
202
|
'',
|
|
186
203
|
'Changed files:',
|
|
187
204
|
changedFilesText,
|
|
@@ -200,10 +217,10 @@ export function buildReviewerPrompt(args) {
|
|
|
200
217
|
'Use `meaningfulProgressRationale` to explain the convergence judgment against the parent objective and recent accepted-scope history. Do not use it to restate correctness findings.',
|
|
201
218
|
'',
|
|
202
219
|
'Coder progress justification for this scope:',
|
|
203
|
-
JSON.stringify(args.progressJustification, null, 2),
|
|
220
|
+
JSON.stringify(boundProgressJustificationForPrompt(args.progressJustification), null, 2),
|
|
204
221
|
'',
|
|
205
222
|
'Recent accepted scope history for this parent objective:',
|
|
206
|
-
args.recentHistorySummary,
|
|
223
|
+
truncateInlineSectionBody(args.recentHistorySummary, AGENT_FREE_TEXT_SECTION_MAX_CHARS),
|
|
207
224
|
'',
|
|
208
225
|
reviewHistoryLine,
|
|
209
226
|
'If prior review history or continuity context describes a finding as fixed, rejected, or deferred, do not reopen the same claim from that history alone.',
|
|
@@ -302,7 +319,7 @@ export function buildCoderResponsePrompt(args) {
|
|
|
302
319
|
...getUserGuidanceLines('coder'),
|
|
303
320
|
'',
|
|
304
321
|
'Open findings:',
|
|
305
|
-
JSON.stringify(args.openFindings, null, 2),
|
|
322
|
+
JSON.stringify(boundOpenFindingsForPrompt(args.openFindings), null, 2),
|
|
306
323
|
'',
|
|
307
324
|
'Current progress state:',
|
|
308
325
|
buildProgressSection(args.progressText),
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'node:fs';
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
|
+
import { truncateInlineSectionBody } from '../context/inline-review-context.js';
|
|
5
|
+
// Per-role character cap for operator guidance inlined into prompts. Guidance
|
|
6
|
+
// beyond the cap is truncated at render time with an explicit marker; the
|
|
7
|
+
// guidance file itself is never modified. `neal check` warns when a guidance
|
|
8
|
+
// file exceeds this cap.
|
|
9
|
+
export const USER_GUIDANCE_MAX_CHARS = 20_000;
|
|
4
10
|
export const GUIDANCE_ROLES = ['coder', 'reviewer', 'planner'];
|
|
5
11
|
export const GUIDANCE_SECTION_HEADER = '## User Guidance';
|
|
6
12
|
const cache = new Map();
|
|
@@ -38,7 +44,7 @@ export function getUserGuidanceLines(role) {
|
|
|
38
44
|
if (!content) {
|
|
39
45
|
return [];
|
|
40
46
|
}
|
|
41
|
-
return ['', GUIDANCE_SECTION_HEADER, '', content];
|
|
47
|
+
return ['', GUIDANCE_SECTION_HEADER, '', truncateInlineSectionBody(content, USER_GUIDANCE_MAX_CHARS)];
|
|
42
48
|
}
|
|
43
49
|
export function clearUserGuidanceCache() {
|
|
44
50
|
cache.clear();
|
|
@@ -52,6 +58,7 @@ export function collectGuidanceDiagnostics() {
|
|
|
52
58
|
entries.push({
|
|
53
59
|
role,
|
|
54
60
|
bytes: Buffer.byteLength(entry.content, 'utf8'),
|
|
61
|
+
chars: entry.content.length,
|
|
55
62
|
path: entry.path,
|
|
56
63
|
});
|
|
57
64
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { boundOpenFindingsForPrompt, truncateInlineSectionBody } from '../context/inline-review-context.js';
|
|
1
2
|
import { AUTONOMY_BLOCKED, AUTONOMY_DONE, getCanonicalPlanContractLines, getDerivedPlanSectionContractLines, getProtocolMarkerArtifactProhibitionLines, getTerminalMarkerArtifactBoundaryLines, } from './shared.js';
|
|
2
3
|
import { assertPromptBuilder } from './assert-builder.js';
|
|
3
|
-
import { getUserGuidanceLines } from './guidance.js';
|
|
4
|
+
import { getUserGuidanceLines, USER_GUIDANCE_MAX_CHARS } from './guidance.js';
|
|
4
5
|
const PROMPT_MODULE_PATH = 'src/neal/prompts/planning.ts';
|
|
5
6
|
const PLAN_VERIFICATION_NECESSITY_RULE = 'A repository-wide invariant or global regression guarantee belongs in the plan only when it is necessary for the requested change to be correct.';
|
|
6
7
|
function getPlanVerificationScopeLines(role) {
|
|
@@ -278,6 +279,9 @@ export function buildCoderPlanResponsePrompt(args) {
|
|
|
278
279
|
mode === 'blocking'
|
|
279
280
|
? 'Address the currently open review findings provided below.'
|
|
280
281
|
: 'The currently open review findings below are non-blocking. Decide whether to address each one now or explicitly reject/defer it with rationale.',
|
|
282
|
+
...(mode === 'optional'
|
|
283
|
+
? ['Return exactly one disposition for every finding listed below; a partial response is rejected.']
|
|
284
|
+
: []),
|
|
281
285
|
reviewMode === 'derived-plan'
|
|
282
286
|
? 'Edit only the derived plan artifact and directly related planning notes for that derived plan.'
|
|
283
287
|
: 'Edit only the plan document and directly related planning artifacts.',
|
|
@@ -308,13 +312,15 @@ export function buildCoderPlanResponsePrompt(args) {
|
|
|
308
312
|
...(args.planReviewGuidance
|
|
309
313
|
? [
|
|
310
314
|
'Operator guidance for this blocked plan-review recovery:',
|
|
311
|
-
|
|
315
|
+
// Same render-time cap as the operator guidance files; the persisted
|
|
316
|
+
// guidance record keeps its full message.
|
|
317
|
+
truncateInlineSectionBody(args.planReviewGuidance.message, USER_GUIDANCE_MAX_CHARS),
|
|
312
318
|
'',
|
|
313
319
|
'This guidance supplements the open reviewer findings. It does not waive plan-contract requirements, verification requirements, or the need to address blocking findings.',
|
|
314
320
|
'',
|
|
315
321
|
]
|
|
316
322
|
: []),
|
|
317
323
|
'Open findings:',
|
|
318
|
-
JSON.stringify(args.openFindings, null, 2),
|
|
324
|
+
JSON.stringify(boundOpenFindingsForPrompt(args.openFindings), null, 2),
|
|
319
325
|
].join('\n');
|
|
320
326
|
}
|