@navels/neal 0.5.1 → 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.
@@ -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.filter(selector).map((finding) => ({
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 non-open findings: ${[...unknownIds].join(', ')}`);
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
- const hasBlockingFindings = findings.some((finding) => finding.severity === 'blocking');
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
- ? EXECUTE_FINALIZATION_PHASE
640
- : 'reviewer_scope';
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
- args.blockedReason,
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
- `- Blocked reason: ${args.blockedReason}`,
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`.
@@ -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: 2,
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 { extractVerificationCommandResults, latestCommandResultPerCommand, summarizeVerificationCommandResults, } from './verification-events.js';
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
- verificationCommands,
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
- const openFindings = state.findings.filter(mode === 'optional' ? isOpenNonBlockingFinding : isOpenBlockingFinding);
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
- : mode === 'optional'
489
- ? derivedPlanReview
490
- ? 'awaiting_derived_plan_execution'
491
- : 'done'
492
- : 'reviewer_plan',
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 || '(no diff stat)',
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
- args.planReviewGuidance.message,
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
  }