@navels/neal 0.5.1 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md 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>/`.
@@ -1,6 +1,6 @@
1
1
  import { mkdir } from 'node:fs/promises';
2
2
  import { runCoderResponseRound, runReviewerRound } from '../agents.js';
3
- import { readOnlyReviewerNeedsInlinedDiff } from '../context/inline-review-context.js';
3
+ import { OPEN_FINDINGS_PROMPT_ITEM_LIMIT, readOnlyReviewerNeedsInlinedDiff, } from '../context/inline-review-context.js';
4
4
  import { buildAndPersistReviewerContextPacket } from '../context/reviewer-context.js';
5
5
  import { getReviewStuckWindow } from '../config.js';
6
6
  import { getDiffForRangePaths } from '../git.js';
@@ -229,9 +229,19 @@ export function buildVerificationHint(state) {
229
229
  '- Rerun full test suites only if your new changes materially invalidate that reviewed baseline or the plan explicitly requires new end-of-scope full-suite verification.',
230
230
  ].join('\n');
231
231
  }
232
+ // The bounded finding set for one coder response round. Both the response
233
+ // prompt and validateExecuteResponseCoverage consume this same selection, so
234
+ // the coder is asked to disposition exactly the findings it can see. At most
235
+ // OPEN_FINDINGS_PROMPT_ITEM_LIMIT findings are presented per round; findings
236
+ // beyond the limit stay open and synthesizeExecuteResponseState keeps the
237
+ // response phase active, presenting the next batch immediately, until every
238
+ // open finding of the round's kind has been dispositioned.
232
239
  export function getExecuteResponseOpenFindings(state, mode = 'blocking') {
233
240
  const selector = mode === 'optional' ? isOpenNonBlockingFinding : isOpenBlockingFinding;
234
- return state.findings.filter(selector).map((finding) => ({
241
+ return state.findings
242
+ .filter(selector)
243
+ .slice(0, OPEN_FINDINGS_PROMPT_ITEM_LIMIT)
244
+ .map((finding) => ({
235
245
  id: finding.id,
236
246
  source: finding.source,
237
247
  claim: finding.claim,
@@ -266,7 +276,7 @@ function validateExecuteResponseCoverage(args) {
266
276
  throw new Error(`Coder ${args.mode} response returned duplicate finding dispositions: ${[...duplicateIds].join(', ')}`);
267
277
  }
268
278
  if (unknownIds.size > 0) {
269
- throw new Error(`Coder ${args.mode} response returned dispositions for non-open findings: ${[...unknownIds].join(', ')}`);
279
+ throw new Error(`Coder ${args.mode} response returned dispositions for findings outside the presented open set: ${[...unknownIds].join(', ')}`);
270
280
  }
271
281
  if (args.response.payload.outcome !== 'responded') {
272
282
  return;
@@ -526,7 +536,12 @@ export function synthesizeExecuteReviewerState(args) {
526
536
  ];
527
537
  mergedFindings = [...args.state.findings, ...findings];
528
538
  }
529
- const hasBlockingFindings = findings.some((finding) => finding.severity === 'blocking');
539
+ // Deliberately derived from the merged set as well as the current reviewer
540
+ // payload: a prior round's still-open blocker (for example one beyond the
541
+ // batched response round's presentation limit) must keep forcing a revision
542
+ // even when the current reviewer round is empty, so an empty round can never
543
+ // accept over an open blocking finding.
544
+ const hasBlockingFindings = findings.some((finding) => finding.severity === 'blocking') || mergedFindings.some(isOpenBlockingFinding);
530
545
  const hasOpenNonBlockingFindings = mergedFindings.some(isOpenNonBlockingFinding);
531
546
  const openBlockingCanonicalSet = getOpenBlockingCanonicalSet(mergedFindings);
532
547
  const openBlockingCanonicalIds = [...openBlockingCanonicalSet].sort();
@@ -633,11 +648,22 @@ export function synthesizeExecuteResponseState(args) {
633
648
  };
634
649
  });
635
650
  const outcome = args.response.payload.outcome;
651
+ // Response rounds batch: when open findings of this round's kind remain
652
+ // after the dispositions are applied (they were beyond the per-round
653
+ // presentation limit), stay in the same response phase so the next batch is
654
+ // presented immediately instead of spending a reviewer round per batch.
655
+ // Coverage validation guarantees the presented batch was fully
656
+ // dispositioned, so the backlog strictly shrinks and the loop terminates.
657
+ const hasNextResponseBatch = findings.some(mode === 'optional' ? isOpenNonBlockingFinding : isOpenBlockingFinding);
636
658
  const nextPhase = outcome === 'blocked' || outcome === 'split_plan'
637
659
  ? 'blocked'
638
660
  : mode === 'optional'
639
- ? EXECUTE_FINALIZATION_PHASE
640
- : 'reviewer_scope';
661
+ ? hasNextResponseBatch
662
+ ? 'coder_optional_response'
663
+ : EXECUTE_FINALIZATION_PHASE
664
+ : hasNextResponseBatch
665
+ ? 'coder_response'
666
+ : 'reviewer_scope';
641
667
  return {
642
668
  findings,
643
669
  nextPhase,
@@ -1,6 +1,6 @@
1
- import { assertNoReadPromptInstructionText, renderInlineReviewerContext, } from '../context/inline-review-context.js';
1
+ import { assertNoReadPromptInstructionText, boundFreeTextValues, renderInlineReviewerContext, truncateInlineSectionBody, } from '../context/inline-review-context.js';
2
2
  import { AUTONOMY_BLOCKED as SHARED_AUTONOMY_BLOCKED, AUTONOMY_CHUNK_DONE as SHARED_AUTONOMY_CHUNK_DONE, AUTONOMY_DONE as SHARED_AUTONOMY_DONE, AUTONOMY_SCOPE_DONE as SHARED_AUTONOMY_SCOPE_DONE, AUTONOMY_SPLIT_PLAN as SHARED_AUTONOMY_SPLIT_PLAN, buildProgressSection, getCanonicalPlanContractLines, getProtocolMarkerArtifactProhibitionLines, getStandalonePlanPayloadSourceOfTruthLines, } from '../prompts/shared.js';
3
- import { getUserGuidanceLines } from '../prompts/guidance.js';
3
+ import { getUserGuidanceLines, USER_GUIDANCE_MAX_CHARS } from '../prompts/guidance.js';
4
4
  import { assertPromptBuilder, resolvePrimaryVariant } from '../prompts/assert-builder.js';
5
5
  export { buildCoderResponsePrompt, buildLegacyScopePrompt, buildReviewerPrompt, buildScopePrompt } from '../prompts/execute.js';
6
6
  export { buildCoderPlanResponsePrompt, buildLegacyPlanningPrompt, buildPlanReviewerPrompt, buildPlanningPrompt, } from '../prompts/planning.js';
@@ -41,13 +41,16 @@ export function buildConsultantPrompt(args) {
41
41
  ...staticInstructionLines,
42
42
  '',
43
43
  'Blocked reason:',
44
- args.blockedReason,
44
+ // Agent-authored free text shares the fixed agent free-text cap at render
45
+ // time; the persisted blocked reason keeps its full text.
46
+ boundFreeTextValues([args.blockedReason])[0],
45
47
  '',
46
48
  renderInlineReviewerContext(args.inlineContext),
47
49
  ].join('\n');
48
50
  }
49
51
  export function buildBlockedRecoveryCoderPrompt(args) {
50
52
  const allowReplacement = args.allowReplacement ?? true;
53
+ const laterScopeRevision = args.terminalOnly ? null : args.laterScopeRevision ?? null;
51
54
  const actionLines = [
52
55
  '- `resume_current_scope`',
53
56
  ...(allowReplacement ? ['- `replace_current_scope`'] : []),
@@ -85,6 +88,14 @@ export function buildBlockedRecoveryCoderPrompt(args) {
85
88
  allowReplacement
86
89
  ? 'Always include a `replacementPlan` string. Use an empty string unless action=`replace_current_scope`.'
87
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.']),
88
99
  ...(allowReplacement
89
100
  ? [
90
101
  'When action=`replace_current_scope`, `replacementPlan` must use the same Neal-executable contract as a top-level plan.',
@@ -102,9 +113,12 @@ export function buildBlockedRecoveryCoderPrompt(args) {
102
113
  'Do not treat operator guidance as authorization to skip verification, waive policy, or reinterpret the target beyond the current scope.',
103
114
  '',
104
115
  'Blocked recovery context:',
105
- `- Blocked reason: ${args.blockedReason}`,
116
+ // The blocked reason is agent-authored free text and the guidance is
117
+ // operator-authored; each gets its class's render-time cap while the
118
+ // persisted values keep their full text.
119
+ `- Blocked reason: ${boundFreeTextValues([args.blockedReason])[0]}`,
106
120
  `- Recovery turns used: ${args.turnsTaken} of ${args.maxTurns}`,
107
- `- Latest operator guidance: ${args.operatorGuidance}`,
121
+ `- Latest operator guidance: ${truncateInlineSectionBody(args.operatorGuidance, USER_GUIDANCE_MAX_CHARS)}`,
108
122
  '',
109
123
  'Current progress state:',
110
124
  buildProgressSection(args.progressText),
@@ -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,8 +2,9 @@ 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
+ import { collectGuidanceDiagnostics, USER_GUIDANCE_MAX_CHARS } from '../prompts/guidance.js';
7
8
  import { runWithAgentTurnLiveness } from '../providers/liveness.js';
8
9
  import { getCoderAdapter, getProviderDefinition, getStructuredAdvisorAdapter, } from '../providers/registry.js';
9
10
  import { sanitizeSensitiveText } from '../sensitive-text.js';
@@ -64,6 +65,7 @@ function validateConfig(cwd) {
64
65
  getInteractiveBlockedRecoveryMaxTurns(cwd);
65
66
  getFinalCompletionContinueExecutionMax(cwd);
66
67
  getNotifyBin(cwd);
68
+ getReviewLevel(cwd);
67
69
  return agentConfig;
68
70
  }
69
71
  async function promptForProviderVerification(stdin, stdout) {
@@ -343,6 +345,11 @@ export async function runNealCheckCli(options = {}) {
343
345
  writeLine(stdout, ` ${describeRole('coder', agentConfig.coder)}`);
344
346
  writeLine(stdout, ` ${describeRole('reviewer', agentConfig.reviewer)}`);
345
347
  writeLine(stdout, ` ${describeNotificationScript(notifyBin)}`);
348
+ for (const entry of collectGuidanceDiagnostics()) {
349
+ if (entry.chars > USER_GUIDANCE_MAX_CHARS) {
350
+ writeLine(stdout, ` warning: ${entry.role} guidance at ${entry.path} is ${entry.chars} characters; prompts inline the first ${USER_GUIDANCE_MAX_CHARS} and truncate the rest. Trim the file.`);
351
+ }
352
+ }
346
353
  writeLine(stdout, '');
347
354
  // Native adapters drive their providers directly; any other writer provider is
348
355
  // an openai-compatible model that should be qualified end-to-end with `neal compat`.
@@ -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',
@@ -19,9 +20,10 @@ const DEFAULT_CONFIG = {
19
20
  agent_turn_startup_timeout_ms: 300_000,
20
21
  agent_turn_retry_limit: 1,
21
22
  interactive_blocked_recovery_max_turns: 3,
22
- final_completion_continue_execution_max: 2,
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) ??
@@ -4,6 +4,122 @@ import { getProviderDefinition, isRegisteredProviderId } from '../providers/regi
4
4
  // truncation posture of truncateForPrompt in src/neal/agents/structured-json.ts:
5
5
  // truncate with an explicit marker instead of silently dropping content.
6
6
  export const INLINE_SECTION_MAX_CHARS = 200_000;
7
+ // Fixed aggregate character budget for the free-text values of one
8
+ // agent-authored payload embedded in another agent's prompt (review-finding
9
+ // text, progress justifications, completion summaries). The budget covers the
10
+ // total rendered free text including truncation markers, so the free-text
11
+ // contribution of a section never exceeds this constant. Control data (ids,
12
+ // severities, statuses, file paths) never rides this budget: callers render it
13
+ // separately and exactly via boundOpenFindingsForPrompt-style wrappers.
14
+ export const AGENT_FREE_TEXT_SECTION_MAX_CHARS = 20_000;
15
+ // Upper bound on one rendered truncation marker:
16
+ // '\n[truncated N character(s)]' with N at most 16 digits.
17
+ const TRUNCATION_MARKER_MAX_CHARS = 42;
18
+ // Maximum open findings presented to the coder per response round. Response
19
+ // selection (getExecuteResponseOpenFindings and the plan-review response
20
+ // phase) and prompt rendering share this bound, so the coder always sees
21
+ // exactly the finding set it must disposition; findings beyond the limit stay
22
+ // open and are presented in later response rounds until every one is handled.
23
+ // The limit also keeps boundFreeTextValues' cardinality precondition
24
+ // satisfied: 3 free-text values per finding times this limit stays far under
25
+ // AGENT_FREE_TEXT_SECTION_MAX_CHARS / TRUNCATION_MARKER_MAX_CHARS.
26
+ export const OPEN_FINDINGS_PROMPT_ITEM_LIMIT = 50;
27
+ function renderBoundedFreeTextValue(text, cap) {
28
+ if (text.length <= cap) {
29
+ return text;
30
+ }
31
+ if (cap <= 0) {
32
+ return `[truncated ${text.length} character(s)]`;
33
+ }
34
+ return `${text.slice(0, cap)}\n[truncated ${text.length - cap} character(s)]`;
35
+ }
36
+ // Largest equal per-value kept-character cap such that the values fit the
37
+ // kept-character budget (water-filling): short values keep their full text and
38
+ // long values share the remaining budget equally. Returns MAX_SAFE_INTEGER
39
+ // when everything already fits.
40
+ function computeFairValueCap(lengths, budget) {
41
+ const sorted = [...lengths].sort((left, right) => left - right);
42
+ let remaining = budget;
43
+ for (let index = 0; index < sorted.length; index += 1) {
44
+ const share = Math.floor(remaining / (sorted.length - index));
45
+ if (sorted[index] > share) {
46
+ return Math.max(share, 0);
47
+ }
48
+ remaining -= sorted[index];
49
+ }
50
+ return Number.MAX_SAFE_INTEGER;
51
+ }
52
+ // Bounds a fixed list of agent-authored free-text values to a fixed aggregate
53
+ // budget, truncation markers included: marker allowance is reserved up front,
54
+ // the kept characters are water-filled over the rest, so the total rendered
55
+ // length is always <= budget. Positions are preserved one-to-one with the
56
+ // input. Callers keep control data (ids, files, severities) out of this list
57
+ // and enforce the finite cardinality this validates. Never mutates the input.
58
+ export function boundFreeTextValues(texts, budget = AGENT_FREE_TEXT_SECTION_MAX_CHARS) {
59
+ if (texts.length * TRUNCATION_MARKER_MAX_CHARS > budget) {
60
+ throw new Error(`boundFreeTextValues received ${texts.length} free-text values; the aggregate budget of ${budget} characters supports at most ${Math.floor(budget / TRUNCATION_MARKER_MAX_CHARS)}. Bound the payload's item cardinality before rendering free text.`);
61
+ }
62
+ const keptBudget = budget - texts.length * TRUNCATION_MARKER_MAX_CHARS;
63
+ const cap = computeFairValueCap(texts.map((text) => text.length), keptBudget);
64
+ return texts.map((text) => renderBoundedFreeTextValue(text, cap));
65
+ }
66
+ // Render-only view of an open-findings list for prompt embedding. Every
67
+ // finding renders: id, source, and severity are copied exactly, each files
68
+ // path renders whole with the list length bounded via boundChangedFileList,
69
+ // and only the claim, requiredAction, and roundSummary free text shares the
70
+ // fixed aggregate budget. Callers must present at most
71
+ // OPEN_FINDINGS_PROMPT_ITEM_LIMIT findings — the same bounded set their
72
+ // response processing validates against — so this never drops a finding the
73
+ // coder is required to disposition; it throws on a larger list instead of
74
+ // silently diverging from the response contract. Never mutates the input.
75
+ export function boundOpenFindingsForPrompt(findings) {
76
+ if (findings.length > OPEN_FINDINGS_PROMPT_ITEM_LIMIT) {
77
+ throw new Error(`boundOpenFindingsForPrompt received ${findings.length} findings; response rounds present at most ${OPEN_FINDINGS_PROMPT_ITEM_LIMIT}. Bound the selection where the response set is chosen.`);
78
+ }
79
+ const boundedTexts = boundFreeTextValues(findings.flatMap((finding) => [finding.claim, finding.requiredAction, finding.roundSummary]));
80
+ return findings.map((finding, index) => ({
81
+ ...finding,
82
+ claim: boundedTexts[index * 3],
83
+ requiredAction: boundedTexts[index * 3 + 1],
84
+ roundSummary: boundedTexts[index * 3 + 2],
85
+ files: boundChangedFileList(finding.files),
86
+ }));
87
+ }
88
+ // Maximum commit subjects rendered per commit-subject list that reaches a
89
+ // prompt (the aggregate completion range and the scope review's commits-in-scope
90
+ // list). Matches CHANGED_FILE_LIST_LIMIT so both run-scaling list bounds read
91
+ // the same way.
92
+ export const COMMIT_SUBJECT_LIST_LIMIT = 20;
93
+ // Character cap for one rendered git summary block that grows with run length
94
+ // (diff-stat output, one line per file). Applied via truncateInlineSectionBody,
95
+ // so an over-cap block carries an explicit truncation marker.
96
+ export const GIT_SUMMARY_SECTION_MAX_CHARS = 20_000;
97
+ // Bounds a commit-subject list for prompt rendering: the first
98
+ // COMMIT_SUBJECT_LIST_LIMIT subjects render with their text sharing the fixed
99
+ // aggregate free-text budget (subjects are largely agent-authored), and the
100
+ // rest collapse to an explicit "(+N more)" entry. The underlying arrays keep
101
+ // every subject for non-prompt consumers. Never mutates the input.
102
+ export function boundCommitSubjectList(subjects) {
103
+ const kept = boundFreeTextValues(subjects.slice(0, COMMIT_SUBJECT_LIST_LIMIT));
104
+ if (subjects.length <= COMMIT_SUBJECT_LIST_LIMIT) {
105
+ return kept;
106
+ }
107
+ return [...kept, `(+${subjects.length - COMMIT_SUBJECT_LIST_LIMIT} more)`];
108
+ }
109
+ // Maximum file paths rendered per changed-file list that reaches a prompt.
110
+ // Matches the reviewer continuity packet's per-scope bound
111
+ // (COMPLETED_SCOPE_CHANGED_FILE_LIMIT in src/neal/context/reviewer-context.ts).
112
+ export const CHANGED_FILE_LIST_LIMIT = 20;
113
+ // Bounds a changed-file list for prompt rendering: the first
114
+ // CHANGED_FILE_LIST_LIMIT entries render and the rest collapse to an explicit
115
+ // "(+N more)" marker entry. Callers join or JSON-embed the result; the
116
+ // underlying arrays in state and packets keep every path.
117
+ export function boundChangedFileList(files, limit = CHANGED_FILE_LIST_LIMIT) {
118
+ if (files.length <= limit) {
119
+ return [...files];
120
+ }
121
+ return [...files.slice(0, limit), `(+${files.length - limit} more)`];
122
+ }
7
123
  // Canonical forbidden-phrase list for no-read prompts (today: the blocked-run
