@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.
- package/README.md +53 -0
- package/dist/neal/adjudicator/execute.js +32 -6
- package/dist/neal/agents/prompts.js +19 -5
- package/dist/neal/agents/rounds.js +33 -4
- package/dist/neal/agents/schemas.js +34 -1
- package/dist/neal/commands/check.js +8 -1
- package/dist/neal/commands/resume-run.js +7 -1
- package/dist/neal/config.js +24 -1
- package/dist/neal/context/inline-review-context.js +116 -0
- package/dist/neal/final-completion.js +4 -7
- package/dist/neal/orchestrator/phases/planning.js +53 -8
- package/dist/neal/orchestrator/phases/recovery.js +99 -8
- package/dist/neal/plan-scope-revision.js +129 -0
- package/dist/neal/prompts/execute.js +28 -9
- package/dist/neal/prompts/guidance.js +8 -1
- package/dist/neal/prompts/planning.js +9 -3
- package/dist/neal/prompts/review-doctrine.js +39 -1
- package/dist/neal/prompts/specialized.js +87 -22
- package/dist/neal/prompts/specs.js +33 -5
- package/dist/neal/providers/input-budget.js +80 -0
- package/dist/neal/providers/openai-codex.js +69 -1
- package/dist/neal/review-findings/run.js +17 -3
- package/dist/neal/scopes.js +4 -4
- package/dist/neal/state-invariants.js +8 -2
- package/dist/neal/state.js +15 -0
- package/dist/neal/status.js +52 -1
- package/dist/neal/support.js +6 -2
- package/dist/neal/verification-events.js +49 -0
- package/docs/plan-format.md +10 -0
- package/docs/prompt-specs.md +80 -0
- package/docs/providers.md +19 -0
- package/docs/state-machine.md +6 -0
- package/neal.yml +10 -1
- package/package.json +3 -3
package/dist/neal/scopes.js
CHANGED
|
@@ -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
|
-
|
|
173
|
-
|
|
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);
|
package/dist/neal/state.js
CHANGED
|
@@ -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 = [
|
package/dist/neal/status.js
CHANGED
|
@@ -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];
|
package/dist/neal/support.js
CHANGED
|
@@ -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}
|
|
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.';
|
package/docs/plan-format.md
CHANGED
|
@@ -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`
|
package/docs/prompt-specs.md
CHANGED
|
@@ -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
|
|
package/docs/state-machine.md
CHANGED
|
@@ -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:
|
|
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.
|
|
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.
|
|
60
|
-
"@openai/codex-sdk": "0.149.
|
|
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",
|