@navels/neal 0.5.1 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +53 -0
- package/dist/neal/adjudicator/execute.js +32 -6
- package/dist/neal/agents/prompts.js +19 -5
- package/dist/neal/agents/rounds.js +33 -4
- package/dist/neal/agents/schemas.js +34 -1
- package/dist/neal/commands/check.js +8 -1
- package/dist/neal/commands/resume-run.js +7 -1
- package/dist/neal/config.js +24 -1
- package/dist/neal/context/inline-review-context.js +116 -0
- package/dist/neal/final-completion.js +4 -7
- package/dist/neal/orchestrator/phases/planning.js +53 -8
- package/dist/neal/orchestrator/phases/recovery.js +99 -8
- package/dist/neal/plan-scope-revision.js +129 -0
- package/dist/neal/prompts/execute.js +28 -9
- package/dist/neal/prompts/guidance.js +8 -1
- package/dist/neal/prompts/planning.js +9 -3
- package/dist/neal/prompts/review-doctrine.js +39 -1
- package/dist/neal/prompts/specialized.js +87 -22
- package/dist/neal/prompts/specs.js +33 -5
- package/dist/neal/providers/input-budget.js +80 -0
- package/dist/neal/providers/openai-codex.js +69 -1
- package/dist/neal/review-findings/run.js +17 -3
- package/dist/neal/scopes.js +4 -4
- package/dist/neal/state-invariants.js +8 -2
- package/dist/neal/state.js +15 -0
- package/dist/neal/status.js +52 -1
- package/dist/neal/support.js +6 -2
- package/dist/neal/verification-events.js +49 -0
- package/docs/plan-format.md +10 -0
- package/docs/prompt-specs.md +80 -0
- package/docs/providers.md +19 -0
- package/docs/state-machine.md +6 -0
- package/neal.yml +10 -1
- package/package.json +3 -3
|
@@ -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
|
-
|
|
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
|
-
:
|
|
489
|
-
?
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
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
|
|
148
|
-
// run continues. Consumes one unit of the per-scope consultant budget
|
|
150
|
+
// a human-supplied `neal resume --message` except that the turn's `origin` is
|
|
151
|
+
// `consultant`, so the coder consumes it and the run continues. Consumes one unit of the per-scope consultant budget
|
|
149
152
|
// (`consultantAttemptCount`) and persists the anti-thrash `recentBlocks`. The
|
|
150
153
|
// caller has already emitted the `consultant.verdict` audit event for this
|
|
151
154
|
// verdict.
|
|
@@ -170,7 +173,7 @@ async function applyRecoverableConsultantDirective(args) {
|
|
|
170
173
|
sourcePhase: enteredState.interactiveBlockedRecovery?.sourcePhase,
|
|
171
174
|
blockedReason: reason,
|
|
172
175
|
});
|
|
173
|
-
const resolvedState = await
|
|
176
|
+
const resolvedState = await recordInteractiveBlockedRecoveryTurn(statePath, resolutionDirective, 'consultant', logger);
|
|
174
177
|
await logger?.event('consultant.resolved', {
|
|
175
178
|
scopeNumber: resolvedState.currentScopeNumber,
|
|
176
179
|
sourcePhase,
|
|
@@ -253,6 +256,11 @@ export function shouldNotifyInteractiveBlockedRecoveryEntry(state) {
|
|
|
253
256
|
return getInteractiveRecoveryView(state)?.waitingForOperatorGuidance ?? false;
|
|
254
257
|
}
|
|
255
258
|
export async function recordInteractiveBlockedRecoveryGuidance(statePath, operatorGuidance, logger) {
|
|
259
|
+
return recordInteractiveBlockedRecoveryTurn(statePath, operatorGuidance, 'operator', logger);
|
|
260
|
+
}
|
|
261
|
+
// Shared by operator guidance (`neal resume --message`) and the consultant
|
|
262
|
+
// injection path; `origin` marks the turn with whichever created it.
|
|
263
|
+
async function recordInteractiveBlockedRecoveryTurn(statePath, operatorGuidance, origin, logger) {
|
|
256
264
|
const trimmedGuidance = operatorGuidance.trim();
|
|
257
265
|
if (!trimmedGuidance) {
|
|
258
266
|
throw new Error('Recovery guidance must not be empty');
|
|
@@ -278,6 +286,7 @@ export async function recordInteractiveBlockedRecoveryGuidance(statePath, operat
|
|
|
278
286
|
recordedAt: new Date().toISOString(),
|
|
279
287
|
operatorGuidance: trimmedGuidance,
|
|
280
288
|
terminalOnly: true,
|
|
289
|
+
origin,
|
|
281
290
|
},
|
|
282
291
|
},
|
|
283
292
|
});
|
|
@@ -299,6 +308,7 @@ export async function recordInteractiveBlockedRecoveryGuidance(statePath, operat
|
|
|
299
308
|
number: turns.length + 1,
|
|
300
309
|
recordedAt: new Date().toISOString(),
|
|
301
310
|
operatorGuidance: trimmedGuidance,
|
|
311
|
+
origin,
|
|
302
312
|
disposition: null,
|
|
303
313
|
},
|
|
304
314
|
],
|
|
@@ -339,6 +349,7 @@ function withRecordedInteractiveBlockedRecoveryDisposition(state, disposition, s
|
|
|
339
349
|
number: state.interactiveBlockedRecovery.turns.length + 1,
|
|
340
350
|
recordedAt: state.interactiveBlockedRecovery.pendingDirective.recordedAt,
|
|
341
351
|
operatorGuidance: state.interactiveBlockedRecovery.pendingDirective.operatorGuidance,
|
|
352
|
+
origin: state.interactiveBlockedRecovery.pendingDirective.origin,
|
|
342
353
|
disposition: {
|
|
343
354
|
recordedAt: new Date().toISOString(),
|
|
344
355
|
sessionHandle,
|
|
@@ -347,6 +358,8 @@ function withRecordedInteractiveBlockedRecoveryDisposition(state, disposition, s
|
|
|
347
358
|
rationale: disposition.rationale,
|
|
348
359
|
blocker: disposition.blocker.trim(),
|
|
349
360
|
replacementPlan: disposition.replacementPlan.trim(),
|
|
361
|
+
laterScopeNumber: disposition.laterScopeNumber,
|
|
362
|
+
laterScopeBody: disposition.laterScopeBody,
|
|
350
363
|
resultingPhase,
|
|
351
364
|
},
|
|
352
365
|
},
|
|
@@ -373,6 +386,8 @@ function withRecordedInteractiveBlockedRecoveryDisposition(state, disposition, s
|
|
|
373
386
|
rationale: disposition.rationale,
|
|
374
387
|
blocker: disposition.blocker.trim(),
|
|
375
388
|
replacementPlan: disposition.replacementPlan.trim(),
|
|
389
|
+
laterScopeNumber: disposition.laterScopeNumber,
|
|
390
|
+
laterScopeBody: disposition.laterScopeBody,
|
|
376
391
|
resultingPhase,
|
|
377
392
|
},
|
|
378
393
|
}
|
|
@@ -422,6 +437,78 @@ function getInteractiveBlockedRecoveryResumePhase(sourcePhase) {
|
|
|
422
437
|
return sourcePhase;
|
|
423
438
|
}
|
|
424
439
|
}
|
|
440
|
+
// Reads the top-level plan (always `state.planDoc`, even while a derived plan
|
|
441
|
+
// is executing) at the time the disposition is applied, splices the revised
|
|
442
|
+
// scope entry in, and writes the file. The plan is read fresh from disk on
|
|
443
|
+
// every later turn, so nothing in state needs to change.
|
|
444
|
+
async function applyLaterScopeRevision(state, disposition, logger) {
|
|
445
|
+
if (disposition.laterScopeNumber === 0 && disposition.laterScopeBody.trim() === '') {
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
if (!isOperatorGuidedRecoveryTurn(state)) {
|
|
449
|
+
throw new Error('Interactive blocked recovery cannot apply the later-scope revision: only operator guidance may direct a later-scope revision, and the pending guidance is not an operator message.');
|
|
450
|
+
}
|
|
451
|
+
const planDocument = await readFile(state.planDoc, 'utf8');
|
|
452
|
+
const errors = getCoderBlockedRecoveryLaterScopeErrors(disposition, {
|
|
453
|
+
allowLaterScopeRevision: true,
|
|
454
|
+
currentScopeNumber: state.currentScopeNumber,
|
|
455
|
+
planDocument,
|
|
456
|
+
});
|
|
457
|
+
if (errors.length > 0) {
|
|
458
|
+
throw new Error(`Interactive blocked recovery cannot apply the later-scope revision: ${errors.join(' ')}`);
|
|
459
|
+
}
|
|
460
|
+
const result = reviseLaterScope({
|
|
461
|
+
planDocument,
|
|
462
|
+
currentScopeNumber: state.currentScopeNumber,
|
|
463
|
+
targetScopeNumber: disposition.laterScopeNumber,
|
|
464
|
+
replacementBody: disposition.laterScopeBody,
|
|
465
|
+
});
|
|
466
|
+
if (!result.ok) {
|
|
467
|
+
throw new Error(`Interactive blocked recovery cannot apply the later-scope revision: ${result.errors.join(' ')}`);
|
|
468
|
+
}
|
|
469
|
+
await writeFile(state.planDoc, result.document, 'utf8');
|
|
470
|
+
await logger?.event('interactive_blocked_recovery.later_scope_revised', {
|
|
471
|
+
scopeNumber: state.currentScopeNumber,
|
|
472
|
+
laterScopeNumber: disposition.laterScopeNumber,
|
|
473
|
+
planDoc: state.planDoc,
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
// Whether the pending guidance is an operator message. Only an operator message
|
|
477
|
+
// may direct a later-scope revision. The turn's `origin` marker is set where the
|
|
478
|
+
// turn is created: `operator` by `neal resume --message`, `consultant` by the
|
|
479
|
+
// consultant-injection path. A turn persisted without the marker, and the
|
|
480
|
+
// operator's turn-cap directive (which cannot choose an action that carries a
|
|
481
|
+
// revision anyway), do not qualify.
|
|
482
|
+
function isOperatorGuidedRecoveryTurn(state) {
|
|
483
|
+
const recovery = state.interactiveBlockedRecovery;
|
|
484
|
+
if (!recovery || recovery.pendingDirective) {
|
|
485
|
+
return false;
|
|
486
|
+
}
|
|
487
|
+
const latestTurn = recovery.turns.at(-1);
|
|
488
|
+
return latestTurn !== undefined && latestTurn.number > recovery.lastHandledTurn && latestTurn.origin === 'operator';
|
|
489
|
+
}
|
|
490
|
+
async function getLaterScopeRevisionOffer(state, terminalOnly) {
|
|
491
|
+
if (terminalOnly || !isOperatorGuidedRecoveryTurn(state)) {
|
|
492
|
+
return null;
|
|
493
|
+
}
|
|
494
|
+
let planDocument;
|
|
495
|
+
try {
|
|
496
|
+
planDocument = await readFile(state.planDoc, 'utf8');
|
|
497
|
+
}
|
|
498
|
+
catch {
|
|
499
|
+
return null;
|
|
500
|
+
}
|
|
501
|
+
const eligibility = getLaterScopeRevisionEligibility(planDocument, state.currentScopeNumber);
|
|
502
|
+
if (!eligibility.eligible) {
|
|
503
|
+
return null;
|
|
504
|
+
}
|
|
505
|
+
return {
|
|
506
|
+
topLevelPlanDoc: state.planDoc,
|
|
507
|
+
planDocument,
|
|
508
|
+
currentScopeNumber: state.currentScopeNumber,
|
|
509
|
+
scopeCount: eligibility.scopeCount,
|
|
510
|
+
};
|
|
511
|
+
}
|
|
425
512
|
export async function applyInteractiveBlockedRecoveryDisposition(state, statePath, disposition, sessionHandle, logger) {
|
|
426
513
|
if (state.phase !== 'interactive_blocked_recovery' || !state.interactiveBlockedRecovery) {
|
|
427
514
|
throw new Error(`Run is not in interactive blocked recovery: ${statePath}`);
|
|
@@ -430,11 +517,11 @@ export async function applyInteractiveBlockedRecoveryDisposition(state, statePat
|
|
|
430
517
|
throw new Error('Interactive blocked recovery is only supported for execute-mode runs');
|
|
431
518
|
}
|
|
432
519
|
const latestTurn = state.interactiveBlockedRecovery.turns.at(-1);
|
|
433
|
-
const
|
|
434
|
-
if (!latestTurn && !
|
|
520
|
+
const pendingDirective = state.interactiveBlockedRecovery.pendingDirective;
|
|
521
|
+
if (!latestTurn && !pendingDirective) {
|
|
435
522
|
throw new Error('Interactive blocked recovery requires recorded operator guidance before a coder response can be applied.');
|
|
436
523
|
}
|
|
437
|
-
if (
|
|
524
|
+
if (pendingDirective &&
|
|
438
525
|
disposition.action !== 'replace_current_scope' &&
|
|
439
526
|
disposition.action !== 'terminal_block') {
|
|
440
527
|
throw new Error('Interactive blocked recovery reached its turn cap and now only allows replace_current_scope or terminal_block.');
|
|
@@ -448,6 +535,7 @@ export async function applyInteractiveBlockedRecoveryDisposition(state, statePat
|
|
|
448
535
|
action: disposition.action,
|
|
449
536
|
sessionHandle,
|
|
450
537
|
});
|
|
538
|
+
await applyLaterScopeRevision(state, disposition, logger);
|
|
451
539
|
if (disposition.action === 'replace_current_scope') {
|
|
452
540
|
const persistedState = await persistSplitPlanRecovery({
|
|
453
541
|
...state,
|
|
@@ -541,12 +629,14 @@ export async function runInteractiveBlockedRecoveryPhase(state, statePath, logge
|
|
|
541
629
|
if (!hasPendingTurn && !pendingDirective) {
|
|
542
630
|
throw new Error('Interactive blocked recovery has no pending operator guidance to process.');
|
|
543
631
|
}
|
|
632
|
+
const terminalOnly = Boolean(pendingDirective?.terminalOnly);
|
|
544
633
|
await logger?.event('phase.start', {
|
|
545
634
|
phase: 'interactive_blocked_recovery',
|
|
546
635
|
recoveryTurn: pendingDirective ? state.interactiveBlockedRecovery.turns.length : latestTurn?.number,
|
|
547
636
|
sourcePhase: state.interactiveBlockedRecovery.sourcePhase,
|
|
548
|
-
terminalOnly
|
|
637
|
+
terminalOnly,
|
|
549
638
|
});
|
|
639
|
+
const laterScopeRevision = await getLaterScopeRevisionOffer(state, terminalOnly);
|
|
550
640
|
let codex;
|
|
551
641
|
try {
|
|
552
642
|
codex = await runBlockedRecoveryCoderRound({
|
|
@@ -559,8 +649,9 @@ export async function runInteractiveBlockedRecoveryPhase(state, statePath, logge
|
|
|
559
649
|
operatorGuidance: pendingDirective?.operatorGuidance ?? latestTurn?.operatorGuidance ?? state.interactiveBlockedRecovery.blockedReason,
|
|
560
650
|
maxTurns: state.interactiveBlockedRecovery.maxTurns,
|
|
561
651
|
turnsTaken: pendingDirective ? state.interactiveBlockedRecovery.turns.length : latestTurn?.number ?? 0,
|
|
562
|
-
terminalOnly
|
|
652
|
+
terminalOnly,
|
|
563
653
|
allowReplacement: true,
|
|
654
|
+
laterScopeRevision,
|
|
564
655
|
sessionHandle: state.coderSessionHandle,
|
|
565
656
|
logger,
|
|
566
657
|
});
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { validatePlanDocument } from './plan-validation.js';
|
|
2
|
+
const EXECUTION_QUEUE_HEADER = '## Execution Queue';
|
|
3
|
+
const SCOPE_HEADING_PATTERN = /^### Scope (\d+):/;
|
|
4
|
+
const SECTION_HEADING_PATTERN = /^##(#)? /;
|
|
5
|
+
/**
|
|
6
|
+
* Decides whether operator guidance may revise a later top-level scope in
|
|
7
|
+
* `planDocument`: the document validates, its shape is `multi_scope`, it is
|
|
8
|
+
* already in canonical `## Execution Queue` / `### Scope N:` form on disk, and
|
|
9
|
+
* it has at least one scope after `currentScopeNumber`.
|
|
10
|
+
*/
|
|
11
|
+
export function getLaterScopeRevisionEligibility(planDocument, currentScopeNumber) {
|
|
12
|
+
const reasons = collectCanonicalMultiScopeErrors(planDocument);
|
|
13
|
+
const scopeCount = reasons.length === 0 ? collectScopeEntries(planDocument.split('\n')).length : 0;
|
|
14
|
+
if (reasons.length === 0 && scopeCount <= currentScopeNumber) {
|
|
15
|
+
reasons.push(`The plan has ${scopeCount} scope(s) and the current scope is ${currentScopeNumber}, so there is no later scope to revise.`);
|
|
16
|
+
}
|
|
17
|
+
return { eligible: reasons.length === 0, scopeCount, reasons };
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Replaces exactly one later `### Scope N:` entry in `planDocument` with
|
|
21
|
+
* `replacementBody`. The splice is bounded by the next `### `/`## ` heading
|
|
22
|
+
* (or end of file), so nothing outside the target entry changes. The revised
|
|
23
|
+
* document must still validate with the same scope count.
|
|
24
|
+
*/
|
|
25
|
+
export function reviseLaterScope(input) {
|
|
26
|
+
const { planDocument, currentScopeNumber, targetScopeNumber, replacementBody } = input;
|
|
27
|
+
const errors = collectCanonicalMultiScopeErrors(planDocument);
|
|
28
|
+
if (errors.length > 0) {
|
|
29
|
+
return { ok: false, errors };
|
|
30
|
+
}
|
|
31
|
+
const lines = planDocument.split('\n');
|
|
32
|
+
const entries = collectScopeEntries(lines);
|
|
33
|
+
const scopeCount = entries.length;
|
|
34
|
+
if (!Number.isInteger(targetScopeNumber) || targetScopeNumber <= currentScopeNumber) {
|
|
35
|
+
errors.push(`Target scope ${targetScopeNumber} must be a later scope than the current scope ${currentScopeNumber}.`);
|
|
36
|
+
}
|
|
37
|
+
if (Number.isInteger(targetScopeNumber) && targetScopeNumber > scopeCount) {
|
|
38
|
+
errors.push(`Target scope ${targetScopeNumber} is past the plan's scope count of ${scopeCount}.`);
|
|
39
|
+
}
|
|
40
|
+
errors.push(...collectReplacementBodyErrors(replacementBody, targetScopeNumber));
|
|
41
|
+
if (errors.length > 0) {
|
|
42
|
+
return { ok: false, errors };
|
|
43
|
+
}
|
|
44
|
+
const target = entries.find((entry) => entry.number === targetScopeNumber);
|
|
45
|
+
if (target === undefined) {
|
|
46
|
+
return { ok: false, errors: [`Target scope ${targetScopeNumber} has no \`### Scope ${targetScopeNumber}:\` heading in the plan.`] };
|
|
47
|
+
}
|
|
48
|
+
const bodyLines = trimTrailingBlankLines(replacementBody.split('\n'));
|
|
49
|
+
const contentEnd = target.start + trimTrailingBlankLines(lines.slice(target.start, target.end)).length;
|
|
50
|
+
const revisedLines = [...lines.slice(0, target.start), ...bodyLines, ...lines.slice(contentEnd)];
|
|
51
|
+
const document = revisedLines.join('\n');
|
|
52
|
+
const validation = validatePlanDocument(document);
|
|
53
|
+
if (!validation.ok) {
|
|
54
|
+
return { ok: false, errors: validation.errors.map((error) => `Revised plan does not validate: ${error}`) };
|
|
55
|
+
}
|
|
56
|
+
const revisedCount = collectScopeEntries(document.split('\n')).length;
|
|
57
|
+
if (revisedCount !== scopeCount) {
|
|
58
|
+
return {
|
|
59
|
+
ok: false,
|
|
60
|
+
errors: [`Revised plan has ${revisedCount} scope(s) but the original has ${scopeCount}; the scope count must not change.`],
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
return { ok: true, document };
|
|
64
|
+
}
|
|
65
|
+
function collectCanonicalMultiScopeErrors(planDocument) {
|
|
66
|
+
const validation = validatePlanDocument(planDocument);
|
|
67
|
+
if (!validation.ok) {
|
|
68
|
+
return validation.errors.map((error) => `Plan does not validate: ${error}`);
|
|
69
|
+
}
|
|
70
|
+
if (validation.executionShape !== 'multi_scope') {
|
|
71
|
+
return [`Plan shape is \`${validation.executionShape}\`; only \`multi_scope\` plans have later scopes to revise.`];
|
|
72
|
+
}
|
|
73
|
+
if (validation.normalization.applied) {
|
|
74
|
+
return [
|
|
75
|
+
'Plan uses an alias form that is normalized in memory; only plans already in canonical `## Execution Queue` / `### Scope N:` form can be revised.',
|
|
76
|
+
];
|
|
77
|
+
}
|
|
78
|
+
return [];
|
|
79
|
+
}
|
|
80
|
+
function collectReplacementBodyErrors(replacementBody, targetScopeNumber) {
|
|
81
|
+
const errors = [];
|
|
82
|
+
const bodyLines = replacementBody.split('\n');
|
|
83
|
+
const firstLine = bodyLines[0] ?? '';
|
|
84
|
+
const headingMatch = SCOPE_HEADING_PATTERN.exec(firstLine.trim());
|
|
85
|
+
if (headingMatch === null || Number(headingMatch[1]) !== targetScopeNumber) {
|
|
86
|
+
errors.push(`Replacement body must start with the line \`### Scope ${targetScopeNumber}:\`.`);
|
|
87
|
+
}
|
|
88
|
+
const extraHeadings = bodyLines.slice(1).filter((line) => SECTION_HEADING_PATTERN.test(line.trim()));
|
|
89
|
+
if (extraHeadings.length > 0) {
|
|
90
|
+
errors.push(`Replacement body must contain exactly one scope entry; found additional heading(s): ${extraHeadings.map((line) => `\`${line.trim()}\``).join(', ')}.`);
|
|
91
|
+
}
|
|
92
|
+
return errors;
|
|
93
|
+
}
|
|
94
|
+
function collectScopeEntries(lines) {
|
|
95
|
+
const queueStart = lines.findIndex((line) => line.trim() === EXECUTION_QUEUE_HEADER);
|
|
96
|
+
if (queueStart === -1) {
|
|
97
|
+
return [];
|
|
98
|
+
}
|
|
99
|
+
let queueEnd = lines.length;
|
|
100
|
+
for (let index = queueStart + 1; index < lines.length; index += 1) {
|
|
101
|
+
if (/^## /.test(lines[index].trim())) {
|
|
102
|
+
queueEnd = index;
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
const entries = [];
|
|
107
|
+
for (let index = queueStart + 1; index < queueEnd; index += 1) {
|
|
108
|
+
const match = SCOPE_HEADING_PATTERN.exec(lines[index].trim());
|
|
109
|
+
if (match === null) {
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
let end = queueEnd;
|
|
113
|
+
for (let cursor = index + 1; cursor < queueEnd; cursor += 1) {
|
|
114
|
+
if (SECTION_HEADING_PATTERN.test(lines[cursor].trim())) {
|
|
115
|
+
end = cursor;
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
entries.push({ number: Number(match[1]), start: index, end });
|
|
120
|
+
}
|
|
121
|
+
return entries;
|
|
122
|
+
}
|
|
123
|
+
function trimTrailingBlankLines(lines) {
|
|
124
|
+
const trimmed = [...lines];
|
|
125
|
+
while (trimmed.length > 0 && trimmed.at(-1)?.trim() === '') {
|
|
126
|
+
trimmed.pop();
|
|
127
|
+
}
|
|
128
|
+
return trimmed;
|
|
129
|
+
}
|
|
@@ -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
|
|
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
|
-
|
|
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
|
}
|