8
124
  // consultant, which always judges from Neal-inlined in-memory context). A
9
125
  // no-read prompt must contain no instruction that requires repository, file,
@@ -1,11 +1,12 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
3
  import { getFinalCompletionContinueExecutionMax } from './config.js';
4
+ import { boundChangedFileList } from './context/inline-review-context.js';
4
5
  import { getChangedFilesForRange, getCommitRange, getCommitSubjects, getDiffStatForRange, } from './git.js';
5
6
  import { toResidualReviewDebt } from './review-debt.js';
6
7
  import { buildScopeAccountingSummary, getCurrentScopeLabel } from './scopes.js';
7
8
  import { getDerivedPlanView, getFinalCompletionView } from './state-views.js';
8
- import { extractVerificationCommandResults, latestCommandResultPerCommand, summarizeVerificationCommandResults, } from './verification-events.js';
9
+ import { buildVerificationTally, extractVerificationCommandResults, } from './verification-events.js';
9
10
  function getEventsPath(runDir) {
10
11
  return join(runDir, 'events.ndjson');
11
12
  }
@@ -27,7 +28,7 @@ function renderCompletedScopeSummary(scopes) {
27
28
  return scopes
28
29
  .map((scope) => {
29
30
  const changedFiles = scope.changedFiles.length > 0
30
- ? `${scope.changedFiles.length} file(s): ${scope.changedFiles.join(', ')}`
31
+ ? `${scope.changedFiles.length} file(s): ${boundChangedFileList(scope.changedFiles).join(', ')}`
31
32
  : 'no changed files';
32
33
  const commit = scope.finalCommit ?? 'pending';
33
34
  const parent = scope.derivedFromParentScope ? ` | parent ${scope.derivedFromParentScope}` : '';
@@ -191,8 +192,6 @@ export async function buildFinalCompletionPacket(args) {
191
192
  const finalCommit = terminalScope?.finalCommit ?? args.state.finalCommit;
192
193
  const effectiveScopes = mergeCompletedScopesWithTerminalScope(args.state, terminalScope);
193
194
  const allVerificationCommandResults = await loadVerificationCommandResults(args.state.runDir);
194
- const verificationCommandResults = latestCommandResultPerCommand(allVerificationCommandResults);
195
- const verificationCommands = verificationCommandResults.map((result) => result.command);
196
195
  const scopeAccounting = buildScopeAccountingSummary(effectiveScopes);
197
196
  const terminalChangedFiles = [...(terminalScope?.changedFiles ?? [])];
198
197
  const planChangedFiles = uniqueFiles(effectiveScopes
@@ -219,9 +218,7 @@ export async function buildFinalCompletionPacket(args) {
219
218
  planChangedFilesSummary: summarizeChangedFiles(planChangedFiles),
220
219
  residualReviewDebt,
221
220
  residualReviewDebtSummary: summarizeResidualReviewDebt(effectiveScopes),
222
- verificationCommands,
223
- verificationCommandResults,
224
- verificationSummary: summarizeVerificationCommandResults(verificationCommandResults),
221
+ verificationTally: buildVerificationTally(allVerificationCommandResults),
225
222
  lastNonEmptyImplementationScope: findLastNonEmptyImplementationScope(effectiveScopes, terminalScope, args.state),
226
223
  continueExecutionCount: finalCompletion?.continueExecutionCount ?? 0,
227
224
  continueExecutionMax: Math.max(0, getFinalCompletionContinueExecutionMax(args.state.cwd)),