@navels/neal 0.5.1 → 0.6.1

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,3 +1,4 @@
1
+ const DEFAULT_REVIEW_LEVEL = 'moderate';
1
2
  export function getAdversarialReviewDoctrineLines(context) {
2
3
  const falsificationTarget = context.falsificationTarget ?? 'the implementation';
3
4
  const creditPhrase = context.creditPhrase ?? 'give it credit for working';
@@ -83,11 +84,46 @@ export function getPreexistingFailureContractLines(context = {}) {
83
84
  'Conversely, treat a fix for a pre-existing issue that fails the acceptance-surface test as scope drift rather than extra credit: flag it so the change stays bounded to the plan.',
84
85
  ];
85
86
  }
87
+ function getReviewLevelTrustBoundaryLine(level) {
88
+ switch (level) {
89
+ case 'strict':
90
+ return 'Review level: strict. Assume adversarial trust boundaries: treat every input, file, and process the change can be reached from, including local processes and internal run artifacts, as a potential attacker. A hardening gap or a missing defense against a local or adversarial actor is a reachable failure under these boundaries and blocks.';
91
+ case 'lenient':
92
+ return 'Review level: lenient. Assume ordinary trust boundaries and block only on correctness failures and real, reachable bugs. Do not demand additional robustness, hardening, performance, or style work as a condition of acceptance.';
93
+ case 'moderate':
94
+ return 'Review level: moderate. Assume ordinary trust boundaries: internal run artifacts and other files this system writes for itself are not security boundaries. Block on correctness bugs and on failures reachable under normal use. Do not require defenses against an actor who could already subvert the system directly, for example by editing its files; a failure that needs such an actor is not reachable.';
95
+ }
96
+ }
97
+ function getDemotedCategoryMeaning(outputContract) {
98
+ return outputContract === 'completion_verdict'
99
+ ? 'In this review a demoted or ignored category is not missing work: it must not produce `continue_execution` or `block_for_operator`, and when the plan objectives are otherwise satisfied you return `accept_complete`.'
100
+ : 'In this review a demoted category means non_blocking severity, and an ignored category produces no finding.';
101
+ }
102
+ // Level calibration for the two code reviewers. Renders the level's assumed
103
+ // trust boundaries, the reachability filter that applies under every level,
104
+ // and the rule for how the User Guidance section refines the level. The adversarial
105
+ // inspection stance itself is not level-dependent and is stated as such.
106
+ export function getReviewLevelCalibrationLines(context) {
107
+ const outputContract = context.outputContract ?? 'structured_findings';
108
+ const performanceExampleOutcome = outputContract === 'completion_verdict' ? 'not missing work' : 'non_blocking';
109
+ return [
110
+ getReviewLevelTrustBoundaryLine(context.level),
111
+ 'Reachability filter: a blocking finding must describe a failure that is reachable under the assumed trust boundaries, as refined by any User Guidance section below. A failure that is only theoretically possible, or that requires an actor outside those boundaries, does not block at any level.',
112
+ 'The review level narrows what rises to blocking; it never changes the inspection stance. At every level, still treat the subject as hostile input, try to falsify before crediting, trace runtime invariants, and catch real regressions. No level authorizes trusting the coder or skipping inspection.',
113
+ 'How the User Guidance section combines with the review level: the level supplies the baseline trust boundaries and the default finding-severity rules. Guidance may widen or narrow the trust boundaries for this project, and those refined boundaries are what "reachable" means when deciding whether a finding blocks.',
114
+ `Guidance may also demote a finding category such as robustness, hardening, performance, or style to non-blocking or ignore it entirely, or promote a category to blocking. ${getDemotedCategoryMeaning(outputContract)}`,
115
+ 'Fixed floor at every level and under any guidance: the reachability filter, the adversarial inspection stance, and blocking on reachable correctness failures (including correctness regressions, where existing behavior now produces wrong results) cannot be switched off. Robustness, hardening, performance, and style are not part of that floor and may be demoted. Ignore guidance on any point where it conflicts with this floor.',
116
+ 'Example: at the moderate level, guidance saying the run directory is defended against local processes makes a local-process attack on the run directory reachable, so a finding about it blocks.',
117
+ `Example: at any level, guidance saying "ignore performance, correctness only" makes a performance regression ${performanceExampleOutcome}, while a reachable correctness failure still blocks.`,
118
+ ];
119
+ }
86
120
  export function getFindingQualityLines(context = {}) {
121
+ const level = context.level ?? DEFAULT_REVIEW_LEVEL;
87
122
  if (context.outputContract === 'completion_verdict') {
88
123
  return [
89
124
  'Treat completion-blocking issues like review findings: each one needs concrete evidence, affected files or runtime behavior, and a required correction.',
90
125
  'Use `continue_execution` only for concrete missing work that is bounded enough for one follow-on scope; use `block_for_operator` for ambiguous or externally constrained gaps.',
126
+ 'A finding category that the review level or the User Guidance section has demoted or ignored is not missing work: never return `continue_execution` or `block_for_operator` for it. When the plan objectives are otherwise satisfied, return `accept_complete`.',
91
127
  'Do not turn low-signal style preferences, trivial code-shape preferences, or optional refactors into missing work.',
92
128
  'If the plan is complete aside from low-signal trivia, return `accept_complete` rather than inventing a non-blocking completion concern.',
93
129
  ];
@@ -95,7 +131,9 @@ export function getFindingQualityLines(context = {}) {
95
131
  return [
96
132
  'Produce only structured review findings.',
97
133
  'Use blocking severity for correctness, regression, or missing-verification issues.',
98
- 'Also use blocking severity for substantive robustness or performance regressions introduced by the implementation, especially in infrastructure, config, parser, caching, retry, or orchestration code.',
134
+ level === 'lenient'
135
+ ? 'Robustness or performance regressions introduced by the implementation are non_blocking unless they are a reachable correctness failure; use blocking severity only in that case.'
136
+ : 'Also use blocking severity for substantive robustness or performance regressions introduced by the implementation, especially in infrastructure, config, parser, caching, retry, or orchestration code.',
99
137
  'Use non_blocking severity for suggestions that do not block acceptance.',
100
138
  'Only emit non_blocking findings when they identify a concrete maintenance, observability, or testability issue that is genuinely worth a later follow-up turn.',
101
139
  'Do not emit non_blocking findings for formatting, whitespace, naming preferences, trivial code-shape preferences, or optional refactors.',
@@ -1,8 +1,8 @@
1
1
  import { guardStructuredJsonOutputFormatLines } from '../agents/structured-json.js';
2
- import { renderInlinedRangeDiffSection } from '../context/inline-review-context.js';
2
+ import { AGENT_FREE_TEXT_SECTION_MAX_CHARS, boundChangedFileList, boundCommitSubjectList, boundFreeTextValues, GIT_SUMMARY_SECTION_MAX_CHARS, renderInlinedRangeDiffSection, truncateInlineSectionBody, } from '../context/inline-review-context.js';
3
3
  import { assertPromptBuilder } from './assert-builder.js';
4
4
  import { getUserGuidanceLines } from './guidance.js';
5
- import { getAdversarialReviewDoctrineLines, getCodeReviewFalsificationLines, getFindingQualityLines, getPreexistingFailureContractLines, getRegressionPreservationLines, getVerificationSkepticismLines, } from './review-doctrine.js';
5
+ import { getAdversarialReviewDoctrineLines, getCodeReviewFalsificationLines, getFindingQualityLines, getPreexistingFailureContractLines, getRegressionPreservationLines, getReviewLevelCalibrationLines, getVerificationSkepticismLines, } from './review-doctrine.js';
6
6
  const PROMPT_MODULE_PATH = 'src/neal/prompts/specialized.ts';
7
7
  // Guarded output-format instruction block shared by the two structured-JSON
8
8
  // completion base prompts. It emits only JSON-only framing and a
@@ -28,9 +28,8 @@ export function buildFinalCompletionSummaryPrompt(args) {
28
28
  if (!finalCompletionVariant) {
29
29
  throw new Error('Prompt spec completion_coder is missing a final_completion variant');
30
30
  }
31
- const lastImplementationScope = args.packet.lastNonEmptyImplementationScope
32
- ? JSON.stringify(args.packet.lastNonEmptyImplementationScope, null, 2)
33
- : 'null';
31
+ const boundedLastScope = boundLastImplementationScope(args.packet.lastNonEmptyImplementationScope);
32
+ const lastImplementationScope = boundedLastScope ? JSON.stringify(boundedLastScope, null, 2) : 'null';
34
33
  return [
35
34
  `Summarize whether the execute-mode plan at ${args.planDoc} is complete as a whole.`,
36
35
  '',
@@ -52,19 +51,19 @@ export function buildFinalCompletionSummaryPrompt(args) {
52
51
  currentScopeLabel: args.packet.currentScopeLabel,
53
52
  acceptedScopeRecordCount: args.packet.acceptedScopeCount,
54
53
  blockedScopeCount: args.packet.blockedScopeCount,
55
- scopeAccountingSummary: args.packet.scopeAccountingSummary,
54
+ scopeAccountingSummary: boundScopeAccountingSummary(args.packet.scopeAccountingSummary),
56
55
  verificationOnlyCompletion: args.packet.verificationOnlyCompletion,
57
- aggregateReviewContext: args.packet.aggregateReviewContext,
58
- completedScopeSummary: args.packet.completedScopeSummary,
56
+ aggregateReviewContext: boundAggregateReviewContext(args.packet.aggregateReviewContext),
57
+ completedScopeSummary: boundCompletedScopeSummary(args.packet.completedScopeSummary),
59
58
  terminalChangedFilesSummary: args.packet.terminalChangedFilesSummary,
60
59
  planChangedFilesSummary: args.packet.planChangedFilesSummary,
61
- verificationCommandResults: args.packet.verificationCommandResults,
62
- verificationSummary: args.packet.verificationSummary,
63
- lastNonEmptyImplementationScope: args.packet.lastNonEmptyImplementationScope,
60
+ verificationTally: args.packet.verificationTally,
61
+ lastNonEmptyImplementationScope: boundedLastScope,
64
62
  continueExecutionCount: args.packet.continueExecutionCount,
65
63
  continueExecutionMax: args.packet.continueExecutionMax,
66
64
  }, null, 2),
67
65
  '',
66
+ '`verificationTally` is a bounded summary of the run\'s recorded verification commands; the complete per-command record is in the run directory\'s events.ndjson.',
68
67
  'If the completion is verification-only, say so directly in `whatChangedOverall` or `remainingKnownGaps` instead of pretending there was a terminal implementation diff.',
69
68
  ...getUserGuidanceLines('coder'),
70
69
  '',
@@ -72,6 +71,71 @@ export function buildFinalCompletionSummaryPrompt(args) {
72
71
  lastImplementationScope,
73
72
  ].join('\n');
74
73
  }
74
+ // Maximum remaining-known-gap entries rendered per completion prompt. Gaps
75
+ // beyond the limit collapse to one explicit overflow entry so the rendered
76
+ // list stays bounded as the gap count grows. Also keeps the summary's
77
+ // free-text value count far under boundFreeTextValues' cardinality bound.
78
+ const REMAINING_GAPS_PROMPT_ITEM_LIMIT = 100;
79
+ // Render-only view of the coder's completion summary: planGoalSatisfied is
80
+ // copied exactly, the free-text fields and the first
81
+ // REMAINING_GAPS_PROMPT_ITEM_LIMIT gap entries share the fixed aggregate
82
+ // free-text budget, and any further gaps collapse to one explicit overflow
83
+ // entry. The stored summary keeps its full text.
84
+ function boundCompletionSummaryForPrompt(summary) {
85
+ const gaps = summary.remainingKnownGaps.slice(0, REMAINING_GAPS_PROMPT_ITEM_LIMIT);
86
+ const bounded = boundFreeTextValues([summary.whatChangedOverall, summary.verificationSummary, ...gaps]);
87
+ const remainingKnownGaps = bounded.slice(2);
88
+ const omittedGaps = summary.remainingKnownGaps.length - gaps.length;
89
+ if (omittedGaps > 0) {
90
+ remainingKnownGaps.push(`(+${omittedGaps} more remaining known gaps omitted from this prompt)`);
91
+ }
92
+ return {
93
+ planGoalSatisfied: summary.planGoalSatisfied,
94
+ whatChangedOverall: bounded[0],
95
+ verificationSummary: bounded[1],
96
+ remainingKnownGaps,
97
+ };
98
+ }
99
+ // Prompt-render view of the completion packet's aggregate review context with
100
+ // its run-scaling fields bounded: the commit-subject and changed-file lists
101
+ // collapse past their limits and the diff stat truncates with a marker. The
102
+ // packet keeps the full values for non-prompt consumers.
103
+ function boundAggregateReviewContext(context) {
104
+ return {
105
+ ...context,
106
+ commitSubjects: boundCommitSubjectList(context.commitSubjects),
107
+ diffStat: truncateInlineSectionBody(context.diffStat, GIT_SUMMARY_SECTION_MAX_CHARS),
108
+ changedFiles: boundChangedFileList(context.changedFiles),
109
+ };
110
+ }
111
+ // Prompt-render view of the packet's completed-scope summary. The summary
112
+ // carries agent-authored blocker and residual-debt text for every completed
113
+ // scope, so it shares the fixed agent free-text cap; the packet keeps the full
114
+ // string for non-prompt consumers.
115
+ function boundCompletedScopeSummary(summary) {
116
+ return truncateInlineSectionBody(summary, AGENT_FREE_TEXT_SECTION_MAX_CHARS);
117
+ }
118
+ // Prompt-render view of the packet's last non-empty implementation scope: the
119
+ // changed-file list is bounded, and the agent-authored commit subject gets the
120
+ // fixed free-text cap while a null subject stays null. The packet keeps the
121
+ // full values for non-prompt consumers.
122
+ function boundLastImplementationScope(scope) {
123
+ if (!scope) {
124
+ return null;
125
+ }
126
+ return {
127
+ ...scope,
128
+ commitSubject: scope.commitSubject === null ? null : boundFreeTextValues([scope.commitSubject])[0],
129
+ changedFiles: boundChangedFileList(scope.changedFiles),
130
+ };
131
+ }
132
+ // Prompt-render view of the packet's scope-accounting summary. The summary
133
+ // grows with derived-plan replacements (one path per replaced parent scope),
134
+ // so it shares the fixed agent free-text cap; the packet keeps the full string
135
+ // for non-prompt consumers.
136
+ function boundScopeAccountingSummary(summary) {
137
+ return truncateInlineSectionBody(summary, AGENT_FREE_TEXT_SECTION_MAX_CHARS);
138
+ }
75
139
  export function buildFinalCompletionReviewerPrompt(args) {
76
140
  const spec = assertPromptBuilder('completion_reviewer', 'buildFinalCompletionReviewerPrompt', PROMPT_MODULE_PATH);
77
141
  const finalCompletionVariant = spec.variants.find((variant) => variant.kind === 'final_completion');
@@ -79,15 +143,15 @@ export function buildFinalCompletionReviewerPrompt(args) {
79
143
  throw new Error('Prompt spec completion_reviewer is missing a final_completion variant');
80
144
  }
81
145
  const accessMode = args.accessMode ?? 'tool-access';
146
+ const reviewLevel = args.reviewLevel ?? 'moderate';
82
147
  // A collected diff may legitimately be the empty string (a range with no
83
148
  // changes); distinguish "collected" (any string, including '') from "not
84
149
  // collected" (null/undefined) so an empty diff still rides the inlined channel
85
150
  // instead of falling back to git_diff-tool phrasing the reviewer cannot use.
86
151
  const rangeDiffInlined = accessMode === 'read-only' && args.inlinedRangeDiff !== null && args.inlinedRangeDiff !== undefined;
87
152
  const completionSummary = args.summary;
88
- const lastImplementationScope = args.packet.lastNonEmptyImplementationScope
89
- ? JSON.stringify(args.packet.lastNonEmptyImplementationScope, null, 2)
90
- : 'null';
153
+ const boundedLastScope = boundLastImplementationScope(args.packet.lastNonEmptyImplementationScope);
154
+ const lastImplementationScope = boundedLastScope ? JSON.stringify(boundedLastScope, null, 2) : 'null';
91
155
  const aggregateRange = args.packet.aggregateReviewContext.range;
92
156
  const falsificationLines = getCodeReviewFalsificationLines({
93
157
  rangeLabel: aggregateRange ? `aggregate range ${aggregateRange}` : null,
@@ -138,6 +202,7 @@ export function buildFinalCompletionReviewerPrompt(args) {
138
202
  judgmentTarget: 'whole-plan completion',
139
203
  proofTarget: 'the aggregate implementation satisfies the plan',
140
204
  }),
205
+ ...getReviewLevelCalibrationLines({ level: reviewLevel, outputContract: 'completion_verdict' }),
141
206
  ...falsificationLines,
142
207
  ...scratchLines,
143
208
  'Falsify cross-scope runtime invariants and integration behavior before accepting completion, especially paths that individual scope reviews could not see together.',
@@ -145,7 +210,7 @@ export function buildFinalCompletionReviewerPrompt(args) {
145
210
  ...skepticismLines,
146
211
  ...regressionLines,
147
212
  ...preexistingLines,
148
- ...getFindingQualityLines({ outputContract: 'completion_verdict' }),
213
+ ...getFindingQualityLines({ outputContract: 'completion_verdict', level: reviewLevel }),
149
214
  'Review the whole-plan result for correctness and completeness against the plan objectives, regressions or missing behavior, cross-scope integration issues that may not have been visible in individual scope reviews, code quality, maintainability, and consistency of the final implementation, and adequacy of test coverage and verification for the total change.',
150
215
  ...getReviewerContextLines(args.reviewerContext),
151
216
  'Do not treat prior per-scope acceptance as sufficient evidence that the whole plan is complete or that the aggregate code quality is acceptable.',
@@ -166,7 +231,7 @@ export function buildFinalCompletionReviewerPrompt(args) {
166
231
  '- Invalid example: subject "Finish scope 4 cleanup"; bullets ["Summarize per-scope plan work", "Describe reviewer process"].',
167
232
  '',
168
233
  'Coder whole-plan completion summary:',
169
- JSON.stringify(completionSummary, null, 2),
234
+ JSON.stringify(boundCompletionSummaryForPrompt(completionSummary), null, 2),
170
235
  '',
171
236
  'Whole-plan completion packet:',
172
237
  JSON.stringify({
@@ -174,20 +239,20 @@ export function buildFinalCompletionReviewerPrompt(args) {
174
239
  currentScopeLabel: args.packet.currentScopeLabel,
175
240
  acceptedScopeRecordCount: args.packet.acceptedScopeCount,
176
241
  blockedScopeCount: args.packet.blockedScopeCount,
177
- scopeAccountingSummary: args.packet.scopeAccountingSummary,
242
+ scopeAccountingSummary: boundScopeAccountingSummary(args.packet.scopeAccountingSummary),
178
243
  verificationOnlyCompletion: args.packet.verificationOnlyCompletion,
179
- aggregateReviewContext: args.packet.aggregateReviewContext,
244
+ aggregateReviewContext: boundAggregateReviewContext(args.packet.aggregateReviewContext),
180
245
  finalCommit: args.packet.finalCommit,
181
- completedScopeSummary: args.packet.completedScopeSummary,
246
+ completedScopeSummary: boundCompletedScopeSummary(args.packet.completedScopeSummary),
182
247
  terminalChangedFilesSummary: args.packet.terminalChangedFilesSummary,
183
248
  planChangedFilesSummary: args.packet.planChangedFilesSummary,
184
- verificationCommandResults: args.packet.verificationCommandResults,
185
- verificationSummary: args.packet.verificationSummary,
186
- lastNonEmptyImplementationScope: args.packet.lastNonEmptyImplementationScope,
249
+ verificationTally: args.packet.verificationTally,
250
+ lastNonEmptyImplementationScope: boundedLastScope,
187
251
  continueExecutionCount: args.packet.continueExecutionCount,
188
252
  continueExecutionMax: args.packet.continueExecutionMax,
189
253
  }, null, 2),
190
254
  '',
255
+ '`verificationTally` is a bounded summary of the run\'s recorded verification commands; the complete per-command record is in the run directory\'s events.ndjson.',
191
256
  'If this was a verification-only terminal scope, judge the whole-plan result directly instead of pretending there was a final implementation diff.',
192
257
  ...getUserGuidanceLines('reviewer'),
193
258
  '',
@@ -67,6 +67,7 @@ const SCOPE_REVIEWER_CONTEXT = context('ScopeReviewerPromptContext', [
67
67
  field('scratchDir', 'run_artifact', true, 'Run-local reviewer scratch directory for temporary verification artifacts.'),
68
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.'),
69
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."),
70
+ field('reviewLevel', 'orchestrator_state', false, "Reviewer strictness ('strict', 'moderate', or 'lenient') from the `neal.review_level` config key, resolved by rounds.ts via getReviewLevel(cwd). Defaults to 'moderate' when absent."),
70
71
  ]);
71
72
  const COMPLETION_CODER_CONTEXT = context('CompletionCoderPromptContext', [
72
73
  field('planDoc', 'prompt_argument', true, 'Path to the execute-mode plan being evaluated for final completion.'),
@@ -80,6 +81,7 @@ const COMPLETION_REVIEWER_CONTEXT = context('CompletionReviewerPromptContext', [
80
81
  field('scratchDir', 'run_artifact', true, 'Run-local final-completion reviewer scratch directory for temporary verification artifacts.'),
81
82
  field('repositoryState', 'repository_state', true, 'Current repository state used to judge whole-plan completion.'),
82
83
  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."),
84
+ field('reviewLevel', 'orchestrator_state', false, "Reviewer strictness ('strict', 'moderate', or 'lenient') from the `neal.review_level` config key, resolved by rounds.ts via getReviewLevel(cwd). Defaults to 'moderate' when absent."),
83
85
  ]);
84
86
  const CONSULTANT_CONTEXT = context('ConsultantPromptContext', [
85
87
  field('blockedReason', 'prompt_argument', true, 'Blocked reason reported by the stalled coder or reviewer turn.'),
@@ -88,7 +90,7 @@ const CONSULTANT_CONTEXT = context('ConsultantPromptContext', [
88
90
  export const PROMPT_SPECS = [
89
91
  {
90
92
  id: 'plan_author',
91
- version: 4,
93
+ version: 5,
92
94
  changelog: [
93
95
  {
94
96
  version: 1,
@@ -106,6 +108,10 @@ export const PROMPT_SPECS = [
106
108
  version: 4,
107
109
  renderSha: 'e9f5b2dd5f66df87986b2e2f884c560d02a235db053b544221dcb43512e057a4',
108
110
  },
111
+ {
112
+ version: 5,
113
+ renderSha: '148ed793be3f39c4a5505289ab3045ff124d8c44a53bf5fa9ccc8c3c5f20d408',
114
+ },
109
115
  ],
110
116
  role: 'coder',
111
117
  purpose: 'Author or revise concise, human-reviewable Neal-executable plans at moderate-to-high-level implementation detail.',
@@ -283,7 +289,7 @@ export const PROMPT_SPECS = [
283
289
  },
284
290
  {
285
291
  id: 'scope_coder',
286
- version: 2,
292
+ version: 3,
287
293
  changelog: [
288
294
  {
289
295
  version: 1,
@@ -293,6 +299,10 @@ export const PROMPT_SPECS = [
293
299
  version: 2,
294
300
  renderSha: '0ce921ee7d0acc4042bacf31e1509968f7e76df8db8417eb4b724ce7842794ac',
295
301
  },
302
+ {
303
+ version: 3,
304
+ renderSha: '8fb94430bf9d9abcb11f905106a3a04fc1b81bd101104c88f84a8693f705c5ea',
305
+ },
296
306
  ],
297
307
  role: 'coder',
298
308
  purpose: 'Execute exactly one bounded implementation scope and respond to in-scope review feedback without starting new scopes.',
@@ -396,7 +406,7 @@ export const PROMPT_SPECS = [
396
406
  },
397
407
  {
398
408
  id: 'scope_reviewer',
399
- version: 4,
409
+ version: 5,
400
410
  changelog: [
401
411
  {
402
412
  version: 1,
@@ -414,6 +424,10 @@ export const PROMPT_SPECS = [
414
424
  version: 4,
415
425
  renderSha: 'da87b19f2401ffdca21e3cefec1037c6470b3e74810b6152d07c55fa4924047f',
416
426
  },
427
+ {
428
+ version: 5,
429
+ renderSha: 'e8dcb976d0ea22e9026e09a87d7dde93c8513334f62ad89472ebefe91771754b',
430
+ },
417
431
  ],
418
432
  role: 'reviewer',
419
433
  purpose: 'Review execute-scope results for correctness, verification coverage, and meaningful progress toward the active parent objective.',
@@ -471,6 +485,7 @@ export const PROMPT_SPECS = [
471
485
  field('parentScopeLabel', 'orchestrator_state', true, 'Active parent objective label.'),
472
486
  field('scratchDir', 'run_artifact', true, 'Run-local scratch directory for reviewer verification artifacts.'),
473
487
  field('accessMode', 'orchestrator_state', false, "Optional explicit doctrine access mode ('tool-access' or 'read-only'); defaults to 'tool-access' when absent."),
488
+ field('reviewLevel', 'orchestrator_state', false, "Optional reviewer strictness ('strict', 'moderate', or 'lenient') from `neal.review_level`, resolved by rounds.ts via getReviewLevel(cwd); defaults to 'moderate' when absent."),
474
489
  ]),
475
490
  },
476
491
  schemaTarget: {
@@ -505,12 +520,16 @@ export const PROMPT_SPECS = [
505
520
  },
506
521
  {
507
522
  id: 'completion_coder',
508
- version: 1,
523
+ version: 2,
509
524
  changelog: [
510
525
  {
511
526
  version: 1,
512
527
  renderSha: 'f88b43d206de28212ad7f6f3ae84576718934b0a5e36953e35afee89c4d7ce5e',
513
528
  },
529
+ {
530
+ version: 2,
531
+ renderSha: '85f36db1245090baa3c8f74e5fe2d3de774664dcb6a3b1b84dcc49425cc3c646',
532
+ },
514
533
  ],
515
534
  role: 'coder',
516
535
  purpose: 'Summarize whole-plan completion state in compact structured JSON.',
@@ -565,7 +584,7 @@ export const PROMPT_SPECS = [
565
584
  },
566
585
  {
567
586
  id: 'completion_reviewer',
568
- version: 3,
587
+ version: 5,
569
588
  changelog: [
570
589
  {
571
590
  version: 1,
@@ -579,6 +598,14 @@ export const PROMPT_SPECS = [
579
598
  version: 3,
580
599
  renderSha: '127097db4b0d06cba8943681d12d93fbb07e577e078c99c1d81036399bedb66a',
581
600
  },
601
+ {
602
+ version: 4,
603
+ renderSha: 'c47009016178fc29c34440e06636ba7b21c6beb59202dd3fe63e365cb32a75cf',
604
+ },
605
+ {
606
+ version: 5,
607
+ renderSha: '7930cec95560ad880bba94a7ae48e68c621805787261e295c4c405d09235c87b',
608
+ },
582
609
  ],
583
610
  role: 'reviewer',
584
611
  purpose: 'Judge whole-plan completion and decide whether Neal should accept completion, continue execution, or block for operator input.',
@@ -625,6 +652,7 @@ export const PROMPT_SPECS = [
625
652
  field('summary', 'review_history', true, 'Coder-authored completion summary.'),
626
653
  field('scratchDir', 'run_artifact', true, 'Run-local scratch directory for final-completion reviewer artifacts.'),
627
654
  field('accessMode', 'orchestrator_state', false, "Optional explicit doctrine access mode ('tool-access' or 'read-only'); defaults to 'tool-access' when absent."),
655
+ field('reviewLevel', 'orchestrator_state', false, "Optional reviewer strictness ('strict', 'moderate', or 'lenient') from `neal.review_level`, resolved by rounds.ts via getReviewLevel(cwd); defaults to 'moderate' when absent."),
628
656
  ]),
629
657
  },
630
658
  schemaTarget: {
@@ -0,0 +1,80 @@
1
+ import { NealProviderError } from './types.js';
2
+ const REPORTED_SECTION_COUNT = 3;
3
+ const SECTION_NAME_MAX_CHARS = 60;
4
+ // Measures the prompt as contiguous sections split on `## ` headings, the
5
+ // heading level Neal's prompt builders use for top-level sections. Text before
6
+ // the first heading is labeled "instructions". Section sizes include the
7
+ // heading line and sum exactly to the prompt length.
8
+ export function measurePromptSections(prompt) {
9
+ const boundaries = [];
10
+ for (const match of prompt.matchAll(/^## (.*)$/gm)) {
11
+ boundaries.push({
12
+ index: match.index,
13
+ name: match[1].trim() === '' ? 'untitled section' : match[1].trim(),
14
+ });
15
+ }
16
+ const sections = [];
17
+ const leadingChars = boundaries.length === 0 ? prompt.length : boundaries[0].index;
18
+ if (leadingChars > 0) {
19
+ sections.push({ name: 'instructions', chars: leadingChars });
20
+ }
21
+ for (const [position, boundary] of boundaries.entries()) {
22
+ const end = position + 1 < boundaries.length ? boundaries[position + 1].index : prompt.length;
23
+ sections.push({ name: boundary.name, chars: end - boundary.index });
24
+ }
25
+ if (sections.length === 0) {
26
+ sections.push({ name: 'instructions', chars: prompt.length });
27
+ }
28
+ return sections;
29
+ }
30
+ function formatChars(value) {
31
+ return value.toLocaleString('en-US');
32
+ }
33
+ function formatSectionName(name) {
34
+ return name.length > SECTION_NAME_MAX_CHARS
35
+ ? `${name.slice(0, SECTION_NAME_MAX_CHARS - 3)}...`
36
+ : name;
37
+ }
38
+ export function buildInputTooLargeMessage(args) {
39
+ const largest = [...args.sections]
40
+ .sort((a, b) => b.chars - a.chars)
41
+ .slice(0, REPORTED_SECTION_COUNT);
42
+ const sectionReport = largest
43
+ .map((section) => `"${formatSectionName(section.name)}" ${formatChars(section.chars)} chars`)
44
+ .join('; ');
45
+ return [
46
+ `Prompt is ${formatChars(args.promptChars)} chars; ${args.provider} accepts at most ${formatChars(args.maxInputChars)} input chars per turn.`,
47
+ `Largest sections: ${sectionReport}.`,
48
+ ].join('\n');
49
+ }
50
+ // Reads the largest section name back out of a message produced by
51
+ // buildInputTooLargeMessage, so `neal status` can name the input to shrink in
52
+ // its Next Action. Provider-authored input_too_large rejections carry no
53
+ // section report, so this returns null for them and the caller falls back to
54
+ // generic wording. Kept next to the builder so the format and its one parser
55
+ // stay in lockstep.
56
+ export function largestSectionNameFromInputTooLargeMessage(message) {
57
+ const match = message.match(/Largest sections: "([^"]+)"/);
58
+ return match ? match[1] : null;
59
+ }
60
+ // No-op when the role declares no budget or the prompt fits. Throws before any
61
+ // SDK work otherwise; the error is non-retryable by construction, so it never
62
+ // consumes API-retry budget.
63
+ export function assertPromptWithinInputBudget(args) {
64
+ if (args.maxInputChars === undefined || args.prompt.length <= args.maxInputChars) {
65
+ return;
66
+ }
67
+ throw new NealProviderError({
68
+ message: buildInputTooLargeMessage({
69
+ provider: args.provider,
70
+ promptChars: args.prompt.length,
71
+ maxInputChars: args.maxInputChars,
72
+ sections: measurePromptSections(args.prompt),
73
+ }),
74
+ provider: args.provider,
75
+ role: args.role,
76
+ sessionHandle: args.sessionHandle ?? null,
77
+ kind: 'input_too_large',
78
+ retryable: false,
79
+ });
80
+ }
@@ -1,10 +1,25 @@
1
1
  import { Codex } from '@openai/codex-sdk';
2
- import { runStructuredJsonProtocol } from '../agents/structured-json.js';
2
+ import { buildStructuredJsonPrompt, runStructuredJsonProtocol } from '../agents/structured-json.js';
3
3
  import { agentSettingsIsolated } from './agent-settings-isolation.js';
4
4
  import { agentSubprocessEnv } from './git-config-isolation.js';
5
+ import { assertPromptWithinInputBudget } from './input-budget.js';
5
6
  import { resolveRateCost } from './pricing.js';
6
7
  import { isContentSafetyRefusalMessage, NealProviderError } from './types.js';
7
8
  const OPENAI_CODEX_PROVIDER_ID = 'openai-codex';
9
+ // Codex's app-server rejects any single turn whose input exceeds this size
10
+ // (JSON-RPC code -32602, input_error_code `input_too_large`). Declared as
11
+ // `maxInputChars` on both role capabilities and enforced by the preflight at
12
+ // every turn/round entry point below.
13
+ const OPENAI_CODEX_MAX_INPUT_CHARS = 1_048_576;
14
+ function assertCodexInputBudget(args) {
15
+ assertPromptWithinInputBudget({
16
+ prompt: args.prompt,
17
+ maxInputChars: OPENAI_CODEX_MAX_INPUT_CHARS,
18
+ provider: OPENAI_CODEX_PROVIDER_ID,
19
+ role: args.role,
20
+ sessionHandle: args.sessionHandle,
21
+ });
22
+ }
8
23
  class CodexInactivityTimeoutError extends Error {
9
24
  constructor(timeoutMs) {
10
25
  super(`Codex timed out after ${Math.round(timeoutMs / 1000)}s without progress`);
@@ -76,6 +91,12 @@ function inferCodexErrorKind(error, message, fallback) {
76
91
  if (isContentSafetyRefusalMessage(message)) {
77
92
  return 'content_refused';
78
93
  }
94
+ // Codex's app-server rejects an over-limit turn with JSON-RPC code -32602
95
+ // and input_error_code `input_too_large`; matching the stable error code
96
+ // means a limit the preflight did not predict still classifies correctly.
97
+ if (text.includes('input_too_large')) {
98
+ return 'input_too_large';
99
+ }
79
100
  if (text.includes('permission') || text.includes('denied') || text.includes('forbidden') || text.includes('not authorized')) {
80
101
  return 'permission_denied';
81
102
  }
@@ -568,6 +589,11 @@ class OpenAICodexCoderAdapter {
568
589
  async runPrompt(args) {
569
590
  let thread = null;
570
591
  try {
592
+ assertCodexInputBudget({
593
+ prompt: args.prompt,
594
+ role: 'coder',
595
+ sessionHandle: args.resumeHandle ?? null,
596
+ });
571
597
  const createThread = this.options.createThread ?? createCodexThread;
572
598
  thread = createThread({
573
599
  cwd: args.cwd,
@@ -620,6 +646,16 @@ class OpenAICodexCoderAdapter {
620
646
  while (true) {
621
647
  let thread = null;
622
648
  try {
649
+ // Preflight the exact text the initial SDK turn will send: the
650
+ // protocol-wrapped prompt. buildStructuredJsonPrompt is the same pure
651
+ // builder runStructuredJsonProtocol applies to the same inputs below,
652
+ // so the checked text is byte-identical to the sent text. Repair
653
+ // prompts are checked in runRepair before their thread is created.
654
+ assertCodexInputBudget({
655
+ prompt: buildStructuredJsonPrompt(args.prompt, args.structuredJsonProtocol),
656
+ role: 'coder',
657
+ sessionHandle: args.resumeHandle ?? null,
658
+ });
623
659
  const createThread = this.options.createThread ?? createCodexThread;
624
660
  thread = createThread({
625
661
  cwd: args.cwd,
@@ -650,6 +686,14 @@ class OpenAICodexCoderAdapter {
650
686
  };
651
687
  },
652
688
  runRepair: async (prompt) => {
689
+ // A generated repair prompt embeds the invalid payload and the
690
+ // original response, so it can exceed the budget even when the
691
+ // initial prompt fit; preflight it before creating its thread.
692
+ assertCodexInputBudget({
693
+ prompt,
694
+ role: 'coder',
695
+ sessionHandle: thread.id ?? args.resumeHandle ?? null,
696
+ });
653
697
  // Repair runs on a fresh prompt-only thread whose prompt forbids
654
698
  // tool use entirely, so nothing in the repair flow needs write
655
699
  // access; the read-only sandbox enforces that mechanically
@@ -740,6 +784,20 @@ class OpenAICodexStructuredAdvisorAdapter {
740
784
  while (true) {
741
785
  let thread = null;
742
786
  try {
787
+ // Preflight the exact text the initial SDK turn will send: the
788
+ // protocol-wrapped prompt on the local-JSON path
789
+ // (buildStructuredJsonPrompt is the same pure builder
790
+ // runStructuredJsonProtocol applies to the same inputs below, so the
791
+ // checked text is byte-identical to the sent text), the bare prompt on
792
+ // the provider-native path. Repair prompts are checked in runRepair
793
+ // before their thread is created.
794
+ assertCodexInputBudget({
795
+ prompt: args.structuredJsonProtocol?.protocol === 'neal-json-block-v1'
796
+ ? buildStructuredJsonPrompt(args.prompt, args.structuredJsonProtocol)
797
+ : args.prompt,
798
+ role: 'structured-advisor',
799
+ sessionHandle: args.resumeHandle ?? null,
800
+ });
743
801
  thread = createThread({
744
802
  cwd: args.cwd,
745
803
  sessionHandle: args.resumeHandle,
@@ -770,6 +828,14 @@ class OpenAICodexStructuredAdvisorAdapter {
770
828
  };
771
829
  },
772
830
  runRepair: async (prompt) => {
831
+ // A generated repair prompt embeds the invalid payload and the
832
+ // original response, so it can exceed the budget even when the
833
+ // initial prompt fit; preflight it before creating its thread.
834
+ assertCodexInputBudget({
835
+ prompt,
836
+ role: 'structured-advisor',
837
+ sessionHandle: thread.id ?? args.resumeHandle ?? null,
838
+ });
773
839
  const repairThread = createThread({
774
840
  cwd: args.cwd,
775
841
  model: this.options.model ?? undefined,
@@ -875,6 +941,7 @@ export const openAICodexProviderDefinition = {
875
941
  write: true,
876
942
  shell: true,
877
943
  },
944
+ maxInputChars: OPENAI_CODEX_MAX_INPUT_CHARS,
878
945
  supportsSessionResume: true,
879
946
  supportsModelOverride: true,
880
947
  supportsStructuredOutput: true,
@@ -891,6 +958,7 @@ export const openAICodexProviderDefinition = {
891
958
  write: false,
892
959
  shell: false,
893
960
  },
961
+ maxInputChars: OPENAI_CODEX_MAX_INPUT_CHARS,
894
962
  supportsSessionResume: true,
895
963
  supportsModelOverride: true,
896
964
  supportsStructuredOutput: true,
@@ -344,7 +344,7 @@ async function readRunStates(cwd) {
344
344
  const runsDir = getRunsDir(cwd);
345
345
  let entries;
346
346
  try {
347
- entries = await readdir(runsDir);
347
+ entries = await readdir(runsDir, { withFileTypes: true });
348
348
  }
349
349
  catch (error) {
350
350
  if (isMissingFileError(error)) {
@@ -353,7 +353,14 @@ async function readRunStates(cwd) {
353
353
  throw error;
354
354
  }
355
355
  const states = {};
356
- for (const entry of entries.sort()) {
356
+ // Only directories are runs. The runs root also collects stray files such as
357
+ // macOS `.DS_Store`, and joining a run-state path onto one of those reads
358
+ // through a non-directory.
359
+ const runDirNames = entries
360
+ .filter((entry) => entry.isDirectory())
361
+ .map((entry) => entry.name)
362
+ .sort();
363
+ for (const entry of runDirNames) {
357
364
  const statePath = getRunStatePath(join(runsDir, entry));
358
365
  const content = await readOptionalText(statePath);
359
366
  if (content !== null) {
@@ -374,6 +381,13 @@ function assertSameStringMap(before, after, label) {
374
381
  }
375
382
  }
376
383
  }
384
+ // A run-state path can be unreadable because nothing is there (`ENOENT`) or
385
+ // because a path segment is not a directory (`ENOTDIR`, e.g. a stray file in the
386
+ // runs root). Both mean "no run state here", never a review failure.
377
387
  function isMissingFileError(error) {
378
- return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT';
388
+ if (typeof error !== 'object' || error === null || !('code' in error)) {
389
+ return false;
390
+ }
391
+ const code = error.code;
392
+ return code === 'ENOENT' || code === 'ENOTDIR';
379
393
  }