@navels/neal 0.6.0 → 0.6.2
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/agents/prompts.js +9 -0
- package/dist/neal/agents/rounds.js +33 -4
- package/dist/neal/agents/schemas.js +34 -1
- package/dist/neal/commands/check.js +2 -1
- package/dist/neal/commands/resume-run.js +7 -1
- package/dist/neal/config.js +23 -0
- package/dist/neal/orchestrator/phases/recovery.js +99 -8
- package/dist/neal/plan-scope-revision.js +129 -0
- package/dist/neal/prompts/execute.js +4 -2
- package/dist/neal/prompts/review-doctrine.js +39 -1
- package/dist/neal/prompts/specialized.js +4 -2
- package/dist/neal/prompts/specs.js +19 -3
- package/dist/neal/review-findings/run.js +17 -3
- package/dist/neal/state-invariants.js +8 -2
- package/dist/neal/state.js +15 -0
- package/dist/neal/support.js +6 -2
- package/docs/plan-format.md +10 -0
- package/docs/prompt-specs.md +2 -0
- package/docs/state-machine.md +6 -0
- package/neal.yml +9 -0
- package/package.json +10 -10
package/README.md
CHANGED
|
@@ -453,6 +453,54 @@ agent:
|
|
|
453
453
|
effort: xhigh
|
|
454
454
|
```
|
|
455
455
|
|
|
456
|
+
### Review level
|
|
457
|
+
|
|
458
|
+
`neal.review_level` sets how strict the scope reviewer and the final-completion
|
|
459
|
+
reviewer are about what rises to a blocking finding. It takes one of three
|
|
460
|
+
values and defaults to `moderate`:
|
|
461
|
+
|
|
462
|
+
- `strict`: assume adversarial trust boundaries. Block on any failure reachable
|
|
463
|
+
under the worst case, including hardening gaps and missing defenses against
|
|
464
|
+
local or adversarial actors.
|
|
465
|
+
- `moderate`: ordinary trust boundaries. Internal run artifacts aren't security
|
|
466
|
+
boundaries. Block on correctness bugs and failures reachable under normal use;
|
|
467
|
+
don't require defenses against an actor who could already subvert the system.
|
|
468
|
+
- `lenient`: correctness and real, reachable bugs only. Minimal robustness,
|
|
469
|
+
style, or hardening demands.
|
|
470
|
+
|
|
471
|
+
```yaml
|
|
472
|
+
neal:
|
|
473
|
+
review_level: moderate
|
|
474
|
+
```
|
|
475
|
+
|
|
476
|
+
Set it in the repo's `neal.yml` or in `~/.neal/config.yml`; the repo value wins.
|
|
477
|
+
A blank or null value means unset and falls back to `moderate`. Any other
|
|
478
|
+
nonblank value (a typo, say) is rejected before any agent work: `neal check`
|
|
479
|
+
fails, every fresh writer command fails at config load, and a `neal resume`
|
|
480
|
+
that has selected a run and is about to resume writer work (plain, manual
|
|
481
|
+
gate, or `--message`) fails before it takes the writer lock, rewrites run
|
|
482
|
+
state, or starts an agent turn. Resume outcomes that never execute writer work
|
|
483
|
+
(already done, already running, waiting for operator guidance) are decided
|
|
484
|
+
first and don't validate the level.
|
|
485
|
+
|
|
486
|
+
Under every level a blocking finding has to describe a failure that's actually
|
|
487
|
+
reachable under the assumed trust boundaries, and the reviewer still treats the
|
|
488
|
+
change as hostile input and tries to falsify it before crediting it. A level
|
|
489
|
+
narrows what counts as blocking; it never means trust the coder or skip
|
|
490
|
+
inspection. The plan reviewer, the consultant, and `neal review` don't use the
|
|
491
|
+
level.
|
|
492
|
+
|
|
493
|
+
`~/.neal/guidance/reviewer.md` refines the level rather than replacing it. It
|
|
494
|
+
can widen or narrow the assumed trust boundaries ("we do defend the run
|
|
495
|
+
directory against local processes" makes a local-process attack on the run
|
|
496
|
+
directory blockable even at `moderate`) and it can demote or promote finding
|
|
497
|
+
categories ("ignore performance, correctness only" makes a performance
|
|
498
|
+
regression non-blocking at any level). It can't turn off the reachability
|
|
499
|
+
filter, the adversarial stance, or blocking on reachable correctness failures,
|
|
500
|
+
including correctness regressions. Guidance that conflicts with that floor is
|
|
501
|
+
ignored on that point. See [Custom guidance](#custom-guidance) for where the
|
|
502
|
+
file lives.
|
|
503
|
+
|
|
456
504
|
### Custom guidance
|
|
457
505
|
|
|
458
506
|
neal supports additive guidance files for local preferences alongside the built-in protocol prompts:
|
|
@@ -463,6 +511,11 @@ neal supports additive guidance files for local preferences alongside the built-
|
|
|
463
511
|
|
|
464
512
|
Set `NEAL_GUIDANCE_DIR=/path/to/guidance` to load those same `coder.md`, `reviewer.md`, and `planner.md` files from another directory. neal records applied guidance roles, selected paths, and byte counts in run artifacts. Guidance contents stay out of terminal output.
|
|
465
513
|
|
|
514
|
+
For the two code reviewers, `reviewer.md` layers on top of `neal.review_level`
|
|
515
|
+
(see [Review level](#review-level)): it can adjust trust boundaries and finding
|
|
516
|
+
categories, but it can't switch off the reachability filter or blocking on
|
|
517
|
+
reachable correctness failures.
|
|
518
|
+
|
|
466
519
|
## Artifacts and storage
|
|
467
520
|
|
|
468
521
|
Writer run artifacts live under `.neal/runs/<run-id>/`, including the original-plan backup at `.neal/runs/<run-id>/PLAN_ORIGINAL.md` and reviewer scratch space under `.neal/runs/<run-id>/scratch/`. Queue artifacts live under `.neal/queues/<queue-id>/`. Review findings artifacts live under `.neal/reviews/<review-id>/`.
|
|
@@ -50,6 +50,7 @@ export function buildConsultantPrompt(args) {
|
|
|
50
50
|
}
|
|
51
51
|
export function buildBlockedRecoveryCoderPrompt(args) {
|
|
52
52
|
const allowReplacement = args.allowReplacement ?? true;
|
|
53
|
+
const laterScopeRevision = args.terminalOnly ? null : args.laterScopeRevision ?? null;
|
|
53
54
|
const actionLines = [
|
|
54
55
|
'- `resume_current_scope`',
|
|
55
56
|
...(allowReplacement ? ['- `replace_current_scope`'] : []),
|
|
@@ -87,6 +88,14 @@ export function buildBlockedRecoveryCoderPrompt(args) {
|
|
|
87
88
|
allowReplacement
|
|
88
89
|
? 'Always include a `replacementPlan` string. Use an empty string unless action=`replace_current_scope`.'
|
|
89
90
|
: 'Always include an empty `replacementPlan` string.',
|
|
91
|
+
...(laterScopeRevision
|
|
92
|
+
? [
|
|
93
|
+
'Always include an integer `laterScopeNumber` and a `laterScopeBody` string. Use `0` and an empty string unless the operator guidance directs a change to a later top-level scope.',
|
|
94
|
+
`The operator guidance may direct a change to one later scope of the top-level plan at ${laterScopeRevision.topLevelPlanDoc}. The current top-level scope is ${laterScopeRevision.currentScopeNumber}; eligible target scopes are ${laterScopeRevision.currentScopeNumber + 1} through ${laterScopeRevision.scopeCount}.`,
|
|
95
|
+
'To revise a later scope, set `laterScopeNumber` to the target scope number and `laterScopeBody` to the complete replacement text of that one `### Scope N:` entry. The body must start with the line `### Scope N:` for the same N (the title after the colon may change), must contain no other `### ` or `## ` heading, and must keep the `- Goal:`, `- Verification:`, and `- Success Condition:` bullets.',
|
|
96
|
+
'A later-scope revision may accompany action=`resume_current_scope` or action=`stay_blocked` only. Set both fields or neither. Do not revise the current scope, an earlier scope, or a derived plan this way, and do not edit the plan file yourself: Neal validates the revised plan and writes it. Put the reasoning for the revision in `rationale`.',
|
|
97
|
+
]
|
|
98
|
+
: ['Always include `laterScopeNumber` as `0` and `laterScopeBody` as an empty string.']),
|
|
90
99
|
...(allowReplacement
|
|
91
100
|
? [
|
|
92
101
|
'When action=`replace_current_scope`, `replacementPlan` must use the same Neal-executable contract as a top-level plan.',
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readFile, writeFile } from 'node:fs/promises';
|
|
2
2
|
import { getAgentTurnRetryLimit, getAgentTurnStartupTimeoutMs, getApiRetryLimit, getInactivityTimeoutMs, } from '../config.js';
|
|
3
|
+
import { getReviewLevel } from '../config.js';
|
|
3
4
|
import { runWithAgentTurnLiveness } from '../providers/liveness.js';
|
|
4
5
|
import { normalizeExecutionShapeDeclaration } from '../plan-validation.js';
|
|
5
6
|
import { getCoderAdapter, getProviderDefinition, getStructuredAdvisorAdapter } from '../providers/registry.js';
|
|
@@ -176,7 +177,13 @@ export async function runReviewerRound(args) {
|
|
|
176
177
|
cwd: args.cwd,
|
|
177
178
|
// The doctrine access mode comes from the reviewer provider's declared
|
|
178
179
|
// structured-advisor tool access, not from inline-context presence alone.
|
|
179
|
-
|
|
180
|
+
// The review level comes from current config (`neal.review_level`), not
|
|
181
|
+
// persisted run state; the builder itself never reads config.
|
|
182
|
+
prompt: buildReviewerPrompt({
|
|
183
|
+
...args,
|
|
184
|
+
accessMode: getReviewerDoctrineAccessMode(args.reviewer),
|
|
185
|
+
reviewLevel: getReviewLevel(args.cwd),
|
|
186
|
+
}),
|
|
180
187
|
schema,
|
|
181
188
|
structuredJsonProtocol: buildStructuredJsonProtocolSpec({
|
|
182
189
|
schemaLabel: 'reviewer_payload',
|
|
@@ -334,7 +341,13 @@ export async function runReviewerFinalCompletionRound(args) {
|
|
|
334
341
|
cwd: args.cwd,
|
|
335
342
|
// The doctrine access mode comes from the reviewer provider's declared
|
|
336
343
|
// structured-advisor tool access, not from inline-context presence alone.
|
|
337
|
-
|
|
344
|
+
// The review level comes from current config (`neal.review_level`), not
|
|
345
|
+
// persisted run state; the builder itself never reads config.
|
|
346
|
+
prompt: buildFinalCompletionReviewerPrompt({
|
|
347
|
+
...args,
|
|
348
|
+
accessMode: getReviewerDoctrineAccessMode(args.reviewer),
|
|
349
|
+
reviewLevel: getReviewLevel(args.cwd),
|
|
350
|
+
}),
|
|
338
351
|
schema,
|
|
339
352
|
structuredJsonProtocol: buildStructuredJsonProtocolSpec({
|
|
340
353
|
schemaLabel: 'final_completion_reviewer_payload',
|
|
@@ -629,6 +642,15 @@ export async function runCoderResponseRound(args) {
|
|
|
629
642
|
export async function runBlockedRecoveryCoderRound(args) {
|
|
630
643
|
const progressText = await safeReadText(args.progressMarkdownPath);
|
|
631
644
|
const schema = buildCoderBlockedRecoveryDispositionSchema();
|
|
645
|
+
const laterScopeRevision = args.terminalOnly ? null : args.laterScopeRevision ?? null;
|
|
646
|
+
const laterScopeContext = laterScopeRevision
|
|
647
|
+
? {
|
|
648
|
+
allowLaterScopeRevision: true,
|
|
649
|
+
currentScopeNumber: laterScopeRevision.currentScopeNumber,
|
|
650
|
+
planDocument: laterScopeRevision.planDocument,
|
|
651
|
+
}
|
|
652
|
+
: null;
|
|
653
|
+
const validator = (rawPayload) => validateCoderBlockedRecoveryDispositionPayload(rawPayload, laterScopeContext);
|
|
632
654
|
const { sessionHandle, structured } = await runCoderStructuredPrompt({
|
|
633
655
|
coder: args.coder,
|
|
634
656
|
cwd: args.cwd,
|
|
@@ -642,20 +664,27 @@ export async function runBlockedRecoveryCoderRound(args) {
|
|
|
642
664
|
turnsTaken: args.turnsTaken,
|
|
643
665
|
terminalOnly: args.terminalOnly,
|
|
644
666
|
allowReplacement: args.allowReplacement,
|
|
667
|
+
laterScopeRevision: laterScopeRevision
|
|
668
|
+
? {
|
|
669
|
+
topLevelPlanDoc: laterScopeRevision.topLevelPlanDoc,
|
|
670
|
+
currentScopeNumber: laterScopeRevision.currentScopeNumber,
|
|
671
|
+
scopeCount: laterScopeRevision.scopeCount,
|
|
672
|
+
}
|
|
673
|
+
: null,
|
|
645
674
|
}),
|
|
646
675
|
schema,
|
|
647
676
|
label: 'Coder blocked-recovery round',
|
|
648
677
|
structuredJsonProtocol: buildStructuredJsonProtocolSpec({
|
|
649
678
|
schemaLabel: 'coder_blocked_recovery_disposition_payload',
|
|
650
679
|
schema,
|
|
651
|
-
validator
|
|
680
|
+
validator,
|
|
652
681
|
}),
|
|
653
682
|
resumeHandle: args.sessionHandle,
|
|
654
683
|
logger: args.logger,
|
|
655
684
|
});
|
|
656
685
|
return {
|
|
657
686
|
sessionHandle,
|
|
658
|
-
payload:
|
|
687
|
+
payload: validator(structured),
|
|
659
688
|
};
|
|
660
689
|
}
|
|
661
690
|
export async function runCoderPlanResponseRound(args) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
+
import { reviseLaterScope } from '../plan-scope-revision.js';
|
|
2
3
|
import { normalizeExecutionShapeDeclaration, validatePlanDocument } from '../plan-validation.js';
|
|
3
4
|
import { repairReviewerSquashMessageDraft, validateReviewerSquashMessageDraft } from '../squash-message.js';
|
|
4
5
|
export const EXECUTE_SCOPE_PROGRESS_PAYLOAD_START = 'NEAL_PROGRESS_JUSTIFICATION_JSON_START';
|
|
@@ -101,6 +102,8 @@ const coderBlockedRecoveryDispositionPayloadSchema = z.object({
|
|
|
101
102
|
rationale: z.string(),
|
|
102
103
|
blocker: z.string(),
|
|
103
104
|
replacementPlan: z.string(),
|
|
105
|
+
laterScopeNumber: z.number(),
|
|
106
|
+
laterScopeBody: z.string(),
|
|
104
107
|
});
|
|
105
108
|
const coderPlanResponsePayloadSchema = z.object({
|
|
106
109
|
outcome: z.enum(CODER_PLAN_RESPONSE_OUTCOMES),
|
|
@@ -823,7 +826,33 @@ function validateManualGateResumeChecks(value) {
|
|
|
823
826
|
};
|
|
824
827
|
});
|
|
825
828
|
}
|
|
826
|
-
export function
|
|
829
|
+
export function getCoderBlockedRecoveryLaterScopeErrors(payload, context) {
|
|
830
|
+
const hasNumber = payload.laterScopeNumber !== 0;
|
|
831
|
+
const hasBody = payload.laterScopeBody.trim().length > 0;
|
|
832
|
+
if (!hasNumber && !hasBody) {
|
|
833
|
+
return [];
|
|
834
|
+
}
|
|
835
|
+
if (!Number.isInteger(payload.laterScopeNumber) || payload.laterScopeNumber < 0) {
|
|
836
|
+
return [`laterScopeNumber must be a non-negative integer, received ${String(payload.laterScopeNumber)}.`];
|
|
837
|
+
}
|
|
838
|
+
if (hasNumber !== hasBody) {
|
|
839
|
+
return ['laterScopeNumber and laterScopeBody must be set together: both for a later-scope revision, or 0 and an empty string.'];
|
|
840
|
+
}
|
|
841
|
+
if (context === null || !context.allowLaterScopeRevision) {
|
|
842
|
+
return ['A later-scope revision is not available for this round; return laterScopeNumber=0 and an empty laterScopeBody.'];
|
|
843
|
+
}
|
|
844
|
+
if (payload.action !== 'resume_current_scope' && payload.action !== 'stay_blocked') {
|
|
845
|
+
return [`A later-scope revision may accompany only action=resume_current_scope or action=stay_blocked, not action=${payload.action}.`];
|
|
846
|
+
}
|
|
847
|
+
const result = reviseLaterScope({
|
|
848
|
+
planDocument: context.planDocument,
|
|
849
|
+
currentScopeNumber: context.currentScopeNumber,
|
|
850
|
+
targetScopeNumber: payload.laterScopeNumber,
|
|
851
|
+
replacementBody: payload.laterScopeBody,
|
|
852
|
+
});
|
|
853
|
+
return result.ok ? [] : result.errors;
|
|
854
|
+
}
|
|
855
|
+
export function validateCoderBlockedRecoveryDispositionPayload(rawPayload, laterScopeContext = null) {
|
|
827
856
|
const payload = parsePayload(coderBlockedRecoveryDispositionPayloadSchema, rawPayload, 'Coder blocked-recovery payload');
|
|
828
857
|
const blocker = payload.blocker.trim();
|
|
829
858
|
const replacementPlan = payload.replacementPlan.trim();
|
|
@@ -836,6 +865,10 @@ export function validateCoderBlockedRecoveryDispositionPayload(rawPayload) {
|
|
|
836
865
|
if ((payload.action === 'stay_blocked' || payload.action === 'terminal_block') && !blocker) {
|
|
837
866
|
throw new Error(`Coder blocked-recovery round returned action=${payload.action} without a blocker payload.`);
|
|
838
867
|
}
|
|
868
|
+
const laterScopeErrors = getCoderBlockedRecoveryLaterScopeErrors(payload, laterScopeContext);
|
|
869
|
+
if (laterScopeErrors.length > 0) {
|
|
870
|
+
throw new Error(`Coder blocked-recovery round returned an invalid later-scope revision: ${laterScopeErrors.join(' ')}`);
|
|
871
|
+
}
|
|
839
872
|
return payload;
|
|
840
873
|
}
|
|
841
874
|
export function parseFinalCompletionSummaryPayload(rawPayload) {
|
|
@@ -2,7 +2,7 @@ import { createInterface } from 'node:readline/promises';
|
|
|
2
2
|
import process from 'node:process';
|
|
3
3
|
import { verifyNotification } from '../../notifier.js';
|
|
4
4
|
import { parseCheckArgs } from '../cli.js';
|
|
5
|
-
import { assertWriterProvidersConfigured, getAgentTurnRetryLimit, getAgentTurnStartupTimeoutMs, getApiRetryLimit, getFinalCompletionContinueExecutionMax, getInactivityTimeoutMs, getInteractiveBlockedRecoveryMaxTurns, getMaxReviewRounds, getNotifyBin, getPhaseHeartbeatMs, getReviewStuckWindow, } from '../config.js';
|
|
5
|
+
import { assertWriterProvidersConfigured, getAgentTurnRetryLimit, getAgentTurnStartupTimeoutMs, getApiRetryLimit, getFinalCompletionContinueExecutionMax, getInactivityTimeoutMs, getInteractiveBlockedRecoveryMaxTurns, getMaxReviewRounds, getNotifyBin, getPhaseHeartbeatMs, getReviewLevel, getReviewStuckWindow, } from '../config.js';
|
|
6
6
|
import { getNealDirGitIgnoreStatus } from '../git.js';
|
|
7
7
|
import { collectGuidanceDiagnostics, USER_GUIDANCE_MAX_CHARS } from '../prompts/guidance.js';
|
|
8
8
|
import { runWithAgentTurnLiveness } from '../providers/liveness.js';
|
|
@@ -65,6 +65,7 @@ function validateConfig(cwd) {
|
|
|
65
65
|
getInteractiveBlockedRecoveryMaxTurns(cwd);
|
|
66
66
|
getFinalCompletionContinueExecutionMax(cwd);
|
|
67
67
|
getNotifyBin(cwd);
|
|
68
|
+
getReviewLevel(cwd);
|
|
68
69
|
return agentConfig;
|
|
69
70
|
}
|
|
70
71
|
async function promptForProviderVerification(stdin, stdout) {
|
|
@@ -2,7 +2,7 @@ import process from 'node:process';
|
|
|
2
2
|
import { readdir, stat } from 'node:fs/promises';
|
|
3
3
|
import { basename, join } from 'node:path';
|
|
4
4
|
import { parseResumeArgs } from '../cli.js';
|
|
5
|
-
import { assertWriterProvidersConfigured } from '../config.js';
|
|
5
|
+
import { assertWriterProvidersConfigured, getReviewLevel } from '../config.js';
|
|
6
6
|
import { writeDiagnostic } from '../diagnostic.js';
|
|
7
7
|
import { assertGitRepositoryWithCommit } from '../git.js';
|
|
8
8
|
import { loadOrInitialize } from '../orchestrator.js';
|
|
@@ -261,6 +261,12 @@ function emitAlreadyRunningOutcome(selection) {
|
|
|
261
261
|
return { kind: 'already_running' };
|
|
262
262
|
}
|
|
263
263
|
async function withResumeWriterLock(selection, evidence, action) {
|
|
264
|
+
// Every path that resumes writer work (plain, manual gate, --message) goes
|
|
265
|
+
// through this seam. The review level is read from current config, not
|
|
266
|
+
// persisted run state, so it is validated here before the lock is taken or
|
|
267
|
+
// any run state is rewritten. No-op and rejection outcomes (done, already
|
|
268
|
+
// running, waiting for guidance) are decided earlier and never reach it.
|
|
269
|
+
getReviewLevel(selection.state.cwd);
|
|
264
270
|
let lock;
|
|
265
271
|
try {
|
|
266
272
|
lock = await acquireResumeWriterLock(selection, evidence);
|
package/dist/neal/config.js
CHANGED
|
@@ -3,6 +3,7 @@ import { homedir } from 'node:os';
|
|
|
3
3
|
import { join, resolve } from 'node:path';
|
|
4
4
|
import YAML from 'yaml';
|
|
5
5
|
import { assertAgentConfigSupportsWriterRun, parseProviderId, } from './providers/registry.js';
|
|
6
|
+
const REVIEW_LEVELS = ['strict', 'moderate', 'lenient'];
|
|
6
7
|
const OPENAI_COMPATIBLE_DEFAULT_API_KEY_ENV = 'OPENAI_COMPATIBLE_API_KEY';
|
|
7
8
|
const WRITER_PROVIDER_CONFIG_KEYS = {
|
|
8
9
|
coder: 'agent.coder.provider',
|
|
@@ -22,6 +23,7 @@ const DEFAULT_CONFIG = {
|
|
|
22
23
|
final_completion_continue_execution_max: 3,
|
|
23
24
|
consultant_max_attempts: 1,
|
|
24
25
|
notify_bin: null,
|
|
26
|
+
review_level: 'moderate',
|
|
25
27
|
},
|
|
26
28
|
agent: {
|
|
27
29
|
planner: {
|
|
@@ -101,6 +103,22 @@ function parseNumberValue(value) {
|
|
|
101
103
|
function parseStringValue(value) {
|
|
102
104
|
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
|
103
105
|
}
|
|
106
|
+
function isReviewLevel(value) {
|
|
107
|
+
return REVIEW_LEVELS.includes(value);
|
|
108
|
+
}
|
|
109
|
+
function parseReviewLevelValue(value, fieldPath) {
|
|
110
|
+
if (value === undefined || value === null) {
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
113
|
+
if (typeof value === 'string' && !value.trim()) {
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|
|
116
|
+
const level = typeof value === 'string' ? value.trim() : value;
|
|
117
|
+
if (typeof level !== 'string' || !isReviewLevel(level)) {
|
|
118
|
+
throw new Error(`Invalid review level for ${fieldPath}: ${JSON.stringify(level)}. Valid values: ${REVIEW_LEVELS.join(', ')}`);
|
|
119
|
+
}
|
|
120
|
+
return level;
|
|
121
|
+
}
|
|
104
122
|
function parseConfigProviderValue(value, fieldPath) {
|
|
105
123
|
if (value === undefined || value === null) {
|
|
106
124
|
return undefined;
|
|
@@ -255,8 +273,13 @@ export function assertWriterProvidersConfigured(cwd = process.cwd(), options = {
|
|
|
255
273
|
throw new WriterProvidersNotConfiguredError(options.guidance ?? 'writer-run', missingProviderKeys);
|
|
256
274
|
}
|
|
257
275
|
assertAgentConfigSupportsWriterRun(agentConfig, { context: options.context });
|
|
276
|
+
getReviewLevel(cwd);
|
|
258
277
|
return agentConfig;
|
|
259
278
|
}
|
|
279
|
+
export function getReviewLevel(cwd = process.cwd()) {
|
|
280
|
+
const config = loadConfigFile(cwd);
|
|
281
|
+
return parseReviewLevelValue(config.neal?.review_level, 'neal.review_level') ?? DEFAULT_CONFIG.neal.review_level;
|
|
282
|
+
}
|
|
260
283
|
export function getInactivityTimeoutMs(cwd = process.cwd()) {
|
|
261
284
|
const config = loadConfigFile(cwd);
|
|
262
285
|
return (parseNumberValue(config.neal?.inactivity_timeout_ms) ??
|
|
@@ -1,11 +1,14 @@
|
|
|
1
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
1
2
|
import { CoderRoundError, runBlockedRecoveryCoderRound, } from '../../agents.js';
|
|
2
3
|
import { CONSULTANT_ELIGIBLE_SOURCE_PHASES, buildRecentBlockCandidate, isReviewerConsultantPhase, runConsultant, upsertRecentBlock, } from '../../adjudicator/consultant.js';
|
|
3
4
|
import { getInteractiveBlockedRecoveryMaxTurns, getConsultantMaxAttempts } from '../../config.js';
|
|
4
5
|
import { EXECUTE_FINALIZATION_PHASE } from '../../execute-finalization.js';
|
|
6
|
+
import { getLaterScopeRevisionEligibility, reviseLaterScope } from '../../plan-scope-revision.js';
|
|
5
7
|
import { hasPendingOperatorGuidance } from '../../run-status.js';
|
|
6
8
|
import { getExecutionPlanPath } from '../../scopes.js';
|
|
7
9
|
import { loadState, saveState } from '../../state.js';
|
|
8
10
|
import { getInteractiveRecoveryView, isActivePendingDerivedPlanReview } from '../../state-views.js';
|
|
11
|
+
import { getCoderBlockedRecoveryLaterScopeErrors } from '../../agents/schemas.js';
|
|
9
12
|
import { writeExecutionArtifacts } from '../artifacts.js';
|
|
10
13
|
import { isCoderTimeoutError, shouldNotifyFailure } from '../failures.js';
|
|
11
14
|
import { flushDerivedPlanNotifications, notifyBlocked } from '../notifications.js';
|
|
@@ -144,8 +147,8 @@ async function buildConsultantAdvice(state, reason, sourcePhase, logger) {
|
|
|
144
147
|
}
|
|
145
148
|
// Applies a recoverable consultant verdict. Enters interactive recovery and
|
|
146
149
|
// injects the consultant's in-scope directive as the pending turn, exactly like
|
|
147
|
-
// a human-supplied `neal resume --message
|
|
148
|
-
// run continues. Consumes one unit of the per-scope consultant budget
|
|
150
|
+
// a human-supplied `neal resume --message` except that the turn's `origin` is
|
|
151
|
+
// `consultant`, so the coder consumes it and the run continues. Consumes one unit of the per-scope consultant budget
|
|
149
152
|
// (`consultantAttemptCount`) and persists the anti-thrash `recentBlocks`. The
|
|
150
153
|
// caller has already emitted the `consultant.verdict` audit event for this
|
|
151
154
|
// verdict.
|
|
@@ -170,7 +173,7 @@ async function applyRecoverableConsultantDirective(args) {
|
|
|
170
173
|
sourcePhase: enteredState.interactiveBlockedRecovery?.sourcePhase,
|
|
171
174
|
blockedReason: reason,
|
|
172
175
|
});
|
|
173
|
-
const resolvedState = await
|
|
176
|
+
const resolvedState = await recordInteractiveBlockedRecoveryTurn(statePath, resolutionDirective, 'consultant', logger);
|
|
174
177
|
await logger?.event('consultant.resolved', {
|
|
175
178
|
scopeNumber: resolvedState.currentScopeNumber,
|
|
176
179
|
sourcePhase,
|
|
@@ -253,6 +256,11 @@ export function shouldNotifyInteractiveBlockedRecoveryEntry(state) {
|
|
|
253
256
|
return getInteractiveRecoveryView(state)?.waitingForOperatorGuidance ?? false;
|
|
254
257
|
}
|
|
255
258
|
export async function recordInteractiveBlockedRecoveryGuidance(statePath, operatorGuidance, logger) {
|
|
259
|
+
return recordInteractiveBlockedRecoveryTurn(statePath, operatorGuidance, 'operator', logger);
|
|
260
|
+
}
|
|
261
|
+
// Shared by operator guidance (`neal resume --message`) and the consultant
|
|
262
|
+
// injection path; `origin` marks the turn with whichever created it.
|
|
263
|
+
async function recordInteractiveBlockedRecoveryTurn(statePath, operatorGuidance, origin, logger) {
|
|
256
264
|
const trimmedGuidance = operatorGuidance.trim();
|
|
257
265
|
if (!trimmedGuidance) {
|
|
258
266
|
throw new Error('Recovery guidance must not be empty');
|
|
@@ -278,6 +286,7 @@ export async function recordInteractiveBlockedRecoveryGuidance(statePath, operat
|
|
|
278
286
|
recordedAt: new Date().toISOString(),
|
|
279
287
|
operatorGuidance: trimmedGuidance,
|
|
280
288
|
terminalOnly: true,
|
|
289
|
+
origin,
|
|
281
290
|
},
|
|
282
291
|
},
|
|
283
292
|
});
|
|
@@ -299,6 +308,7 @@ export async function recordInteractiveBlockedRecoveryGuidance(statePath, operat
|
|
|
299
308
|
number: turns.length + 1,
|
|
300
309
|
recordedAt: new Date().toISOString(),
|
|
301
310
|
operatorGuidance: trimmedGuidance,
|
|
311
|
+
origin,
|
|
302
312
|
disposition: null,
|
|
303
313
|
},
|
|
304
314
|
],
|
|
@@ -339,6 +349,7 @@ function withRecordedInteractiveBlockedRecoveryDisposition(state, disposition, s
|
|
|
339
349
|
number: state.interactiveBlockedRecovery.turns.length + 1,
|
|
340
350
|
recordedAt: state.interactiveBlockedRecovery.pendingDirective.recordedAt,
|
|
341
351
|
operatorGuidance: state.interactiveBlockedRecovery.pendingDirective.operatorGuidance,
|
|
352
|
+
origin: state.interactiveBlockedRecovery.pendingDirective.origin,
|
|
342
353
|
disposition: {
|
|
343
354
|
recordedAt: new Date().toISOString(),
|
|
344
355
|
sessionHandle,
|
|
@@ -347,6 +358,8 @@ function withRecordedInteractiveBlockedRecoveryDisposition(state, disposition, s
|
|
|
347
358
|
rationale: disposition.rationale,
|
|
348
359
|
blocker: disposition.blocker.trim(),
|
|
349
360
|
replacementPlan: disposition.replacementPlan.trim(),
|
|
361
|
+
laterScopeNumber: disposition.laterScopeNumber,
|
|
362
|
+
laterScopeBody: disposition.laterScopeBody,
|
|
350
363
|
resultingPhase,
|
|
351
364
|
},
|
|
352
365
|
},
|
|
@@ -373,6 +386,8 @@ function withRecordedInteractiveBlockedRecoveryDisposition(state, disposition, s
|
|
|
373
386
|
rationale: disposition.rationale,
|
|
374
387
|
blocker: disposition.blocker.trim(),
|
|
375
388
|
replacementPlan: disposition.replacementPlan.trim(),
|
|
389
|
+
laterScopeNumber: disposition.laterScopeNumber,
|
|
390
|
+
laterScopeBody: disposition.laterScopeBody,
|
|
376
391
|
resultingPhase,
|
|
377
392
|
},
|
|
378
393
|
}
|
|
@@ -422,6 +437,78 @@ function getInteractiveBlockedRecoveryResumePhase(sourcePhase) {
|
|
|
422
437
|
return sourcePhase;
|
|
423
438
|
}
|
|
424
439
|
}
|
|
440
|
+
// Reads the top-level plan (always `state.planDoc`, even while a derived plan
|
|
441
|
+
// is executing) at the time the disposition is applied, splices the revised
|
|
442
|
+
// scope entry in, and writes the file. The plan is read fresh from disk on
|
|
443
|
+
// every later turn, so nothing in state needs to change.
|
|
444
|
+
async function applyLaterScopeRevision(state, disposition, logger) {
|
|
445
|
+
if (disposition.laterScopeNumber === 0 && disposition.laterScopeBody.trim() === '') {
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
if (!isOperatorGuidedRecoveryTurn(state)) {
|
|
449
|
+
throw new Error('Interactive blocked recovery cannot apply the later-scope revision: only operator guidance may direct a later-scope revision, and the pending guidance is not an operator message.');
|
|
450
|
+
}
|
|
451
|
+
const planDocument = await readFile(state.planDoc, 'utf8');
|
|
452
|
+
const errors = getCoderBlockedRecoveryLaterScopeErrors(disposition, {
|
|
453
|
+
allowLaterScopeRevision: true,
|
|
454
|
+
currentScopeNumber: state.currentScopeNumber,
|
|
455
|
+
planDocument,
|
|
456
|
+
});
|
|
457
|
+
if (errors.length > 0) {
|
|
458
|
+
throw new Error(`Interactive blocked recovery cannot apply the later-scope revision: ${errors.join(' ')}`);
|
|
459
|
+
}
|
|
460
|
+
const result = reviseLaterScope({
|
|
461
|
+
planDocument,
|
|
462
|
+
currentScopeNumber: state.currentScopeNumber,
|
|
463
|
+
targetScopeNumber: disposition.laterScopeNumber,
|
|
464
|
+
replacementBody: disposition.laterScopeBody,
|
|
465
|
+
});
|
|
466
|
+
if (!result.ok) {
|
|
467
|
+
throw new Error(`Interactive blocked recovery cannot apply the later-scope revision: ${result.errors.join(' ')}`);
|
|
468
|
+
}
|
|
469
|
+
await writeFile(state.planDoc, result.document, 'utf8');
|
|
470
|
+
await logger?.event('interactive_blocked_recovery.later_scope_revised', {
|
|
471
|
+
scopeNumber: state.currentScopeNumber,
|
|
472
|
+
laterScopeNumber: disposition.laterScopeNumber,
|
|
473
|
+
planDoc: state.planDoc,
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
// Whether the pending guidance is an operator message. Only an operator message
|
|
477
|
+
// may direct a later-scope revision. The turn's `origin` marker is set where the
|
|
478
|
+
// turn is created: `operator` by `neal resume --message`, `consultant` by the
|
|
479
|
+
// consultant-injection path. A turn persisted without the marker, and the
|
|
480
|
+
// operator's turn-cap directive (which cannot choose an action that carries a
|
|
481
|
+
// revision anyway), do not qualify.
|
|
482
|
+
function isOperatorGuidedRecoveryTurn(state) {
|
|
483
|
+
const recovery = state.interactiveBlockedRecovery;
|
|
484
|
+
if (!recovery || recovery.pendingDirective) {
|
|
485
|
+
return false;
|
|
486
|
+
}
|
|
487
|
+
const latestTurn = recovery.turns.at(-1);
|
|
488
|
+
return latestTurn !== undefined && latestTurn.number > recovery.lastHandledTurn && latestTurn.origin === 'operator';
|
|
489
|
+
}
|
|
490
|
+
async function getLaterScopeRevisionOffer(state, terminalOnly) {
|
|
491
|
+
if (terminalOnly || !isOperatorGuidedRecoveryTurn(state)) {
|
|
492
|
+
return null;
|
|
493
|
+
}
|
|
494
|
+
let planDocument;
|
|
495
|
+
try {
|
|
496
|
+
planDocument = await readFile(state.planDoc, 'utf8');
|
|
497
|
+
}
|
|
498
|
+
catch {
|
|
499
|
+
return null;
|
|
500
|
+
}
|
|
501
|
+
const eligibility = getLaterScopeRevisionEligibility(planDocument, state.currentScopeNumber);
|
|
502
|
+
if (!eligibility.eligible) {
|
|
503
|
+
return null;
|
|
504
|
+
}
|
|
505
|
+
return {
|
|
506
|
+
topLevelPlanDoc: state.planDoc,
|
|
507
|
+
planDocument,
|
|
508
|
+
currentScopeNumber: state.currentScopeNumber,
|
|
509
|
+
scopeCount: eligibility.scopeCount,
|
|
510
|
+
};
|
|
511
|
+
}
|
|
425
512
|
export async function applyInteractiveBlockedRecoveryDisposition(state, statePath, disposition, sessionHandle, logger) {
|
|
426
513
|
if (state.phase !== 'interactive_blocked_recovery' || !state.interactiveBlockedRecovery) {
|
|
427
514
|
throw new Error(`Run is not in interactive blocked recovery: ${statePath}`);
|
|
@@ -430,11 +517,11 @@ export async function applyInteractiveBlockedRecoveryDisposition(state, statePat
|
|
|
430
517
|
throw new Error('Interactive blocked recovery is only supported for execute-mode runs');
|
|
431
518
|
}
|
|
432
519
|
const latestTurn = state.interactiveBlockedRecovery.turns.at(-1);
|
|
433
|
-
const
|
|
434
|
-
if (!latestTurn && !
|
|
520
|
+
const pendingDirective = state.interactiveBlockedRecovery.pendingDirective;
|
|
521
|
+
if (!latestTurn && !pendingDirective) {
|
|
435
522
|
throw new Error('Interactive blocked recovery requires recorded operator guidance before a coder response can be applied.');
|
|
436
523
|
}
|
|
437
|
-
if (
|
|
524
|
+
if (pendingDirective &&
|
|
438
525
|
disposition.action !== 'replace_current_scope' &&
|
|
439
526
|
disposition.action !== 'terminal_block') {
|
|
440
527
|
throw new Error('Interactive blocked recovery reached its turn cap and now only allows replace_current_scope or terminal_block.');
|
|
@@ -448,6 +535,7 @@ export async function applyInteractiveBlockedRecoveryDisposition(state, statePat
|
|
|
448
535
|
action: disposition.action,
|
|
449
536
|
sessionHandle,
|
|
450
537
|
});
|
|
538
|
+
await applyLaterScopeRevision(state, disposition, logger);
|
|
451
539
|
if (disposition.action === 'replace_current_scope') {
|
|
452
540
|
const persistedState = await persistSplitPlanRecovery({
|
|
453
541
|
...state,
|
|
@@ -541,12 +629,14 @@ export async function runInteractiveBlockedRecoveryPhase(state, statePath, logge
|
|
|
541
629
|
if (!hasPendingTurn && !pendingDirective) {
|
|
542
630
|
throw new Error('Interactive blocked recovery has no pending operator guidance to process.');
|
|
543
631
|
}
|
|
632
|
+
const terminalOnly = Boolean(pendingDirective?.terminalOnly);
|
|
544
633
|
await logger?.event('phase.start', {
|
|
545
634
|
phase: 'interactive_blocked_recovery',
|
|
546
635
|
recoveryTurn: pendingDirective ? state.interactiveBlockedRecovery.turns.length : latestTurn?.number,
|
|
547
636
|
sourcePhase: state.interactiveBlockedRecovery.sourcePhase,
|
|
548
|
-
terminalOnly
|
|
637
|
+
terminalOnly,
|
|
549
638
|
});
|
|
639
|
+
const laterScopeRevision = await getLaterScopeRevisionOffer(state, terminalOnly);
|
|
550
640
|
let codex;
|
|
551
641
|
try {
|
|
552
642
|
codex = await runBlockedRecoveryCoderRound({
|
|
@@ -559,8 +649,9 @@ export async function runInteractiveBlockedRecoveryPhase(state, statePath, logge
|
|
|
559
649
|
operatorGuidance: pendingDirective?.operatorGuidance ?? latestTurn?.operatorGuidance ?? state.interactiveBlockedRecovery.blockedReason,
|
|
560
650
|
maxTurns: state.interactiveBlockedRecovery.maxTurns,
|
|
561
651
|
turnsTaken: pendingDirective ? state.interactiveBlockedRecovery.turns.length : latestTurn?.number ?? 0,
|
|
562
|
-
terminalOnly
|
|
652
|
+
terminalOnly,
|
|
563
653
|
allowReplacement: true,
|
|
654
|
+
laterScopeRevision,
|
|
564
655
|
sessionHandle: state.coderSessionHandle,
|
|
565
656
|
logger,
|
|
566
657
|
});
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { validatePlanDocument } from './plan-validation.js';
|
|
2
|
+
const EXECUTION_QUEUE_HEADER = '## Execution Queue';
|
|
3
|
+
const SCOPE_HEADING_PATTERN = /^### Scope (\d+):/;
|
|
4
|
+
const SECTION_HEADING_PATTERN = /^##(#)? /;
|
|
5
|
+
/**
|
|
6
|
+
* Decides whether operator guidance may revise a later top-level scope in
|
|
7
|
+
* `planDocument`: the document validates, its shape is `multi_scope`, it is
|
|
8
|
+
* already in canonical `## Execution Queue` / `### Scope N:` form on disk, and
|
|
9
|
+
* it has at least one scope after `currentScopeNumber`.
|
|
10
|
+
*/
|
|
11
|
+
export function getLaterScopeRevisionEligibility(planDocument, currentScopeNumber) {
|
|
12
|
+
const reasons = collectCanonicalMultiScopeErrors(planDocument);
|
|
13
|
+
const scopeCount = reasons.length === 0 ? collectScopeEntries(planDocument.split('\n')).length : 0;
|
|
14
|
+
if (reasons.length === 0 && scopeCount <= currentScopeNumber) {
|
|
15
|
+
reasons.push(`The plan has ${scopeCount} scope(s) and the current scope is ${currentScopeNumber}, so there is no later scope to revise.`);
|
|
16
|
+
}
|
|
17
|
+
return { eligible: reasons.length === 0, scopeCount, reasons };
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Replaces exactly one later `### Scope N:` entry in `planDocument` with
|
|
21
|
+
* `replacementBody`. The splice is bounded by the next `### `/`## ` heading
|
|
22
|
+
* (or end of file), so nothing outside the target entry changes. The revised
|
|
23
|
+
* document must still validate with the same scope count.
|
|
24
|
+
*/
|
|
25
|
+
export function reviseLaterScope(input) {
|
|
26
|
+
const { planDocument, currentScopeNumber, targetScopeNumber, replacementBody } = input;
|
|
27
|
+
const errors = collectCanonicalMultiScopeErrors(planDocument);
|
|
28
|
+
if (errors.length > 0) {
|
|
29
|
+
return { ok: false, errors };
|
|
30
|
+
}
|
|
31
|
+
const lines = planDocument.split('\n');
|
|
32
|
+
const entries = collectScopeEntries(lines);
|
|
33
|
+
const scopeCount = entries.length;
|
|
34
|
+
if (!Number.isInteger(targetScopeNumber) || targetScopeNumber <= currentScopeNumber) {
|
|
35
|
+
errors.push(`Target scope ${targetScopeNumber} must be a later scope than the current scope ${currentScopeNumber}.`);
|
|
36
|
+
}
|
|
37
|
+
if (Number.isInteger(targetScopeNumber) && targetScopeNumber > scopeCount) {
|
|
38
|
+
errors.push(`Target scope ${targetScopeNumber} is past the plan's scope count of ${scopeCount}.`);
|
|
39
|
+
}
|
|
40
|
+
errors.push(...collectReplacementBodyErrors(replacementBody, targetScopeNumber));
|
|
41
|
+
if (errors.length > 0) {
|
|
42
|
+
return { ok: false, errors };
|
|
43
|
+
}
|
|
44
|
+
const target = entries.find((entry) => entry.number === targetScopeNumber);
|
|
45
|
+
if (target === undefined) {
|
|
46
|
+
return { ok: false, errors: [`Target scope ${targetScopeNumber} has no \`### Scope ${targetScopeNumber}:\` heading in the plan.`] };
|
|
47
|
+
}
|
|
48
|
+
const bodyLines = trimTrailingBlankLines(replacementBody.split('\n'));
|
|
49
|
+
const contentEnd = target.start + trimTrailingBlankLines(lines.slice(target.start, target.end)).length;
|
|
50
|
+
const revisedLines = [...lines.slice(0, target.start), ...bodyLines, ...lines.slice(contentEnd)];
|
|
51
|
+
const document = revisedLines.join('\n');
|
|
52
|
+
const validation = validatePlanDocument(document);
|
|
53
|
+
if (!validation.ok) {
|
|
54
|
+
return { ok: false, errors: validation.errors.map((error) => `Revised plan does not validate: ${error}`) };
|
|
55
|
+
}
|
|
56
|
+
const revisedCount = collectScopeEntries(document.split('\n')).length;
|
|
57
|
+
if (revisedCount !== scopeCount) {
|
|
58
|
+
return {
|
|
59
|
+
ok: false,
|
|
60
|
+
errors: [`Revised plan has ${revisedCount} scope(s) but the original has ${scopeCount}; the scope count must not change.`],
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
return { ok: true, document };
|
|
64
|
+
}
|
|
65
|
+
function collectCanonicalMultiScopeErrors(planDocument) {
|
|
66
|
+
const validation = validatePlanDocument(planDocument);
|
|
67
|
+
if (!validation.ok) {
|
|
68
|
+
return validation.errors.map((error) => `Plan does not validate: ${error}`);
|
|
69
|
+
}
|
|
70
|
+
if (validation.executionShape !== 'multi_scope') {
|
|
71
|
+
return [`Plan shape is \`${validation.executionShape}\`; only \`multi_scope\` plans have later scopes to revise.`];
|
|
72
|
+
}
|
|
73
|
+
if (validation.normalization.applied) {
|
|
74
|
+
return [
|
|
75
|
+
'Plan uses an alias form that is normalized in memory; only plans already in canonical `## Execution Queue` / `### Scope N:` form can be revised.',
|
|
76
|
+
];
|
|
77
|
+
}
|
|
78
|
+
return [];
|
|
79
|
+
}
|
|
80
|
+
function collectReplacementBodyErrors(replacementBody, targetScopeNumber) {
|
|
81
|
+
const errors = [];
|
|
82
|
+
const bodyLines = replacementBody.split('\n');
|
|
83
|
+
const firstLine = bodyLines[0] ?? '';
|
|
84
|
+
const headingMatch = SCOPE_HEADING_PATTERN.exec(firstLine.trim());
|
|
85
|
+
if (headingMatch === null || Number(headingMatch[1]) !== targetScopeNumber) {
|
|
86
|
+
errors.push(`Replacement body must start with the line \`### Scope ${targetScopeNumber}:\`.`);
|
|
87
|
+
}
|
|
88
|
+
const extraHeadings = bodyLines.slice(1).filter((line) => SECTION_HEADING_PATTERN.test(line.trim()));
|
|
89
|
+
if (extraHeadings.length > 0) {
|
|
90
|
+
errors.push(`Replacement body must contain exactly one scope entry; found additional heading(s): ${extraHeadings.map((line) => `\`${line.trim()}\``).join(', ')}.`);
|
|
91
|
+
}
|
|
92
|
+
return errors;
|
|
93
|
+
}
|
|
94
|
+
function collectScopeEntries(lines) {
|
|
95
|
+
const queueStart = lines.findIndex((line) => line.trim() === EXECUTION_QUEUE_HEADER);
|
|
96
|
+
if (queueStart === -1) {
|
|
97
|
+
return [];
|
|
98
|
+
}
|
|
99
|
+
let queueEnd = lines.length;
|
|
100
|
+
for (let index = queueStart + 1; index < lines.length; index += 1) {
|
|
101
|
+
if (/^## /.test(lines[index].trim())) {
|
|
102
|
+
queueEnd = index;
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
const entries = [];
|
|
107
|
+
for (let index = queueStart + 1; index < queueEnd; index += 1) {
|
|
108
|
+
const match = SCOPE_HEADING_PATTERN.exec(lines[index].trim());
|
|
109
|
+
if (match === null) {
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
let end = queueEnd;
|
|
113
|
+
for (let cursor = index + 1; cursor < queueEnd; cursor += 1) {
|
|
114
|
+
if (SECTION_HEADING_PATTERN.test(lines[cursor].trim())) {
|
|
115
|
+
end = cursor;
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
entries.push({ number: Number(match[1]), start: index, end });
|
|
120
|
+
}
|
|
121
|
+
return entries;
|
|
122
|
+
}
|
|
123
|
+
function trimTrailingBlankLines(lines) {
|
|
124
|
+
const trimmed = [...lines];
|
|
125
|
+
while (trimmed.length > 0 && trimmed.at(-1)?.trim() === '') {
|
|
126
|
+
trimmed.pop();
|
|
127
|
+
}
|
|
128
|
+
return trimmed;
|
|
129
|
+
}
|
|
@@ -2,7 +2,7 @@ import { AGENT_FREE_TEXT_SECTION_MAX_CHARS, boundChangedFileList, boundCommitSub
|
|
|
2
2
|
import { AUTONOMY_BLOCKED, AUTONOMY_DONE, AUTONOMY_SCOPE_DONE, AUTONOMY_SPLIT_PLAN, buildProgressSection, getCanonicalPlanContractLines, getDerivedPlanSectionContractLines, getExecuteScopeProgressPayloadContractLines, getProtocolMarkerArtifactProhibitionLines, getStandalonePlanPayloadSourceOfTruthLines, getTerminalMarkerArtifactBoundaryLines, } from './shared.js';
|
|
3
3
|
import { assertPromptBuilder } from './assert-builder.js';
|
|
4
4
|
import { getUserGuidanceLines } from './guidance.js';
|
|
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/execute.ts';
|
|
7
7
|
export const CODER_REGRESSION_PRESERVATION_LINE = 'Treat existing behavior on the code paths you touch as part of the contract: identify the shared subsystems your change intersects (state, parsing, dispatch, lifecycle, storage, startup) and confirm that adjacent behavior on those paths still works — by running existing tests that exercise them when such tests exist, or by concrete reasoning through representative flows otherwise — before you finish.';
|
|
8
8
|
export const CODER_PREEXISTING_FAILURE_LINES = [
|
|
@@ -136,6 +136,7 @@ export function buildReviewerPrompt(args) {
|
|
|
136
136
|
throw new Error('Prompt spec scope_reviewer is missing primary or meaningful-progress coverage');
|
|
137
137
|
}
|
|
138
138
|
const accessMode = args.accessMode ?? 'tool-access';
|
|
139
|
+
const reviewLevel = args.reviewLevel ?? 'moderate';
|
|
139
140
|
// A collected diff may legitimately be the empty string (a range with no
|
|
140
141
|
// changes); distinguish "collected" (any string, including '') from "not
|
|
141
142
|
// collected" (null/undefined) so an empty diff still rides the inlined channel
|
|
@@ -183,8 +184,9 @@ export function buildReviewerPrompt(args) {
|
|
|
183
184
|
...getAdversarialReviewDoctrineLines({
|
|
184
185
|
reviewSubject: 'the scope diff',
|
|
185
186
|
}),
|
|
187
|
+
...getReviewLevelCalibrationLines({ level: reviewLevel, outputContract: 'structured_findings' }),
|
|
186
188
|
'',
|
|
187
|
-
...getFindingQualityLines(),
|
|
189
|
+
...getFindingQualityLines({ outputContract: 'structured_findings', level: reviewLevel }),
|
|
188
190
|
...skepticismLines,
|
|
189
191
|
...regressionLines,
|
|
190
192
|
EARLIER_SCOPE_PRESERVATION_LINE,
|
|
@@ -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
|
-
|
|
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.',
|
|
@@ -2,7 +2,7 @@ import { guardStructuredJsonOutputFormatLines } from '../agents/structured-json.
|
|
|
2
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
|
|
@@ -143,6 +143,7 @@ export function buildFinalCompletionReviewerPrompt(args) {
|
|
|
143
143
|
throw new Error('Prompt spec completion_reviewer is missing a final_completion variant');
|
|
144
144
|
}
|
|
145
145
|
const accessMode = args.accessMode ?? 'tool-access';
|
|
146
|
+
const reviewLevel = args.reviewLevel ?? 'moderate';
|
|
146
147
|
// A collected diff may legitimately be the empty string (a range with no
|
|
147
148
|
// changes); distinguish "collected" (any string, including '') from "not
|
|
148
149
|
// collected" (null/undefined) so an empty diff still rides the inlined channel
|
|
@@ -201,6 +202,7 @@ export function buildFinalCompletionReviewerPrompt(args) {
|
|
|
201
202
|
judgmentTarget: 'whole-plan completion',
|
|
202
203
|
proofTarget: 'the aggregate implementation satisfies the plan',
|
|
203
204
|
}),
|
|
205
|
+
...getReviewLevelCalibrationLines({ level: reviewLevel, outputContract: 'completion_verdict' }),
|
|
204
206
|
...falsificationLines,
|
|
205
207
|
...scratchLines,
|
|
206
208
|
'Falsify cross-scope runtime invariants and integration behavior before accepting completion, especially paths that individual scope reviews could not see together.',
|
|
@@ -208,7 +210,7 @@ export function buildFinalCompletionReviewerPrompt(args) {
|
|
|
208
210
|
...skepticismLines,
|
|
209
211
|
...regressionLines,
|
|
210
212
|
...preexistingLines,
|
|
211
|
-
...getFindingQualityLines({ outputContract: 'completion_verdict' }),
|
|
213
|
+
...getFindingQualityLines({ outputContract: 'completion_verdict', level: reviewLevel }),
|
|
212
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.',
|
|
213
215
|
...getReviewerContextLines(args.reviewerContext),
|
|
214
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.',
|
|
@@ -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.'),
|
|
@@ -287,7 +289,7 @@ export const PROMPT_SPECS = [
|
|
|
287
289
|
},
|
|
288
290
|
{
|
|
289
291
|
id: 'scope_coder',
|
|
290
|
-
version:
|
|
292
|
+
version: 3,
|
|
291
293
|
changelog: [
|
|
292
294
|
{
|
|
293
295
|
version: 1,
|
|
@@ -297,6 +299,10 @@ export const PROMPT_SPECS = [
|
|
|
297
299
|
version: 2,
|
|
298
300
|
renderSha: '0ce921ee7d0acc4042bacf31e1509968f7e76df8db8417eb4b724ce7842794ac',
|
|
299
301
|
},
|
|
302
|
+
{
|
|
303
|
+
version: 3,
|
|
304
|
+
renderSha: '8fb94430bf9d9abcb11f905106a3a04fc1b81bd101104c88f84a8693f705c5ea',
|
|
305
|
+
},
|
|
300
306
|
],
|
|
301
307
|
role: 'coder',
|
|
302
308
|
purpose: 'Execute exactly one bounded implementation scope and respond to in-scope review feedback without starting new scopes.',
|
|
@@ -400,7 +406,7 @@ export const PROMPT_SPECS = [
|
|
|
400
406
|
},
|
|
401
407
|
{
|
|
402
408
|
id: 'scope_reviewer',
|
|
403
|
-
version:
|
|
409
|
+
version: 5,
|
|
404
410
|
changelog: [
|
|
405
411
|
{
|
|
406
412
|
version: 1,
|
|
@@ -418,6 +424,10 @@ export const PROMPT_SPECS = [
|
|
|
418
424
|
version: 4,
|
|
419
425
|
renderSha: 'da87b19f2401ffdca21e3cefec1037c6470b3e74810b6152d07c55fa4924047f',
|
|
420
426
|
},
|
|
427
|
+
{
|
|
428
|
+
version: 5,
|
|
429
|
+
renderSha: 'e8dcb976d0ea22e9026e09a87d7dde93c8513334f62ad89472ebefe91771754b',
|
|
430
|
+
},
|
|
421
431
|
],
|
|
422
432
|
role: 'reviewer',
|
|
423
433
|
purpose: 'Review execute-scope results for correctness, verification coverage, and meaningful progress toward the active parent objective.',
|
|
@@ -475,6 +485,7 @@ export const PROMPT_SPECS = [
|
|
|
475
485
|
field('parentScopeLabel', 'orchestrator_state', true, 'Active parent objective label.'),
|
|
476
486
|
field('scratchDir', 'run_artifact', true, 'Run-local scratch directory for reviewer verification artifacts.'),
|
|
477
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."),
|
|
478
489
|
]),
|
|
479
490
|
},
|
|
480
491
|
schemaTarget: {
|
|
@@ -573,7 +584,7 @@ export const PROMPT_SPECS = [
|
|
|
573
584
|
},
|
|
574
585
|
{
|
|
575
586
|
id: 'completion_reviewer',
|
|
576
|
-
version:
|
|
587
|
+
version: 5,
|
|
577
588
|
changelog: [
|
|
578
589
|
{
|
|
579
590
|
version: 1,
|
|
@@ -591,6 +602,10 @@ export const PROMPT_SPECS = [
|
|
|
591
602
|
version: 4,
|
|
592
603
|
renderSha: 'c47009016178fc29c34440e06636ba7b21c6beb59202dd3fe63e365cb32a75cf',
|
|
593
604
|
},
|
|
605
|
+
{
|
|
606
|
+
version: 5,
|
|
607
|
+
renderSha: '7930cec95560ad880bba94a7ae48e68c621805787261e295c4c405d09235c87b',
|
|
608
|
+
},
|
|
594
609
|
],
|
|
595
610
|
role: 'reviewer',
|
|
596
611
|
purpose: 'Judge whole-plan completion and decide whether Neal should accept completion, continue execution, or block for operator input.',
|
|
@@ -637,6 +652,7 @@ export const PROMPT_SPECS = [
|
|
|
637
652
|
field('summary', 'review_history', true, 'Coder-authored completion summary.'),
|
|
638
653
|
field('scratchDir', 'run_artifact', true, 'Run-local scratch directory for final-completion reviewer artifacts.'),
|
|
639
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."),
|
|
640
656
|
]),
|
|
641
657
|
},
|
|
642
658
|
schemaTarget: {
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
}
|
|
@@ -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/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`);
|
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
|
@@ -361,6 +361,8 @@ testing or profile experiments. That override wins over the default directory.
|
|
|
361
361
|
|
|
362
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.
|
|
363
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
|
+
|
|
364
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.
|
|
365
367
|
|
|
366
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/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
|
@@ -42,6 +42,15 @@
|
|
|
42
42
|
# # for more work before neal stops reopening the plan.
|
|
43
43
|
# final_completion_continue_execution_max: 3
|
|
44
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
|
|
53
|
+
#
|
|
45
54
|
# # Optional local notification command. Leave commented to keep
|
|
46
55
|
# # notifications disabled.
|
|
47
56
|
# # notify_bin: /absolute/path/to/notify
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@navels/neal",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.2",
|
|
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": {
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
},
|
|
9
9
|
"author": "Lee Nave",
|
|
10
10
|
"type": "module",
|
|
11
|
-
"packageManager": "pnpm@11.
|
|
11
|
+
"packageManager": "pnpm@11.24.0",
|
|
12
12
|
"homepage": "https://github.com/navels/neal#readme",
|
|
13
13
|
"repository": {
|
|
14
14
|
"type": "git",
|
|
@@ -40,8 +40,8 @@
|
|
|
40
40
|
"examples"
|
|
41
41
|
],
|
|
42
42
|
"engines": {
|
|
43
|
-
"node": ">=24.
|
|
44
|
-
"pnpm": ">=11.
|
|
43
|
+
"node": ">=24.20.0",
|
|
44
|
+
"pnpm": ">=11.24.0"
|
|
45
45
|
},
|
|
46
46
|
"scripts": {
|
|
47
47
|
"build": "rm -rf dist && node node_modules/typescript-7/bin/tsc -p tsconfig.json && chmod +x dist/neal/index.js",
|
|
@@ -55,10 +55,10 @@
|
|
|
55
55
|
"typecheck": "node node_modules/typescript-7/bin/tsc --noEmit -p tsconfig.json && node node_modules/typescript-7/bin/tsc -p tsconfig.test.json"
|
|
56
56
|
},
|
|
57
57
|
"dependencies": {
|
|
58
|
-
"@ai-sdk/openai-compatible": "3.0.
|
|
59
|
-
"@anthropic-ai/claude-agent-sdk": "0.3.
|
|
60
|
-
"@openai/codex-sdk": "0.
|
|
61
|
-
"ai": "7.0.
|
|
58
|
+
"@ai-sdk/openai-compatible": "3.0.39",
|
|
59
|
+
"@anthropic-ai/claude-agent-sdk": "0.3.251",
|
|
60
|
+
"@openai/codex-sdk": "0.151.0",
|
|
61
|
+
"ai": "7.0.83",
|
|
62
62
|
"dotenv": "^17.4.2",
|
|
63
63
|
"yaml": "^2.9.0",
|
|
64
64
|
"zod": "4.4.3"
|
|
@@ -66,10 +66,10 @@
|
|
|
66
66
|
"devDependencies": {
|
|
67
67
|
"@eslint/js": "^10.0.1",
|
|
68
68
|
"@types/node": "^24.13.3",
|
|
69
|
-
"eslint": "^10.9.
|
|
69
|
+
"eslint": "^10.9.1",
|
|
70
70
|
"tsx": "^4.23.12",
|
|
71
71
|
"typescript": "^6.0.3",
|
|
72
72
|
"typescript-7": "npm:typescript@^7.0.2",
|
|
73
|
-
"typescript-eslint": "^8.
|
|
73
|
+
"typescript-eslint": "^8.68.0"
|
|
74
74
|
}
|
|
75
75
|
}
|