@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 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
- prompt: buildReviewerPrompt({ ...args, accessMode: getReviewerDoctrineAccessMode(args.reviewer) }),
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
- prompt: buildFinalCompletionReviewerPrompt({ ...args, accessMode: getReviewerDoctrineAccessMode(args.reviewer) }),
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: validateCoderBlockedRecoveryDispositionPayload,
680
+ validator,
652
681
  }),
653
682
  resumeHandle: args.sessionHandle,
654
683
  logger: args.logger,
655
684
  });
656
685
  return {
657
686
  sessionHandle,
658
- payload: validateCoderBlockedRecoveryDispositionPayload(structured),
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 validateCoderBlockedRecoveryDispositionPayload(rawPayload) {
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);
@@ -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`, so the coder consumes it and the
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 recordInteractiveBlockedRecoveryGuidance(statePath, resolutionDirective, logger);
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 terminalDirective = state.interactiveBlockedRecovery.pendingDirective;
434
- if (!latestTurn && !terminalDirective) {
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 (terminalDirective &&
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: Boolean(pendingDirective?.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: Boolean(pendingDirective?.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
- 'Also use blocking severity for substantive robustness or performance regressions introduced by the implementation, especially in infrastructure, config, parser, caching, retry, or orchestration code.',
134
+ level === 'lenient'
135
+ ? 'Robustness or performance regressions introduced by the implementation are non_blocking unless they are a reachable correctness failure; use blocking severity only in that case.'
136
+ : 'Also use blocking severity for substantive robustness or performance regressions introduced by the implementation, especially in infrastructure, config, parser, caching, retry, or orchestration code.',
99
137
  'Use non_blocking severity for suggestions that do not block acceptance.',
100
138
  'Only emit non_blocking findings when they identify a concrete maintenance, observability, or testability issue that is genuinely worth a later follow-up turn.',
101
139
  'Do not emit non_blocking findings for formatting, whitespace, naming preferences, trivial code-shape preferences, or optional refactors.',
@@ -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: 2,
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: 4,
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: 4,
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
- for (const entry of entries.sort()) {
356
+ // Only directories are runs. The runs root also collects stray files such as
357
+ // macOS `.DS_Store`, and joining a run-state path onto one of those reads
358
+ // through a non-directory.
359
+ const runDirNames = entries
360
+ .filter((entry) => entry.isDirectory())
361
+ .map((entry) => entry.name)
362
+ .sort();
363
+ for (const entry of runDirNames) {
357
364
  const statePath = getRunStatePath(join(runsDir, entry));
358
365
  const content = await readOptionalText(statePath);
359
366
  if (content !== null) {
@@ -374,6 +381,13 @@ function assertSameStringMap(before, after, label) {
374
381
  }
375
382
  }
376
383
  }
384
+ // A run-state path can be unreadable because nothing is there (`ENOENT`) or
385
+ // because a path segment is not a directory (`ENOTDIR`, e.g. a stray file in the
386
+ // runs root). Both mean "no run state here", never a review failure.
377
387
  function isMissingFileError(error) {
378
- return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT';
388
+ if (typeof error !== 'object' || error === null || !('code' in error)) {
389
+ return false;
390
+ }
391
+ const code = error.code;
392
+ return code === 'ENOENT' || code === 'ENOTDIR';
379
393
  }
@@ -169,8 +169,14 @@ function assertValidInteractiveBlockedRecoveryState(args) {
169
169
  if (recovery.lastHandledTurn > recovery.turns.length) {
170
170
  throwStateInvariant(context, `${fieldPath}.lastHandledTurn`, `must not exceed recorded turn count ${recovery.turns.length}`);
171
171
  }
172
- if (recovery.turns.length > recovery.maxTurns) {
173
- throwStateInvariant(context, `${fieldPath}.turns`, `recorded turn count ${recovery.turns.length} exceeds maxTurns ${recovery.maxTurns}`);
172
+ // A recovery that reaches the turn cap and is then resolved by a turn-cap
173
+ // terminal directive records one terminal-resolution turn beyond `maxTurns`,
174
+ // so the recorded count may be `maxTurns + 1`. That extra turn is only ever
175
+ // the terminal resolution: past the cap, new guidance becomes a
176
+ // `pendingDirective` rather than an appended turn, so nothing else can push
177
+ // the count higher.
178
+ if (recovery.turns.length > recovery.maxTurns + 1) {
179
+ throwStateInvariant(context, `${fieldPath}.turns`, `recorded turn count ${recovery.turns.length} exceeds maxTurns ${recovery.maxTurns} by more than the one terminal-resolution turn`);
174
180
  }
175
181
  if (recovery.pendingDirective) {
176
182
  assertNonEmptyString(recovery.pendingDirective.recordedAt, `${fieldPath}.pendingDirective.recordedAt`, context);
@@ -11,6 +11,7 @@ import { refreshActiveRunLock } from './run-lock.js';
11
11
  import { validateReviewerSquashMessageDraft } from './squash-message.js';
12
12
  import { assertOrchestrationPhase, assertOrchestrationStatus, assertValidOrchestrationState, } from './state-invariants.js';
13
13
  const TOP_LEVEL_MODES = new Set(['plan', 'execute']);
14
+ const INTERACTIVE_BLOCKED_RECOVERY_TURN_ORIGINS = ['operator', 'consultant'];
14
15
  const INTERACTIVE_BLOCKED_RECOVERY_SOURCE_PHASES = new Set([
15
16
  'coder_plan',
16
17
  'reviewer_plan',
@@ -257,6 +258,16 @@ function readNullableString(record, key, fieldPath = key) {
257
258
  }
258
259
  throwInvalidState(fieldPath, `expected string or null, received ${formatStateValue(value)}`);
259
260
  }
261
+ function readOptionalString(record, key, fieldPath = key) {
262
+ if (!hasOwn(record, key)) {
263
+ return undefined;
264
+ }
265
+ const value = record[key];
266
+ if (typeof value === 'string') {
267
+ return value;
268
+ }
269
+ throwInvalidState(fieldPath, `expected string, received ${formatStateValue(value)}`);
270
+ }
260
271
  function readOptionalBoolean(record, key, fieldPath = key) {
261
272
  if (!hasOwn(record, key)) {
262
273
  return undefined;
@@ -646,6 +657,8 @@ function hydrateInteractiveBlockedRecoveryTurnDisposition(value, fieldPath) {
646
657
  rationale: readString(disposition, 'rationale', `${fieldPath}.rationale`),
647
658
  blocker: readString(disposition, 'blocker', `${fieldPath}.blocker`),
648
659
  replacementPlan: readString(disposition, 'replacementPlan', `${fieldPath}.replacementPlan`),
660
+ laterScopeNumber: readOptionalSafeInteger(disposition, 'laterScopeNumber', `${fieldPath}.laterScopeNumber`) ?? 0,
661
+ laterScopeBody: readOptionalString(disposition, 'laterScopeBody', `${fieldPath}.laterScopeBody`) ?? '',
649
662
  resultingPhase: readOrchestrationPhase(disposition, 'resultingPhase', `${fieldPath}.resultingPhase`),
650
663
  };
651
664
  }
@@ -655,6 +668,7 @@ function hydrateInteractiveBlockedRecoveryTurn(value, fieldPath) {
655
668
  number: readSafeInteger(turn, 'number', `${fieldPath}.number`),
656
669
  recordedAt: readString(turn, 'recordedAt', `${fieldPath}.recordedAt`),
657
670
  operatorGuidance: readString(turn, 'operatorGuidance', `${fieldPath}.operatorGuidance`),
671
+ origin: readOptionalNullableEnum(turn, 'origin', INTERACTIVE_BLOCKED_RECOVERY_TURN_ORIGINS, `${fieldPath}.origin`) ?? null,
658
672
  disposition: hydrateInteractiveBlockedRecoveryTurnDisposition(readRequired(turn, 'disposition', `${fieldPath}.disposition`), `${fieldPath}.disposition`),
659
673
  };
660
674
  }
@@ -667,6 +681,7 @@ function hydrateInteractiveBlockedRecoveryDirective(value, fieldPath) {
667
681
  recordedAt: readString(directive, 'recordedAt', `${fieldPath}.recordedAt`),
668
682
  operatorGuidance: readString(directive, 'operatorGuidance', `${fieldPath}.operatorGuidance`),
669
683
  terminalOnly: readBoolean(directive, 'terminalOnly', `${fieldPath}.terminalOnly`),
684
+ origin: readOptionalNullableEnum(directive, 'origin', INTERACTIVE_BLOCKED_RECOVERY_TURN_ORIGINS, `${fieldPath}.origin`) ?? null,
670
685
  };
671
686
  }
672
687
  const CONSULTANT_TRIAGE_CATEGORIES = [
@@ -17,9 +17,13 @@ function appendInteractiveBlockedRecoverySection(lines, title, recovery, options
17
17
  return;
18
18
  }
19
19
  for (const turn of recovery.turns) {
20
- lines.push(`- Recovery turn ${turn.number} at ${turn.recordedAt}: ${turn.operatorGuidance}`);
20
+ lines.push(`- Recovery turn ${turn.number} at ${turn.recordedAt} (${turn.origin ?? 'unrecorded'} origin): ${turn.operatorGuidance}`);
21
21
  if (turn.disposition) {
22
- lines.push(`- Recovery turn ${turn.number} coder action: ${turn.disposition.action}`, `- Recovery turn ${turn.number} coder summary: ${turn.disposition.summary}`, `- Recovery turn ${turn.number} coder blocker: ${turn.disposition.blocker || 'n/a'}`, `- Recovery turn ${turn.number} coder rationale: ${turn.disposition.rationale}`, `- Recovery turn ${turn.number} resulting step: ${formatPublicPhase(turn.disposition.resultingPhase)}`);
22
+ lines.push(`- Recovery turn ${turn.number} coder action: ${turn.disposition.action}`, `- Recovery turn ${turn.number} coder summary: ${turn.disposition.summary}`, `- Recovery turn ${turn.number} coder blocker: ${turn.disposition.blocker || 'n/a'}`, `- Recovery turn ${turn.number} coder rationale: ${turn.disposition.rationale}`);
23
+ if (turn.disposition.laterScopeNumber > 0) {
24
+ lines.push(`- Recovery turn ${turn.number} revised later scope: ${turn.disposition.laterScopeNumber}`, `- Recovery turn ${turn.number} revised scope text:`, ...turn.disposition.laterScopeBody.split('\n').map((line) => ` ${line}`));
25
+ }
26
+ lines.push(`- Recovery turn ${turn.number} resulting step: ${formatPublicPhase(turn.disposition.resultingPhase)}`);
23
27
  }
24
28
  else {
25
29
  lines.push(`- Recovery turn ${turn.number} coder response: pending`);
@@ -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`
@@ -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.
@@ -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.0",
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.22.0",
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.19.0",
44
- "pnpm": ">=11.22.0"
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.34",
59
- "@anthropic-ai/claude-agent-sdk": "0.3.240",
60
- "@openai/codex-sdk": "0.149.0",
61
- "ai": "7.0.74",
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.0",
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.67.0"
73
+ "typescript-eslint": "^8.68.0"
74
74
  }
75
75
  }