@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.
@@ -5,6 +5,7 @@ import { isOpenBlockingFinding, isOpenNonBlockingFinding, mapDecisionToStatus, }
5
5
  import { getDerivedPlanBlockedReason, isDerivedPlanReviewState, plannerProviderStartsFreshSessions, resolvePlanningAdjudicationContext, runPlanningResponseAdjudication, runPlanningReviewerAdjudication, synthesizePlanReviewRound, } from '../../adjudicator/planning.js';
6
6
  import { assertAdjudicationTransitionSignal } from '../../adjudicator/specs.js';
7
7
  import { getPlanReviewDebtRoundThreshold, getReviewStuckWindow } from '../../config.js';
8
+ import { OPEN_FINDINGS_PROMPT_ITEM_LIMIT } from '../../context/inline-review-context.js';
8
9
  import { toPlanReviewDebt } from '../../review-debt.js';
9
10
  import { writeDiagnostic } from '../../diagnostic.js';
10
11
  import { getWorktreeStatus } from '../../git.js';
@@ -402,7 +403,14 @@ export async function runPlanningResponsePhase(state, statePath, phase, logger)
402
403
  if (isPlanRefinementState(state)) {
403
404
  writeDiagnostic(`${formatPlanRefinementRoundLine({ round: state.rounds.length + 1, maxRounds: state.maxRounds })}\n`, logger);
404
405
  }
405
- const openFindings = state.findings.filter(mode === 'optional' ? isOpenNonBlockingFinding : isOpenBlockingFinding);
406
+ // The bounded finding set for this response round: the prompt and the
407
+ // disposition eligibility below consume this same selection. Findings
408
+ // beyond the per-round limit stay open; when the presented set is fully
409
+ // dispositioned, this phase stays active and presents the next batch (see
410
+ // hasNextResponseBatch below), so the cap never strands a finding.
411
+ const openFindings = state.findings
412
+ .filter(mode === 'optional' ? isOpenNonBlockingFinding : isOpenBlockingFinding)
413
+ .slice(0, OPEN_FINDINGS_PROMPT_ITEM_LIMIT);
406
414
  // Recorded operator guidance must reach the planner. If a prior blocked response
407
415
  // closed every finding, the guidance would otherwise be silently discarded here
408
416
  // (finalizePlanReviewResponseWithoutOpenFindings clears pendingPlanReviewGuidance
@@ -459,6 +467,31 @@ export async function runPlanningResponsePhase(state, statePath, phase, logger)
459
467
  // hardening finding cannot be silently un-banked by an out-of-band disposition)
460
468
  // and matches the replay harness's open-blocking eligibility guard.
461
469
  const openFindingIds = new Set(openFindings.map((finding) => finding.id));
470
+ // Optional responses must disposition every presented finding exactly once,
471
+ // matching execute optional response coverage: a partial optional response
472
+ // would otherwise land acceptance below while presented findings — and every
473
+ // overflow batch beyond the per-round presentation limit — were never
474
+ // resolved. Out-of-set ids keep their documented no-op tolerance.
475
+ if (mode === 'optional' && codex.payload.outcome === 'responded') {
476
+ const seenPresentedIds = new Set();
477
+ const duplicateIds = new Set();
478
+ for (const response of codex.payload.responses) {
479
+ if (!openFindingIds.has(response.id)) {
480
+ continue;
481
+ }
482
+ if (seenPresentedIds.has(response.id)) {
483
+ duplicateIds.add(response.id);
484
+ }
485
+ seenPresentedIds.add(response.id);
486
+ }
487
+ if (duplicateIds.size > 0) {
488
+ throw new Error(`Planner optional response returned duplicate finding dispositions: ${[...duplicateIds].join(', ')}`);
489
+ }
490
+ const missingIds = openFindings.map((finding) => finding.id).filter((id) => !seenPresentedIds.has(id));
491
+ if (missingIds.length > 0) {
492
+ throw new Error(`Planner optional response did not disposition every presented finding: ${missingIds.join(', ')}`);
493
+ }
494
+ }
462
495
  const findings = state.findings.map((finding) => {
463
496
  if (!openFindingIds.has(finding.id)) {
464
497
  return finding;
@@ -474,6 +507,16 @@ export async function runPlanningResponsePhase(state, statePath, phase, logger)
474
507
  coderCommit: null,
475
508
  };
476
509
  });
