@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,4 +1,5 @@
1
1
  import { readFile, stat } from 'node:fs/promises';
2
+ import { boundChangedFileList } from './context/inline-review-context.js';
2
3
  import { validatePlanDocument } from './plan-validation.js';
3
4
  import { getDerivedPlanIdentityView } from './state-views.js';
4
5
  export const DEFAULT_PARENT_OBJECTIVE_HISTORY_WINDOW = 5;
@@ -390,14 +391,13 @@ export function renderRecentAcceptedScopesSummary(state, parentScopeLabel, windo
390
391
  }
391
392
  const concentrationSummary = touchedFileCounts.size === 0
392
393
  ? '(no changed files recorded)'
393
- : [...touchedFileCounts.entries()]
394
+ : boundChangedFileList([...touchedFileCounts.entries()]
394
395
  .sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]))
395
- .map(([file, touches]) => `${file} (${touches}/${recentScopes.length} scopes)`)
396
- .join(', ');
396
+ .map(([file, touches]) => `${file} (${touches}/${recentScopes.length} scopes)`)).join(', ');
397
397
  return [
398
398
  `Accepted scope history for parent objective ${parentScopeLabel} (oldest to newest, last ${window} max):`,
399
399
  ...recentScopes.map((scope) => {
400
- const changedFiles = scope.changedFiles.length > 0 ? scope.changedFiles.join(', ') : '(no changed files)';
400
+ const changedFiles = scope.changedFiles.length > 0 ? boundChangedFileList(scope.changedFiles).join(', ') : '(no changed files)';
401
401
  return [
402
402
  `- Scope ${scope.number}`,
403
403
  ` commit: ${scope.finalCommit ?? 'pending'}`,
@@ -169,8 +169,14 @@ function assertValidInteractiveBlockedRecoveryState(args) {
169
169
  if (recovery.lastHandledTurn > recovery.turns.length) {
170
170
  throwStateInvariant(context, `${fieldPath}.lastHandledTurn`, `must not exceed recorded turn count ${recovery.turns.length}`);
171
171
  }
172
- if (recovery.turns.length > recovery.maxTurns) {
173
- throwStateInvariant(context, `${fieldPath}.turns`, `recorded turn count ${recovery.turns.length} exceeds maxTurns ${recovery.maxTurns}`);
172
+ // A recovery that reaches the turn cap and is then resolved by a turn-cap
173
+ // terminal directive records one terminal-resolution turn beyond `maxTurns`,
174
+ // so the recorded count may be `maxTurns + 1`. That extra turn is only ever
175
+ // the terminal resolution: past the cap, new guidance becomes a
176
+ // `pendingDirective` rather than an appended turn, so nothing else can push
177
+ // the count higher.
178
+ if (recovery.turns.length > recovery.maxTurns + 1) {
179
+ throwStateInvariant(context, `${fieldPath}.turns`, `recorded turn count ${recovery.turns.length} exceeds maxTurns ${recovery.maxTurns} by more than the one terminal-resolution turn`);
174
180
  }
175
181
  if (recovery.pendingDirective) {
176
182
  assertNonEmptyString(recovery.pendingDirective.recordedAt, `${fieldPath}.pendingDirective.recordedAt`, context);
@@ -11,6 +11,7 @@ import { refreshActiveRunLock } from './run-lock.js';
11
11
  import { validateReviewerSquashMessageDraft } from './squash-message.js';
12
12
  import { assertOrchestrationPhase, assertOrchestrationStatus, assertValidOrchestrationState, } from './state-invariants.js';
13
13
  const TOP_LEVEL_MODES = new Set(['plan', 'execute']);
14
+ const INTERACTIVE_BLOCKED_RECOVERY_TURN_ORIGINS = ['operator', 'consultant'];
14
15
  const INTERACTIVE_BLOCKED_RECOVERY_SOURCE_PHASES = new Set([
15
16
  'coder_plan',
16
17
  'reviewer_plan',
@@ -257,6 +258,16 @@ function readNullableString(record, key, fieldPath = key) {
257
258
  }
258
259
  throwInvalidState(fieldPath, `expected string or null, received ${formatStateValue(value)}`);
259
260
  }
261
+ function readOptionalString(record, key, fieldPath = key) {
262
+ if (!hasOwn(record, key)) {
263
+ return undefined;
264
+ }
265
+ const value = record[key];
266
+ if (typeof value === 'string') {
267
+ return value;
268
+ }
269
+ throwInvalidState(fieldPath, `expected string, received ${formatStateValue(value)}`);
270
+ }
260
271
  function readOptionalBoolean(record, key, fieldPath = key) {
261
272
  if (!hasOwn(record, key)) {
262
273
  return undefined;
@@ -646,6 +657,8 @@ function hydrateInteractiveBlockedRecoveryTurnDisposition(value, fieldPath) {
646
657
  rationale: readString(disposition, 'rationale', `${fieldPath}.rationale`),
647
658
  blocker: readString(disposition, 'blocker', `${fieldPath}.blocker`),
648
659
  replacementPlan: readString(disposition, 'replacementPlan', `${fieldPath}.replacementPlan`),
660
+ laterScopeNumber: readOptionalSafeInteger(disposition, 'laterScopeNumber', `${fieldPath}.laterScopeNumber`) ?? 0,
661
+ laterScopeBody: readOptionalString(disposition, 'laterScopeBody', `${fieldPath}.laterScopeBody`) ?? '',
649
662
  resultingPhase: readOrchestrationPhase(disposition, 'resultingPhase', `${fieldPath}.resultingPhase`),
650
663
  };
651
664
  }
@@ -655,6 +668,7 @@ function hydrateInteractiveBlockedRecoveryTurn(value, fieldPath) {
655
668
  number: readSafeInteger(turn, 'number', `${fieldPath}.number`),
656
669
  recordedAt: readString(turn, 'recordedAt', `${fieldPath}.recordedAt`),
657
670
  operatorGuidance: readString(turn, 'operatorGuidance', `${fieldPath}.operatorGuidance`),
671
+ origin: readOptionalNullableEnum(turn, 'origin', INTERACTIVE_BLOCKED_RECOVERY_TURN_ORIGINS, `${fieldPath}.origin`) ?? null,
658
672
  disposition: hydrateInteractiveBlockedRecoveryTurnDisposition(readRequired(turn, 'disposition', `${fieldPath}.disposition`), `${fieldPath}.disposition`),
659
673
  };
660
674
  }
@@ -667,6 +681,7 @@ function hydrateInteractiveBlockedRecoveryDirective(value, fieldPath) {
667
681
  recordedAt: readString(directive, 'recordedAt', `${fieldPath}.recordedAt`),
668
682
  operatorGuidance: readString(directive, 'operatorGuidance', `${fieldPath}.operatorGuidance`),
669
683
  terminalOnly: readBoolean(directive, 'terminalOnly', `${fieldPath}.terminalOnly`),
684
+ origin: readOptionalNullableEnum(directive, 'origin', INTERACTIVE_BLOCKED_RECOVERY_TURN_ORIGINS, `${fieldPath}.origin`) ?? null,
670
685
  };
671
686
  }
672
687
  const CONSULTANT_TRIAGE_CATEGORIES = [
@@ -10,6 +10,7 @@ import { listRuns } from './run-registry.js';
10
10
  import { formatPublicRunStatus, getRunDisplayStatus } from './run-status.js';
11
11
  import { getRunStatePath, loadState } from './state.js';
12
12
  import { getDerivedPlanView } from './state-views.js';
13
+ import { largestSectionNameFromInputTooLargeMessage } from './providers/input-budget.js';
13
14
  import { getNealBuildMetadata } from './version.js';
14
15
  export { formatPublicPhase } from './phase-display.js';
15
16
  const EVENT_TAIL_BYTES = 256 * 1024;
@@ -25,6 +26,7 @@ const PROVIDER_ERROR_KINDS = new Set([
25
26
  'permission_denied',
26
27
  'session_unavailable',
27
28
  'content_refused',
29
+ 'input_too_large',
28
30
  'provider_failed',
29
31
  'unknown',
30
32
  ]);
@@ -104,16 +106,21 @@ export async function buildStatusSnapshot(args) {
104
106
  const health = classifyHealth(state, eventSummary, nowMs, finalCompletionStaleness);
105
107
  const publicStatus = formatPublicStatusForDisplayStatus(displayStatus, health);
106
108
  const publicPhase = formatPublicPhase(state.phase);
109
+ const providerError = summarizeProviderError(tail.events);
107
110
  const nextAction = formatNextAction({
108
111
  manualGate,
109
112
  resumeDecision,
110
113
  finalCompletionStaleness,
111
114
  runId,
112
115
  blockedGuidance,
116
+ // The Next Action must reflect the run's current failure, not history: the
117
+ // provider-error summary stays on the snapshot as historical information,
118
+ // but it only drives the Next Action while no later provider turn or phase
119
+ // has completed successfully after it.
120
+ providerError: providerError && isProviderErrorActive(tail.events) ? providerError : null,
113
121
  });
114
122
  const commits = summarizeCommits(state);
115
123
  const squash = await summarizeSquashArtifact(state.runDir);
116
- const providerError = summarizeProviderError(tail.events);
117
124
  const build = await summarizeBuild(state);
118
125
  const patch = await summarizePatch(state, displayStatus, squash);
119
126
  return {
@@ -372,6 +379,24 @@ function formatNextAction(snapshot) {
372
379
  if (snapshot.finalCompletionStaleness.stale && snapshot.resumeDecision.kind === 'continue') {
373
380
  return `Final completion appears stale after reviewer output. Inspect artifacts and recover explicitly after confirming the branch state: neal status --run ${snapshot.runId}`;
374
381
  }
382
+ // An input_too_large failure keeps the resume decision at `continue` on
383
+ // purpose: the adapter-boundary preflight re-measures the actual rebuilt
384
+ // prompt against the provider budget on every attempt, so resume is always
385
+ // executable and an unchanged oversized prompt fails fast before any
386
+ // provider call. This branch only redirects the operator to shrink the named
387
+ // input before that resume. Every lever it names works on an existing run:
388
+ // operator guidance files are re-read at every prompt build, and upgrading
389
+ // neal applies the current prompt bounds on resume. Per-run provider
390
+ // rebinding does not exist, so a prompt that cannot fit needs a new run on a
391
+ // provider with a larger or no declared limit.
392
+ if (snapshot.providerError?.kind === 'input_too_large' && snapshot.resumeDecision.kind === 'continue') {
393
+ const largestSection = largestSectionNameFromInputTooLargeMessage(snapshot.providerError.message);
394
+ const namedInput = largestSection ? `the "${largestSection}" prompt section` : 'the oversized prompt input';
395
+ return (`The last attempt failed because the prompt exceeded the provider's input limit. ` +
396
+ `Shrink ${namedInput} first (trim operator guidance files, or upgrade neal so the current prompt bounds apply), ` +
397
+ `then resume this run: ${snapshot.resumeDecision.resumeCommand}. ` +
398
+ `If the prompt cannot fit under the limit, start a new run with a provider that has a larger or no input limit.`);
399
+ }
375
400
  if (snapshot.resumeDecision.kind === 'needs_message' && snapshot.blockedGuidance) {
376
401
  const firstOption = snapshot.blockedGuidance.options[0]?.command;
377
402
  if (firstOption) {
@@ -401,6 +426,9 @@ export function formatStatusNextActionForState(state) {
401
426
  },
402
427
  runId,
403
428
  blockedGuidance: buildBlockedGuidance({ state, runId }),
429
+ // This state-only path has no events access, so it cannot see provider
430
+ // errors and renders the plain decision-based action.
431
+ providerError: null,
404
432
  });
405
433
  }
406
434
  function formatNextActionForDecision(decision) {
@@ -1026,6 +1054,29 @@ function parseSquashArtifact(value) {
1026
1054
  originalFinalCommit,
1027
1055
  };
1028
1056
  }
1057
+ // Events that prove the run progressed past a provider failure: a provider
1058
+ // turn or structured round finished, or a whole phase completed. A provider
1059
+ // error followed by any of these is resolved history, not the active failure.
1060
+ const PROVIDER_ERROR_RESOLUTION_EVENT_TYPES = new Set([
1061
+ 'provider.turn_completed',
1062
+ 'provider.structured_output_received',
1063
+ 'phase.complete',
1064
+ ]);
1065
+ // True while the latest provider_error event has no later resolution event
1066
+ // after it in the tail, so conditional guidance keyed off the error (the
1067
+ // input_too_large Next Action) stops as soon as a retry or resume succeeds.
1068
+ function isProviderErrorActive(events) {
1069
+ for (let index = events.length - 1; index >= 0; index -= 1) {
1070
+ const type = events[index].type;
1071
+ if (type === 'provider.provider_error') {
1072
+ return true;
1073
+ }
1074
+ if (PROVIDER_ERROR_RESOLUTION_EVENT_TYPES.has(type)) {
1075
+ return false;
1076
+ }
1077
+ }
1078
+ return false;
1079
+ }
1029
1080
  function summarizeProviderError(events) {
1030
1081
  for (let index = events.length - 1; index >= 0; index -= 1) {
1031
1082
  const event = events[index];
@@ -17,9 +17,13 @@ function appendInteractiveBlockedRecoverySection(lines, title, recovery, options
17
17
  return;
18
18
  }
19
19
  for (const turn of recovery.turns) {
20
- lines.push(`- Recovery turn ${turn.number} at ${turn.recordedAt}: ${turn.operatorGuidance}`);
20
+ lines.push(`- Recovery turn ${turn.number} at ${turn.recordedAt} (${turn.origin ?? 'unrecorded'} origin): ${turn.operatorGuidance}`);
21
21
  if (turn.disposition) {
22
- lines.push(`- Recovery turn ${turn.number} coder action: ${turn.disposition.action}`, `- Recovery turn ${turn.number} coder summary: ${turn.disposition.summary}`, `- Recovery turn ${turn.number} coder blocker: ${turn.disposition.blocker || 'n/a'}`, `- Recovery turn ${turn.number} coder rationale: ${turn.disposition.rationale}`, `- Recovery turn ${turn.number} resulting step: ${formatPublicPhase(turn.disposition.resultingPhase)}`);
22
+ lines.push(`- Recovery turn ${turn.number} coder action: ${turn.disposition.action}`, `- Recovery turn ${turn.number} coder summary: ${turn.disposition.summary}`, `- Recovery turn ${turn.number} coder blocker: ${turn.disposition.blocker || 'n/a'}`, `- Recovery turn ${turn.number} coder rationale: ${turn.disposition.rationale}`);
23
+ if (turn.disposition.laterScopeNumber > 0) {
24
+ lines.push(`- Recovery turn ${turn.number} revised later scope: ${turn.disposition.laterScopeNumber}`, `- Recovery turn ${turn.number} revised scope text:`, ...turn.disposition.laterScopeBody.split('\n').map((line) => ` ${line}`));
25
+ }
26
+ lines.push(`- Recovery turn ${turn.number} resulting step: ${formatPublicPhase(turn.disposition.resultingPhase)}`);
23
27
  }
24
28
  else {
25
29
  lines.push(`- Recovery turn ${turn.number} coder response: pending`);
@@ -1,3 +1,4 @@
1
+ import { truncateInlineSectionBody } from './context/inline-review-context.js';
1
2
  function stringValue(value) {
2
3
  return typeof value === 'string' && value.trim() ? value.trim() : null;
3
4
  }
@@ -56,6 +57,54 @@ function renderCommandStatus(result) {
56
57
  }
57
58
  return 'unknown';
58
59
  }
60
+ // Bounds for the final-completion verification tally: at most this many recent
61
+ // failing commands, each command string capped so one pathological command
62
+ // cannot inflate the tally.
63
+ export const VERIFICATION_TALLY_RECENT_FAILURE_LIMIT = 10;
64
+ export const VERIFICATION_TALLY_COMMAND_MAX_CHARS = 300;
65
+ // Builds the bounded verification tally the final-completion prompts embed:
66
+ // pass/fail/unknown counts over the latest result per distinct command, plus
67
+ // the last few failing commands with their exit codes. Distinct commands are
68
+ // ordered by their latest event position (not first appearance), so a repeated
69
+ // command that failed at the end of the run counts as recent. The complete
70
+ // per-command record stays in events.ndjson.
71
+ export function buildVerificationTally(results) {
72
+ const latestByCommand = new Map();
73
+ results.forEach((result, index) => {
74
+ latestByCommand.set(result.command, { result, lastIndex: index });
75
+ });
76
+ const latestResults = [...latestByCommand.values()]
77
+ .sort((a, b) => a.lastIndex - b.lastIndex)
78
+ .map((entry) => entry.result);
79
+ let passed = 0;
80
+ let failed = 0;
81
+ let unknown = 0;
82
+ const failures = [];
83
+ for (const result of latestResults) {
84
+ const status = renderCommandStatus(result);
85
+ if (status === 'passed') {
86
+ passed += 1;
87
+ }
88
+ else if (status === 'failed') {
89
+ failed += 1;
90
+ failures.push({
91
+ command: truncateInlineSectionBody(result.command, VERIFICATION_TALLY_COMMAND_MAX_CHARS),
92
+ exitCode: result.exitCode,
93
+ });
94
+ }
95
+ else {
96
+ unknown += 1;
97
+ }
98
+ }
99
+ return {
100
+ totalRuns: results.length,
101
+ distinctCommands: latestResults.length,
102
+ passed,
103
+ failed,
104
+ unknown,
105
+ recentFailures: failures.slice(-VERIFICATION_TALLY_RECENT_FAILURE_LIMIT),
106
+ };
107
+ }
59
108
  export function summarizeVerificationCommandResults(results) {
60
109
  if (results.length === 0) {
61
110
  return 'No verification commands were recorded in events.ndjson.';
@@ -74,6 +74,16 @@ declares and owns its own execution shape. Plans authored `multi_scope` or
74
74
  `multi_scope_unknown` are unaffected. Refinement may adjust their scope
75
75
  content as usual.
76
76
 
77
+ ### Revising a later scope mid-run
78
+
79
+ Operator guidance during a block can revise a later scope. When a
80
+ `neal resume --message` directive calls for changing a scope after the current
81
+ one, the coder returns replacement text for that one scope, and neal splices it
82
+ into the plan, checks it still parses, and writes it. neal reads the plan fresh
83
+ from disk each turn, so the next scope runs against the revised text. The coder
84
+ can only revise a scope after the one it's working on, never the current or an
85
+ earlier scope, and a consultant-injected directive can't trigger it.
86
+
77
87
  ## Multi-scope format
78
88
 
79
89
  `executionShape: multi_scope` must include a literal `## Execution Queue`
@@ -175,6 +175,84 @@ older run state.
175
175
 
176
176
  If a prompt change would force validator or retained parser behavior to change, treat that as a contract change and review the prompt spec, prompt builder, schema builder, and tests together.
177
177
 
178
+ ## Prompt size bounds
179
+
180
+ Prompt inputs that grow with run length are either capped at render time or
181
+ deliberately left unbounded with the provider input budget as their backstop,
182
+ and each section below says which. The contract has four parts.
183
+
184
+ **The completion packet carries a verification tally, not the history.**
185
+ `buildFinalCompletionPacket()` embeds `verificationTally`
186
+ (`buildVerificationTally` in `src/neal/verification-events.ts`): total command
187
+ runs, distinct commands, passed/failed/unknown counts over the latest result
188
+ per distinct command, and the last 10 failing commands with exit codes, each
189
+ command string capped at 300 characters. Both completion prompts embed the
190
+ tally and point at the run directory's `events.ndjson` for the complete
191
+ per-command record. The events file is the source of truth; the packet never
192
+ carries the full history and no extra artifact is written.
193
+
194
+ **Run-scaling sections are capped where they enter a prompt.** The shared
195
+ helpers live in `src/neal/context/inline-review-context.ts` and truncation is
196
+ render-time only: stored state, persisted artifacts, and schema-validated
197
+ coder outputs are never mutated to satisfy a bound.
198
+
199
+ - Inlined range-diff sections: `INLINE_SECTION_MAX_CHARS` (200,000
200
+ characters) via `truncateInlineSectionBody`, with an explicit truncation
201
+ marker.
202
+ - Operator guidance: `USER_GUIDANCE_MAX_CHARS` (20,000 characters) applies
203
+ per role file (`src/neal/prompts/guidance.ts`; `neal check` warns when a
204
+ guidance file exceeds the cap), to the persisted plan-review recovery
205
+ guidance message, and to the latest blocked-recovery guidance line.
206
+ - Agent-authored free text (completion summaries, finding claims, round
207
+ summaries, blocked reasons, the last implementation scope's commit
208
+ subject): `boundFreeTextValues` shares
209
+ `AGENT_FREE_TEXT_SECTION_MAX_CHARS` (20,000 characters) across each fixed
210
+ group of values, markers included. The completion packet's completed-scope
211
+ summary, its scope-accounting summary, and the scope reviewer's recent
212
+ accepted-scope history are single strings capped at the same constant via
213
+ `truncateInlineSectionBody`.
214
+ - Changed-file lists: `boundChangedFileList` renders the first
215
+ `CHANGED_FILE_LIST_LIMIT` (20) paths and collapses the rest to a
216
+ `(+N more)` entry; the underlying arrays keep every path for non-prompt
217
+ consumers.
218
+ - Commit-subject lists (the aggregate completion range and the scope
219
+ review's commits-in-scope list): `boundCommitSubjectList` renders the
220
+ first `COMMIT_SUBJECT_LIST_LIMIT` (20) subjects under the shared free-text
221
+ budget and collapses the rest to a `(+N more)` entry.
222
+ - Git diff-stat blocks (aggregate completion range and scope review):
223
+ `GIT_SUMMARY_SECTION_MAX_CHARS` (20,000 characters) via
224
+ `truncateInlineSectionBody`.
225
+
226
+ Every bound is a module constant, not configuration. Three sections are
227
+ intentionally unbounded and rely on the provider input budget below as their
228
+ backstop: the inlined plan text, the progress text, and the review-findings
229
+ selected-range diff (`buildReviewFindingsInlinedDiffSection` in
230
+ `src/neal/review-findings/prompts.ts` inlines the full resolved-range diff
231
+ for read-only reviewers because it is the source of truth for what the range
232
+ changed — only the separate draft-prompt preview is capped, at
233
+ `DIFF_PREVIEW_LIMIT`). The `INLINE_SECTION_MAX_CHARS` cap applies to the
234
+ inlined range-diff sections built through `truncateInlineSectionBody`, not to
235
+ every diff that reaches a prompt.
236
+
237
+ **Providers with a hard limit reject oversized prompts before the SDK call.**
238
+ A capability role that declares `maxInputChars` gets an adapter-boundary
239
+ preflight on the exact text each turn sends; over-limit prompts fail fast
240
+ with a non-retryable `input_too_large` error naming the prompt size, the
241
+ limit, and the three largest `## ` sections. See
242
+ [providers.md](providers.md) for the capability field, the preflight
243
+ mechanics, and the error kind.
244
+
245
+ **Input-size failures are recoverable without state surgery.** There is no
246
+ sticky gate: the preflight re-measures the actual rebuilt prompt on every
247
+ attempt, so `neal resume` after the prompt shrinks proceeds normally, while
248
+ an unchanged oversized prompt fails fast before any provider call. While the
249
+ latest failure is `input_too_large`, `neal status` renders a conditional Next
250
+ Action: shrink the named largest input first (trim operator guidance files,
251
+ or upgrade neal so current prompt bounds apply on resume), then resume; if
252
+ the prompt can't fit under the limit, start a new run on a provider with a
253
+ larger or no declared limit, because per-run provider rebinding doesn't
254
+ exist.
255
+
178
256
  ## Provider variants
179
257
 
180
258
  Provider-specific variants are allowed, but they are not the default escape hatch. Each spec declares `providerVariants` for `shared` (status `default`) plus `openai-codex` and `anthropic-claude` (status `reserved_for_justified_divergence`).
@@ -283,6 +361,8 @@ testing or profile experiments. That override wins over the default directory.
283
361
 
284
362
  When present, the file contents are appended under a fixed `## User Guidance` section inside the built-in prompt. Structured output contracts, completion markers, and the canonical plan contract survive injection.
285
363
 
364
+ In the scope reviewer and final-completion reviewer prompts, `## User Guidance` renders after the `neal.review_level` calibration lines (`getReviewLevelCalibrationLines` in [src/neal/prompts/review-doctrine.ts](../src/neal/prompts/review-doctrine.ts)). The level supplies the baseline trust boundaries and the default finding-severity rules; guidance may widen or narrow those boundaries and demote or promote finding categories, and the refined boundaries are what "reachable" means when the reviewer decides whether a finding blocks. Guidance can't remove the floor: the reachability filter, the adversarial stance, and blocking on reachable correctness failures (including correctness regressions) stay on at every level. The calibration text itself states this merge rule, so the precedence is rendered in the prompt rather than implied by section order.
365
+
286
366
  Diagnostics: when a neal writer run initializes or resumes, it logs which roles have guidance applied and the byte count to the run's `stderr.log` and as a `run.user_guidance_applied` / `run.user_guidance_scanned` event. That is enough to confirm a guidance file was picked up without dumping contents.
287
367
 
288
368
  Non-goals: no repo-local `.neal/guidance/` override, no full-prompt replacement, no per-scope guidance variants, and no substitution of built-in sections.
package/docs/providers.md CHANGED
@@ -72,6 +72,7 @@ Each provider capability role declares:
72
72
 
73
73
  - whether the role is supported
74
74
  - read, write, and shell tool access
75
+ - an optional hard per-turn input limit in characters (`maxInputChars`)
75
76
  - session resume support
76
77
  - model override support
77
78
  - neal structured control protocol support
@@ -85,6 +86,16 @@ structured-advisor capability and read tool access: every reviewer inspects
85
86
  the repository directly. Session resume support is required when a persisted
86
87
  session handle is present.
87
88
 
89
+ A role that declares `maxInputChars` gets an input-budget preflight
90
+ (`src/neal/providers/input-budget.ts`) in the adapter on the exact text each
91
+ SDK turn sends: the bare prompt for plain turns, the protocol-wrapped prompt
92
+ for structured turns, and each generated repair prompt before its repair
93
+ thread is created. Every call site is covered without per-site wiring. A
94
+ prompt over the limit fails fast with a non-retryable `input_too_large` error
95
+ before any SDK call and without consuming API-retry budget; the error message
96
+ names the prompt size, the limit, and the three largest `## ` sections. Roles
97
+ without a declared limit skip the preflight.
98
+
88
99
  Current built-in capabilities are intentionally conservative:
89
100
 
90
101
  - OpenAI Codex supports coder and structured-advisor roles. The coder role is
@@ -92,6 +103,8 @@ Current built-in capabilities are intentionally conservative:
92
103
  broad local access. The structured-advisor (reviewer) role is read capable
93
104
  but never write or shell capable (see
94
105
  [The read-only reviewer invariant](#the-read-only-reviewer-invariant)).
106
+ Both roles declare `maxInputChars: 1,048,576` — the size at which Codex's
107
+ app-server rejects a turn with its `input_too_large` input-error code.
95
108
  - Anthropic Claude supports coder and structured-advisor roles. The coder role
96
109
  is read, write, and shell capable. The structured-advisor (reviewer) role is
97
110
  read capable but never write or shell capable.
@@ -402,6 +415,12 @@ Normalized error kinds are:
402
415
  - `structured_output_invalid`
403
416
  - `permission_denied`
404
417
  - `session_unavailable`
418
+ - `input_too_large` (the assembled prompt exceeds the role's declared
419
+ `maxInputChars`; thrown by the adapter's preflight before any SDK call, and
420
+ a provider-side over-limit rejection — Codex's `input_too_large`
421
+ input-error code — normalizes to the same kind. Always non-retryable; the
422
+ message names the prompt size, the limit, and the three largest `## `
423
+ sections)
405
424
  - `provider_failed`
406
425
  - `unknown`
407
426
 
@@ -79,6 +79,12 @@ Site A below). The consultant is read-only: it never grants authorization, expan
79
79
  scope, or waives verification gates. A recoverable verdict acts automatically. A
80
80
  non-recoverable verdict yields to the operator, carrying the verdict as advice.
81
81
 
82
+ Each recovery turn records its `origin` — `operator` for a `neal resume --message`
83
+ directive, `consultant` for a consultant injection — and the same marker rides the
84
+ turn-cap `pendingDirective`. A later-scope revision is offered only on an
85
+ operator-origin turn, so a consultant directive can direct the current scope but
86
+ never rewrites a later scope on its own.
87
+
82
88
  Public resume eligibility is classified by `src/neal/resume-decision.ts` before
83
89
  any recovery mutation. That read-only decision layer combines loaded child-run
84
90
  state with lock, queue, and retrospective evidence, then returns the shared
package/neal.yml CHANGED
@@ -40,7 +40,16 @@
40
40
  #
41
41
  # # Maximum number of times final completion review may send execution back
42
42
  # # for more work before neal stops reopening the plan.
43
- # final_completion_continue_execution_max: 2
43
+ # final_completion_continue_execution_max: 3
44
+ #
45
+ # # How strict the scope and final-completion reviewers are about what
46
+ # # rises to a blocking finding. Under every level a blocking finding must
47
+ # # describe a failure reachable under the assumed trust boundaries.
48
+ # # strict: assume adversarial trust boundaries; block on hardening gaps
49
+ # # moderate: ordinary trust boundaries; internal run artifacts are not
50
+ # # security boundaries; block on correctness and normal-use bugs
51
+ # # lenient: correctness and real, reachable bugs only
52
+ # review_level: moderate
44
53
  #
45
54
  # # Optional local notification command. Leave commented to keep
46
55
  # # notifications disabled.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@navels/neal",
3
- "version": "0.5.1",
3
+ "version": "0.6.1",
4
4
  "description": "A source-first multi-agent CLI for planning, executing, reviewing, and resuming scoped code changes.",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -56,8 +56,8 @@
56
56
  },
57
57
  "dependencies": {
58
58
  "@ai-sdk/openai-compatible": "3.0.34",
59
- "@anthropic-ai/claude-agent-sdk": "0.3.239",
60
- "@openai/codex-sdk": "0.149.0",
59
+ "@anthropic-ai/claude-agent-sdk": "0.3.246",
60
+ "@openai/codex-sdk": "0.149.1",
61
61
  "ai": "7.0.74",
62
62
  "dotenv": "^17.4.2",
63
63
  "yaml": "^2.9.0",