510
+ // Response rounds batch: when the presented set was fully dispositioned but
511
+ // open findings of this round's kind remain (they were beyond the per-round
512
+ // presentation limit), stay in this response phase so the next batch is
513
+ // presented immediately instead of spending a plan-review round per batch.
514
+ // The full-disposition requirement guarantees the backlog strictly shrinks;
515
+ // a partially-skipped presented set falls through to the reviewer so the
516
+ // existing convergence machinery judges it.
517
+ const openSelector = mode === 'optional' ? isOpenNonBlockingFinding : isOpenBlockingFinding;
518
+ const presentedStillOpen = findings.some((finding) => openFindingIds.has(finding.id) && openSelector(finding));
519
+ const hasNextResponseBatch = !presentedStillOpen && findings.some(openSelector);
477
520
  const nextState = await saveState(statePath, {
478
521
  ...state,
479
522
  plannerSessionHandle: codex.sessionHandle,
@@ -485,17 +528,19 @@ export async function runPlanningResponsePhase(state, statePath, phase, logger)
485
528
  planReviewDebt: toPlanReviewDebt(findings),
486
529
  phase: dirtyWorktreeBlocker || codex.payload.outcome === 'blocked'
487
530
  ? 'blocked'
488
- : mode === 'optional'
489
- ? derivedPlanReview
490
- ? 'awaiting_derived_plan_execution'
491
- : 'done'
492
- : 'reviewer_plan',
531
+ : hasNextResponseBatch
532
+ ? phase
533
+ : mode === 'optional'
534
+ ? derivedPlanReview
535
+ ? 'awaiting_derived_plan_execution'
536
+ : 'done'
537
+ : 'reviewer_plan',
493
538
  status: dirtyWorktreeBlocker || codex.payload.outcome === 'blocked'
494
539
  ? 'blocked'
495
- : mode === 'optional' && !derivedPlanReview
540
+ : mode === 'optional' && !derivedPlanReview && !hasNextResponseBatch
496
541
  ? 'done'
497
542
  : 'running',
498
- derivedPlanStatus: mode === 'optional' && codex.payload.outcome !== 'blocked' && derivedPlanReview
543
+ derivedPlanStatus: mode === 'optional' && codex.payload.outcome !== 'blocked' && derivedPlanReview && !hasNextResponseBatch
499
544
  ? 'accepted'
500
545
  : state.derivedPlanStatus,
501
546
  blockedFromPhase: dirtyWorktreeBlocker || codex.payload.outcome === 'blocked' ? phase : null,
@@ -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
+ }
@@ -1,8 +1,8 @@
1
- import { renderInlinedRangeDiffSection, truncateInlineSectionBody } from '../context/inline-review-context.js';
1
+ import { AGENT_FREE_TEXT_SECTION_MAX_CHARS, boundChangedFileList, boundCommitSubjectList, boundFreeTextValues, boundOpenFindingsForPrompt, GIT_SUMMARY_SECTION_MAX_CHARS, renderInlinedRangeDiffSection, truncateInlineSectionBody, } from '../context/inline-review-context.js';
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 = [
@@ -107,6 +107,23 @@ export function buildLegacyScopePrompt(planDoc, progressText) {
107
107
  ].join('\n');
108
108
  }
109
109
  export const EARLIER_SCOPE_CHANGES_SECTION_HEADING = '## Earlier-scope changes to files in this diff';
110
+ // Render-only view of the coder's progress justification: the four free-text
111
+ // fields share the fixed aggregate free-text budget; the stored payload keeps
112
+ // its full text.
113
+ function boundProgressJustificationForPrompt(justification) {
114
+ const bounded = boundFreeTextValues([
115
+ justification.milestoneTargeted,
116
+ justification.newEvidence,
117
+ justification.whyNotRedundant,
118
+ justification.nextStepUnlocked,
119
+ ]);
120
+ return {
121
+ milestoneTargeted: bounded[0],
122
+ newEvidence: bounded[1],
123
+ whyNotRedundant: bounded[2],
124
+ nextStepUnlocked: bounded[3],
125
+ };
126
+ }
110
127
  // Rendered for every execute-scope review, with or without an overlap: a
111
128
  // tool-access reviewer can find earlier-scope history itself, and the rule
112
129
  // about what that history means must not depend on whether Neal inlined it.
@@ -119,13 +136,14 @@ export function buildReviewerPrompt(args) {
119
136
  throw new Error('Prompt spec scope_reviewer is missing primary or meaningful-progress coverage');
120
137
  }
121
138
  const accessMode = args.accessMode ?? 'tool-access';
139
+ const reviewLevel = args.reviewLevel ?? 'moderate';
122
140
  // A collected diff may legitimately be the empty string (a range with no
123
141
  // changes); distinguish "collected" (any string, including '') from "not
124
142
  // collected" (null/undefined) so an empty diff still rides the inlined channel
125
143
  // instead of falling back to git_diff-tool phrasing the reviewer cannot use.
126
144
  const rangeDiffInlined = accessMode === 'read-only' && args.inlinedRangeDiff !== null && args.inlinedRangeDiff !== undefined;
127
- const changedFilesText = args.changedFiles.length > 0 ? args.changedFiles.join('\n') : '(no changed files)';
128
- const commitsText = args.commits.length > 0 ? args.commits.join('\n') : '(no commits recorded)';
145
+ const changedFilesText = args.changedFiles.length > 0 ? boundChangedFileList(args.changedFiles).join('\n') : '(no changed files)';
146
+ const commitsText = args.commits.length > 0 ? boundCommitSubjectList(args.commits).join('\n') : '(no commits recorded)';
129
147
  const falsificationLines = getCodeReviewFalsificationLines({
130
148
  rangeLabel: 'commit range',
131
149
  gitInspectionExamples: `Use git commands against the repository, for example: git diff ${args.baseCommit}..${args.headCommit}, git show --stat ${args.headCommit}, and targeted path diffs or file reads for changed files.`,
@@ -166,8 +184,9 @@ export function buildReviewerPrompt(args) {
166
184
  ...getAdversarialReviewDoctrineLines({
167
185
  reviewSubject: 'the scope diff',
168
186
  }),
187
+ ...getReviewLevelCalibrationLines({ level: reviewLevel, outputContract: 'structured_findings' }),
169
188
  '',
170
- ...getFindingQualityLines(),
189
+ ...getFindingQualityLines({ outputContract: 'structured_findings', level: reviewLevel }),
171
190
  ...skepticismLines,
172
191
  ...regressionLines,
173
192
  EARLIER_SCOPE_PRESERVATION_LINE,
@@ -181,7 +200,7 @@ export function buildReviewerPrompt(args) {
181
200
  commitsText,
182
201
  '',
183
202
  'Diff stat:',
184
- args.diffStat || '(no diff stat)',
203
+ args.diffStat ? truncateInlineSectionBody(args.diffStat, GIT_SUMMARY_SECTION_MAX_CHARS) : '(no diff stat)',
185
204
  '',
186
205
  'Changed files:',
187
206
  changedFilesText,
@@ -200,10 +219,10 @@ export function buildReviewerPrompt(args) {
200
219
  'Use `meaningfulProgressRationale` to explain the convergence judgment against the parent objective and recent accepted-scope history. Do not use it to restate correctness findings.',
201
220
  '',
202
221
  'Coder progress justification for this scope:',
203
- JSON.stringify(args.progressJustification, null, 2),
222
+ JSON.stringify(boundProgressJustificationForPrompt(args.progressJustification), null, 2),
204
223
  '',
205
224
  'Recent accepted scope history for this parent objective:',
206
- args.recentHistorySummary,
225
+ truncateInlineSectionBody(args.recentHistorySummary, AGENT_FREE_TEXT_SECTION_MAX_CHARS),
207
226
  '',
208
227
  reviewHistoryLine,
209
228
  'If prior review history or continuity context describes a finding as fixed, rejected, or deferred, do not reopen the same claim from that history alone.',
@@ -302,7 +321,7 @@ export function buildCoderResponsePrompt(args) {
302
321
  ...getUserGuidanceLines('coder'),
303
322
  '',
304
323
  'Open findings:',
305
- JSON.stringify(args.openFindings, null, 2),
324
+ JSON.stringify(boundOpenFindingsForPrompt(args.openFindings), null, 2),
306
325
  '',
307
326
  'Current progress state:',
308
327
  buildProgressSection(args.progressText),
@@ -1,6 +1,12 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
3
  import { join } from 'node:path';
4
+ import { truncateInlineSectionBody } from '../context/inline-review-context.js';
5
+ // Per-role character cap for operator guidance inlined into prompts. Guidance
6
+ // beyond the cap is truncated at render time with an explicit marker; the
7
+ // guidance file itself is never modified. `neal check` warns when a guidance
8
+ // file exceeds this cap.
9
+ export const USER_GUIDANCE_MAX_CHARS = 20_000;
4
10
  export const GUIDANCE_ROLES = ['coder', 'reviewer', 'planner'];
5
11
  export const GUIDANCE_SECTION_HEADER = '## User Guidance';
6
12
  const cache = new Map();
@@ -38,7 +44,7 @@ export function getUserGuidanceLines(role) {
38
44
  if (!content) {
39
45
  return [];
40
46
  }
41
- return ['', GUIDANCE_SECTION_HEADER, '', content];
47
+ return ['', GUIDANCE_SECTION_HEADER, '', truncateInlineSectionBody(content, USER_GUIDANCE_MAX_CHARS)];
42
48
  }
43
49
  export function clearUserGuidanceCache() {
44
50
  cache.clear();
@@ -52,6 +58,7 @@ export function collectGuidanceDiagnostics() {
52
58
  entries.push({
53
59
  role,
54
60
  bytes: Buffer.byteLength(entry.content, 'utf8'),
61
+ chars: entry.content.length,
55
62
  path: entry.path,
56
63
  });
57
64
  }
@@ -1,6 +1,7 @@
1
+ import { boundOpenFindingsForPrompt, truncateInlineSectionBody } from '../context/inline-review-context.js';
1
2
  import { AUTONOMY_BLOCKED, AUTONOMY_DONE, getCanonicalPlanContractLines, getDerivedPlanSectionContractLines, getProtocolMarkerArtifactProhibitionLines, getTerminalMarkerArtifactBoundaryLines, } from './shared.js';
2
3
  import { assertPromptBuilder } from './assert-builder.js';
3
- import { getUserGuidanceLines } from './guidance.js';
4
+ import { getUserGuidanceLines, USER_GUIDANCE_MAX_CHARS } from './guidance.js';
4
5
  const PROMPT_MODULE_PATH = 'src/neal/prompts/planning.ts';
5
6
  const PLAN_VERIFICATION_NECESSITY_RULE = 'A repository-wide invariant or global regression guarantee belongs in the plan only when it is necessary for the requested change to be correct.';
6
7
  function getPlanVerificationScopeLines(role) {
@@ -278,6 +279,9 @@ export function buildCoderPlanResponsePrompt(args) {
278
279
  mode === 'blocking'
279
280
  ? 'Address the currently open review findings provided below.'
280
281
  : 'The currently open review findings below are non-blocking. Decide whether to address each one now or explicitly reject/defer it with rationale.',
282
+ ...(mode === 'optional'
283
+ ? ['Return exactly one disposition for every finding listed below; a partial response is rejected.']
284
+ : []),
281
285
  reviewMode === 'derived-plan'
282
286
  ? 'Edit only the derived plan artifact and directly related planning notes for that derived plan.'
283
287
  : 'Edit only the plan document and directly related planning artifacts.',
@@ -308,13 +312,15 @@ export function buildCoderPlanResponsePrompt(args) {
308
312
  ...(args.planReviewGuidance
309
313
  ? [
310
314
  'Operator guidance for this blocked plan-review recovery:',
311
- args.planReviewGuidance.message,
315
+ // Same render-time cap as the operator guidance files; the persisted
316
+ // guidance record keeps its full message.
317
+ truncateInlineSectionBody(args.planReviewGuidance.message, USER_GUIDANCE_MAX_CHARS),
312
318
  '',
313
319
  'This guidance supplements the open reviewer findings. It does not waive plan-contract requirements, verification requirements, or the need to address blocking findings.',
314
320
  '',
315
321
  ]
316
322
  : []),
317
323
  'Open findings:',
318
- JSON.stringify(args.openFindings, null, 2),
324
+ JSON.stringify(boundOpenFindingsForPrompt(args.openFindings), null, 2),
319
325
  ].join('\n');
320
326
  }