@navels/neal 0.2.0 → 0.3.0
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 +206 -209
- package/SECURITY.md +16 -19
- package/dist/neal/adjudicator/{blocked-adjudicator.js → consultant.js} +21 -21
- package/dist/neal/adjudicator/execute.js +33 -33
- package/dist/neal/adjudicator/final-completion.js +2 -31
- package/dist/neal/adjudicator/planning.js +230 -38
- package/dist/neal/agents/prompts.js +6 -17
- package/dist/neal/agents/rounds.js +43 -73
- package/dist/neal/agents/schemas.js +47 -13
- package/dist/neal/blocked-guidance.js +16 -9
- package/dist/neal/cli.js +2 -2
- package/dist/neal/commands/check.js +2 -2
- package/dist/neal/commands/compat.js +100 -36
- package/dist/neal/commands/interactive-activity.js +2 -2
- package/dist/neal/commands/recovery-guidance.js +20 -8
- package/dist/neal/commands/runtime.js +4 -2
- package/dist/neal/config.js +16 -7
- package/dist/neal/context/inline-review-context.js +13 -28
- package/dist/neal/context/reviewer-context.js +19 -14
- package/dist/neal/eval/reviewer-eval-manifest.js +86 -0
- package/dist/neal/eval/reviewer-eval.js +155 -0
- package/dist/neal/interactive-controls.js +5 -0
- package/dist/neal/orchestrator/completion.js +49 -2
- package/dist/neal/orchestrator/failures.js +7 -0
- package/dist/neal/orchestrator/notifications.js +9 -9
- package/dist/neal/orchestrator/phases/planning.js +117 -80
- package/dist/neal/orchestrator/phases/recovery.js +151 -115
- package/dist/neal/orchestrator/phases/review.js +28 -1
- package/dist/neal/orchestrator/transitions.js +7 -1
- package/dist/neal/orchestrator.js +3 -0
- package/dist/neal/plan-queue.js +29 -1
- package/dist/neal/prompts/execute.js +7 -19
- package/dist/neal/prompts/planning.js +64 -55
- package/dist/neal/prompts/review-doctrine.js +13 -50
- package/dist/neal/prompts/specialized.js +7 -17
- package/dist/neal/prompts/specs.js +180 -32
- package/dist/neal/providers/agent-settings-isolation.js +35 -0
- package/dist/neal/providers/anthropic-claude.js +18 -1
- package/dist/neal/providers/detection.js +3 -7
- package/dist/neal/providers/git-config-isolation.js +83 -0
- package/dist/neal/providers/openai-codex.js +25 -3
- package/dist/neal/providers/{generic-agentic-tools.js → openai-compatible-tools.js} +11 -5
- package/dist/neal/providers/openai-compatible.js +1078 -430
- package/dist/neal/providers/openrouter-routing.js +68 -0
- package/dist/neal/providers/pricing.js +1 -1
- package/dist/neal/providers/rate-card.js +6 -32
- package/dist/neal/providers/registry.js +6 -17
- package/dist/neal/providers/types.js +12 -0
- package/dist/neal/resume-decision.js +7 -6
- package/dist/neal/resume-planner.js +9 -0
- package/dist/neal/retrospective.js +3 -3
- package/dist/neal/review-debt.js +30 -0
- package/dist/neal/review-findings/provider.js +17 -29
- package/dist/neal/review.js +17 -1
- package/dist/neal/state-invariants.js +11 -3
- package/dist/neal/state-views.js +27 -5
- package/dist/neal/state.js +59 -15
- package/dist/neal/status.js +28 -1
- package/dist/neal/support.js +3 -3
- package/docs/README.md +22 -18
- package/docs/{ADJUDICATOR_INVENTORY.md → adjudicator-inventory.md} +58 -57
- package/docs/architecture.md +79 -83
- package/docs/automation.md +11 -11
- package/docs/compat.md +91 -61
- package/docs/compatible-models.md +145 -121
- package/docs/demo.md +15 -5
- package/docs/maintenance.md +65 -34
- package/docs/plan-format.md +61 -33
- package/docs/prompt-evals.md +96 -0
- package/docs/{PROMPT_SPECS.md → prompt-specs.md} +52 -48
- package/docs/providers.md +279 -349
- package/docs/release.md +18 -19
- package/docs/review-convergence.md +246 -0
- package/docs/state-machine.md +61 -59
- package/docs/storage.md +40 -31
- package/docs/troubleshooting.md +32 -29
- package/examples/compat/add-edit-verify/PLAN.md +2 -0
- package/examples/compat/add-edit-verify/broken.diff +2 -2
- package/examples/compat/add-edit-verify/good.diff +2 -2
- package/examples/compat/add-edit-verify/src/add.js +1 -1
- package/examples/compat/manifest.json +25 -29
- package/examples/compat/sum-grep-edit/PLAN.md +33 -0
- package/examples/compat/sum-grep-edit/broken.diff +12 -0
- package/examples/compat/sum-grep-edit/good.diff +12 -0
- package/examples/compat/sum-grep-edit/package.json +5 -0
- package/examples/compat/sum-grep-edit/src/numbers.js +9 -0
- package/examples/compat/sum-grep-edit/test/numbers.test.js +15 -0
- package/examples/issue-triage-js/README.md +0 -15
- package/examples/reviewer-eval/clean-doc/base/src/clamp.ts +3 -0
- package/examples/reviewer-eval/clean-doc/change.diff +9 -0
- package/examples/reviewer-eval/clean-extract/base/src/greet.ts +3 -0
- package/examples/reviewer-eval/clean-extract/change.diff +13 -0
- package/examples/reviewer-eval/dropped-error/base/src/load.ts +4 -0
- package/examples/reviewer-eval/dropped-error/change.diff +15 -0
- package/examples/reviewer-eval/manifest.json +45 -0
- package/examples/reviewer-eval/off-by-one/base/src/paginate.ts +5 -0
- package/examples/reviewer-eval/off-by-one/change.diff +11 -0
- package/{config.yml → neal.yml} +1 -1
- package/package.json +15 -14
- package/dist/neal/providers/generic-agentic.js +0 -1256
- package/docs/comparison.md +0 -105
- package/docs/issue-pipeline.md +0 -124
- package/examples/compat/is-even-add-test/PLAN.md +0 -30
- package/examples/compat/is-even-add-test/broken.diff +0 -11
- package/examples/compat/is-even-add-test/good.diff +0 -11
- package/examples/compat/is-even-add-test/package.json +0 -5
- package/examples/compat/is-even-add-test/src/is-even.js +0 -3
- package/examples/compat/is-even-add-test/test/is-even.test.js +0 -9
- package/examples/compat/reverse-grep-edit/PLAN.md +0 -32
- package/examples/compat/reverse-grep-edit/broken.diff +0 -12
- package/examples/compat/reverse-grep-edit/good.diff +0 -12
- package/examples/compat/reverse-grep-edit/package.json +0 -5
- package/examples/compat/reverse-grep-edit/src/strings.js +0 -10
- package/examples/compat/reverse-grep-edit/test/strings.test.js +0 -15
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
2
|
import { dirname, join, parse } from 'node:path';
|
|
3
3
|
import { CoderRoundError, ReviewerRoundError, runCoderPlanRound } from '../../agents.js';
|
|
4
|
-
import {
|
|
5
|
-
import { isDerivedPlanReviewState, plannerProviderStartsFreshSessions, resolvePlanningAdjudicationContext,
|
|
4
|
+
import { isOpenBlockingFinding, isOpenNonBlockingFinding, mapDecisionToStatus, } from '../../adjudicator/execute.js';
|
|
5
|
+
import { getDerivedPlanBlockedReason, isDerivedPlanReviewState, plannerProviderStartsFreshSessions, resolvePlanningAdjudicationContext, runPlanningResponseAdjudication, runPlanningReviewerAdjudication, synthesizePlanReviewRound, } from '../../adjudicator/planning.js';
|
|
6
6
|
import { assertAdjudicationTransitionSignal } from '../../adjudicator/specs.js';
|
|
7
|
-
import { getReviewStuckWindow } from '../../config.js';
|
|
7
|
+
import { getPlanReviewDebtRoundThreshold, getReviewStuckWindow } from '../../config.js';
|
|
8
|
+
import { toPlanReviewDebt } from '../../review-debt.js';
|
|
8
9
|
import { writeDiagnostic } from '../../diagnostic.js';
|
|
9
10
|
import { getWorktreeStatus } from '../../git.js';
|
|
10
11
|
import { formatPlanRefinementRoundLine, isPlanRefinementState } from '../../plan-refinement.js';
|
|
@@ -13,7 +14,7 @@ import { getCurrentScopeLabel, getExecutionPlanPath } from '../../scopes.js';
|
|
|
13
14
|
import { saveState } from '../../state.js';
|
|
14
15
|
import { filterAllowedDirtyPathStatus, filterWrapperOwnedWorktreeStatus, } from '../../worktree-status.js';
|
|
15
16
|
import { writeExecutionArtifacts } from '../artifacts.js';
|
|
16
|
-
import { isCoderTimeoutError, shouldNotifyFailure } from '../failures.js';
|
|
17
|
+
import { isCoderTimeoutError, REVIEWER_CONTENT_REFUSED_BLOCK_REASON, shouldNotifyFailure } from '../failures.js';
|
|
17
18
|
import { flushDerivedPlanNotifications, notifyBlocked, notifyComplete, } from '../notifications.js';
|
|
18
19
|
import { adoptAcceptedDerivedPlan, shouldNotifyDerivedPlanAcceptance, transitionPlanReviewWithoutOpenFindings, } from '../transitions.js';
|
|
19
20
|
import { enterInteractiveBlockedRecovery, shouldNotifyInteractiveBlockedRecoveryEntry } from './recovery.js';
|
|
@@ -58,6 +59,9 @@ export async function synthesizePlanReviewFindings(args) {
|
|
|
58
59
|
round: args.round,
|
|
59
60
|
source: 'plan_structure',
|
|
60
61
|
severity: 'blocking',
|
|
62
|
+
// Structural defects are always plan-correctness (fail-safe blocking);
|
|
63
|
+
// they must never be treated as convertible verification-hardening debt.
|
|
64
|
+
findingClass: 'plan_correctness',
|
|
61
65
|
files: [preparedReview.originalPlanPath],
|
|
62
66
|
claim: `Plan document structure is invalid: ${error}`,
|
|
63
67
|
requiredAction: 'Revise the plan document so it satisfies the required execution-shape and execution-queue contract.',
|
|
@@ -72,12 +76,6 @@ function getPlanningCompletionProblem(marker) {
|
|
|
72
76
|
}
|
|
73
77
|
return marker === 'AUTONOMY_DONE' ? null : 'Planning mode must end with AUTONOMY_DONE or AUTONOMY_BLOCKED.';
|
|
74
78
|
}
|
|
75
|
-
function getDerivedPlanBlockedReason(state, reason) {
|
|
76
|
-
if (!isDerivedPlanReviewState(state)) {
|
|
77
|
-
return reason;
|
|
78
|
-
}
|
|
79
|
-
return `split-plan recovery failed to converge: ${reason}`;
|
|
80
|
-
}
|
|
81
79
|
async function getPlanPhaseDirtyWorktreeBlocker(state, phase) {
|
|
82
80
|
const allowedDirtyPaths = [...new Set([...state.allowedDirtyPaths, state.planDoc])];
|
|
83
81
|
let statusOutput;
|
|
@@ -122,18 +120,34 @@ async function finalizePlanReviewResponseWithoutOpenFindings(state, statePath, p
|
|
|
122
120
|
}
|
|
123
121
|
return nextState;
|
|
124
122
|
}
|
|
125
|
-
export async function finalizeBlockedPlanReviewResponse(state, statePath, derivedPlanReview, blocker, logger) {
|
|
123
|
+
export async function finalizeBlockedPlanReviewResponse(state, statePath, derivedPlanReview, blocker, blockCause, logger) {
|
|
126
124
|
const { spec } = resolvePlanningAdjudicationContext(state);
|
|
127
125
|
assertAdjudicationTransitionSignal(spec, 'block_for_operator', 'orchestrator:planning:block_for_operator');
|
|
128
126
|
if (state.topLevelMode !== 'execute') {
|
|
129
|
-
//
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
127
|
+
// Top-level plan stage (`neal run`/`neal plan`).
|
|
128
|
+
if (blockCause === 'coder_authored') {
|
|
129
|
+
// Recoverable landing for a coder-authored *response* block: persist the
|
|
130
|
+
// durable blocker reason and leave `status:'blocked'` (writer exit 2) for
|
|
131
|
+
// an operator to answer via `neal resume --message`. Attended and
|
|
132
|
+
// unattended land identically now — both blocked-with-reason — because
|
|
133
|
+
// neither could previously answer this block (attended left it blocked but
|
|
134
|
+
// reason-less; unattended terminal-failed).
|
|
135
|
+
const blockedState = await saveState(statePath, {
|
|
136
|
+
...state,
|
|
137
|
+
blockerReason: blocker,
|
|
138
|
+
});
|
|
139
|
+
if (!derivedPlanReview) {
|
|
140
|
+
await notifyBlocked(blockedState, blocker, logger);
|
|
141
|
+
}
|
|
142
|
+
return flushDerivedPlanNotifications(blockedState, statePath, logger, blocker);
|
|
143
|
+
}
|
|
144
|
+
// Site C: a `dirty_worktree` safety block or a `reviewer_convergence`
|
|
145
|
+
// cap/stall block. Attended runs leave `status:'blocked'`; unattended runs
|
|
146
|
+
// have no operator to answer, so run the shared terminal-fail action. The
|
|
147
|
+
// dirty-worktree case must not be silently converted to the recoverable
|
|
148
|
+
// landing (the planner dirtied non-plan files with no operator to clean it),
|
|
149
|
+
// and the convergence cap already bounded the autonomous push. Any plan
|
|
150
|
+
// artifact is preserved unsubmitted, exactly as today's failed runs leave it.
|
|
137
151
|
if (state.unattended) {
|
|
138
152
|
return persistUnattendedBlockUnresolvedFailure(state, statePath, 'reviewer_plan', logger);
|
|
139
153
|
}
|
|
@@ -213,6 +227,9 @@ export async function runCoderPlanPhase(state, statePath, logger) {
|
|
|
213
227
|
phase: codex.marker === 'AUTONOMY_BLOCKED' || completionProblem || dirtyWorktreeBlocker ? 'blocked' : 'reviewer_plan',
|
|
214
228
|
status: codex.marker === 'AUTONOMY_BLOCKED' || completionProblem || dirtyWorktreeBlocker ? 'blocked' : 'running',
|
|
215
229
|
blockedFromPhase: codex.marker === 'AUTONOMY_BLOCKED' || completionProblem || dirtyWorktreeBlocker ? 'coder_plan' : null,
|
|
230
|
+
// The initial coder_plan authoring block never carries the recoverable
|
|
231
|
+
// coder-response blocker reason; keep it cleared whether we advance or block.
|
|
232
|
+
blockerReason: null,
|
|
216
233
|
});
|
|
217
234
|
await writeExecutionArtifacts(nextState);
|
|
218
235
|
await logger?.event('phase.complete', {
|
|
@@ -261,6 +278,31 @@ export async function runPlanReviewPhase(state, statePath, logger) {
|
|
|
261
278
|
}
|
|
262
279
|
catch (error) {
|
|
263
280
|
if (error instanceof ReviewerRoundError) {
|
|
281
|
+
// A content-safety refusal is a distinct, terminal, non-coder-recoverable
|
|
282
|
+
// condition: end the run blocked (exit 2) with a durable actionable reason
|
|
283
|
+
// by RETURNING the terminal blocked state, never throwing (a thrown error
|
|
284
|
+
// over a persisted blocked state escapes executeRun).
|
|
285
|
+
if (error.kind === 'content_refused') {
|
|
286
|
+
const blockedState = await saveState(statePath, {
|
|
287
|
+
...state,
|
|
288
|
+
reviewerSessionHandle: null,
|
|
289
|
+
phase: 'blocked',
|
|
290
|
+
status: 'blocked',
|
|
291
|
+
blockedFromPhase: null,
|
|
292
|
+
blockerReason: REVIEWER_CONTENT_REFUSED_BLOCK_REASON,
|
|
293
|
+
});
|
|
294
|
+
await writeExecutionArtifacts(blockedState);
|
|
295
|
+
await logger?.event('phase.error', {
|
|
296
|
+
phase: 'reviewer_plan',
|
|
297
|
+
round,
|
|
298
|
+
sessionHandle: error.sessionHandle,
|
|
299
|
+
subtype: error.subtype,
|
|
300
|
+
errorKind: error.kind,
|
|
301
|
+
message: error.message,
|
|
302
|
+
});
|
|
303
|
+
await notifyBlocked(blockedState, REVIEWER_CONTENT_REFUSED_BLOCK_REASON, logger);
|
|
304
|
+
return blockedState;
|
|
305
|
+
}
|
|
264
306
|
const failedState = await saveState(statePath, {
|
|
265
307
|
...state,
|
|
266
308
|
reviewerSessionHandle: null,
|
|
@@ -290,46 +332,25 @@ export async function runPlanReviewPhase(state, statePath, logger) {
|
|
|
290
332
|
// as a soft planner-prompt bias than a hard shape clamp.
|
|
291
333
|
const normalizedFindingInputs = synthesizedReview.findings;
|
|
292
334
|
printReviewResult('plan-review', claude.summary, normalizedFindingInputs, logger);
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
canonicalId,
|
|
300
|
-
status: 'open',
|
|
301
|
-
coderDisposition: null,
|
|
302
|
-
coderCommit: null,
|
|
303
|
-
};
|
|
304
|
-
});
|
|
305
|
-
const mergedFindings = [...state.findings, ...findings];
|
|
306
|
-
const hasBlockingFindings = findings.some((finding) => finding.severity === 'blocking');
|
|
307
|
-
const hasOpenNonBlockingFindings = mergedFindings.some(isOpenNonBlockingFinding);
|
|
308
|
-
const reachedMaxRounds = round >= roundLimit;
|
|
309
|
-
const openBlockingCanonicalSet = getOpenBlockingCanonicalSet(mergedFindings);
|
|
310
|
-
const openBlockingCanonicalIds = [...openBlockingCanonicalSet].sort();
|
|
311
|
-
const openBlockingCanonicalCount = openBlockingCanonicalSet.size;
|
|
312
|
-
const stalledBlockingCount = hasRepeatedUnresolvedBlockingCanonicals({
|
|
313
|
-
rounds: state.rounds,
|
|
314
|
-
currentOpenBlockingCanonicals: openBlockingCanonicalSet,
|
|
315
|
-
cwd: state.cwd,
|
|
316
|
-
});
|
|
317
|
-
const reopenedCanonical = getReopenedCanonical(mergedFindings);
|
|
318
|
-
const shouldBlockForConvergence = Boolean(reopenedCanonical || stalledBlockingCount);
|
|
319
|
-
const blockReason = reopenedCanonical
|
|
320
|
-
? getDerivedPlanBlockedReason(state, `review_stuck: blocking finding ${reopenedCanonical} reopened across multiple reviewer rounds`)
|
|
321
|
-
: stalledBlockingCount
|
|
322
|
-
? getDerivedPlanBlockedReason(state, `review_stuck: blocking findings did not decrease across ${getReviewStuckWindow(state.cwd)} consecutive reviewer rounds`)
|
|
323
|
-
: reachedMaxRounds && hasBlockingFindings
|
|
324
|
-
? getDerivedPlanBlockedReason(state, `reached max review rounds (${roundLimit}) with blocking findings still open`)
|
|
325
|
-
: null;
|
|
326
|
-
const disposition = resolvePlanReviewDisposition({
|
|
327
|
-
shouldBlockForConvergence,
|
|
328
|
-
hasBlockingFindings,
|
|
329
|
-
reachedMaxRounds,
|
|
330
|
-
hasOpenNonBlockingFindings,
|
|
335
|
+
const { findings, mergedFindings, disposition, blockReason, roundRecord, planReviewDebt } = synthesizePlanReviewRound({
|
|
336
|
+
state,
|
|
337
|
+
round,
|
|
338
|
+
roundLimit,
|
|
339
|
+
reviewStuckWindow: getReviewStuckWindow(state.cwd),
|
|
340
|
+
debtRoundThreshold: getPlanReviewDebtRoundThreshold(state.cwd),
|
|
331
341
|
derivedPlanReview,
|
|
332
342
|
currentDerivedPlanStatus: state.derivedPlanStatus,
|
|
343
|
+
executionShape: synthesizedReview.executionShape,
|
|
344
|
+
findingInputs: normalizedFindingInputs,
|
|
345
|
+
reviewerSessionHandle: claude.sessionHandle,
|
|
346
|
+
reviewedPlanPath: synthesizedReview.reviewedPlanPath,
|
|
347
|
+
normalizationApplied: preparedReview.validation.normalization.applied,
|
|
348
|
+
normalizationOperations: preparedReview.validation.normalization.operations,
|
|
349
|
+
normalizationScopeLabelMappings: preparedReview.validation.normalization.scopeLabelMappings,
|
|
350
|
+
commitRange: {
|
|
351
|
+
base: state.baseCommit ?? '',
|
|
352
|
+
head: state.finalCommit ?? state.baseCommit ?? '',
|
|
353
|
+
},
|
|
333
354
|
});
|
|
334
355
|
assertAdjudicationTransitionSignal(context.spec, disposition.planningSignal, 'orchestrator:reviewer_plan');
|
|
335
356
|
const nextState = await saveState(statePath, {
|
|
@@ -338,25 +359,11 @@ export async function runPlanReviewPhase(state, statePath, logger) {
|
|
|
338
359
|
executionShape: synthesizedReview.executionShape,
|
|
339
360
|
phase: disposition.phase,
|
|
340
361
|
status: disposition.status,
|
|
341
|
-
rounds: [
|
|
342
|
-
...state.rounds,
|
|
343
|
-
{
|
|
344
|
-
round,
|
|
345
|
-
reviewerSessionHandle: claude.sessionHandle,
|
|
346
|
-
reviewedPlanPath: synthesizedReview.reviewedPlanPath,
|
|
347
|
-
normalizationApplied: preparedReview.validation.normalization.applied,
|
|
348
|
-
normalizationOperations: preparedReview.validation.normalization.operations,
|
|
349
|
-
normalizationScopeLabelMappings: preparedReview.validation.normalization.scopeLabelMappings,
|
|
350
|
-
commitRange: {
|
|
351
|
-
base: state.baseCommit ?? '',
|
|
352
|
-
head: state.finalCommit ?? state.baseCommit ?? '',
|
|
353
|
-
},
|
|
354
|
-
openBlockingCanonicalCount,
|
|
355
|
-
openBlockingCanonicalIds,
|
|
356
|
-
findings: findings.map((finding) => finding.id),
|
|
357
|
-
},
|
|
358
|
-
],
|
|
362
|
+
rounds: [...state.rounds, roundRecord],
|
|
359
363
|
findings: mergedFindings,
|
|
364
|
+
// Recompute the current-negotiation plan-review debt projection from the
|
|
365
|
+
// post-conversion merged findings. Never touches inheritedPlanReviewDebt.
|
|
366
|
+
planReviewDebt,
|
|
360
367
|
derivedPlanStatus: disposition.derivedPlanStatus,
|
|
361
368
|
blockedFromPhase: disposition.blockedFromPhase,
|
|
362
369
|
});
|
|
@@ -370,7 +377,8 @@ export async function runPlanReviewPhase(state, statePath, logger) {
|
|
|
370
377
|
nextPhase: nextState.phase,
|
|
371
378
|
});
|
|
372
379
|
if (nextState.status === 'blocked' && blockReason) {
|
|
373
|
-
|
|
380
|
+
// A reviewer convergence/cap terminal block, never a coder-authored one.
|
|
381
|
+
return finalizeBlockedPlanReviewResponse(nextState, statePath, derivedPlanReview, blockReason, 'reviewer_convergence', logger);
|
|
374
382
|
}
|
|
375
383
|
if (shouldNotifyDerivedPlanAcceptance(state, nextState)) {
|
|
376
384
|
return flushDerivedPlanNotifications(nextState, statePath, logger);
|
|
@@ -387,7 +395,7 @@ export async function runPlanningResponsePhase(state, statePath, phase, logger)
|
|
|
387
395
|
// authored by a persisted planner session, so a missing handle stays a hard error for
|
|
388
396
|
// resume-capable planner providers. Derived-plan revisions are authored by the coder and create
|
|
389
397
|
// no planner session, and no-resume planner providers (`supportsSessionResume: false`, e.g.
|
|
390
|
-
//
|
|
398
|
+
// openai-compatible) never persist a planner handle at all — they start a
|
|
391
399
|
// fresh planner session instead.
|
|
392
400
|
if (!derivedPlanReview &&
|
|
393
401
|
!state.plannerSessionHandle &&
|
|
@@ -404,7 +412,12 @@ export async function runPlanningResponsePhase(state, statePath, phase, logger)
|
|
|
404
412
|
writeDiagnostic(`${formatPlanRefinementRoundLine({ round: state.rounds.length + 1, maxRounds: state.maxRounds })}\n`, logger);
|
|
405
413
|
}
|
|
406
414
|
const openFindings = state.findings.filter(mode === 'optional' ? isOpenNonBlockingFinding : isOpenBlockingFinding);
|
|
407
|
-
|
|
415
|
+
// Recorded operator guidance must reach the planner. If a prior blocked response
|
|
416
|
+
// closed every finding, the guidance would otherwise be silently discarded here
|
|
417
|
+
// (finalizePlanReviewResponseWithoutOpenFindings clears pendingPlanReviewGuidance
|
|
418
|
+
// and accepts the plan) without ever delivering the operator's answer. Force a
|
|
419
|
+
// response adjudication whenever guidance is pending so it is delivered first.
|
|
420
|
+
if (openFindings.length === 0 && state.pendingPlanReviewGuidance === null) {
|
|
408
421
|
return finalizePlanReviewResponseWithoutOpenFindings(state, statePath, phase, logger);
|
|
409
422
|
}
|
|
410
423
|
let codex;
|
|
@@ -443,7 +456,22 @@ export async function runPlanningResponsePhase(state, statePath, phase, logger)
|
|
|
443
456
|
const responseById = new Map(codex.payload.responses.map((response) => [response.id, response]));
|
|
444
457
|
assertAdjudicationTransitionSignal(spec, codex.payload.outcome === 'blocked' ? 'block_for_operator' : mode === 'optional' ? 'optional_revision' : 'request_revision', `orchestrator:${phase}`);
|
|
445
458
|
const dirtyWorktreeBlocker = await getPlanPhaseDirtyWorktreeBlocker(state, phase);
|
|
459
|
+
// Response-eligibility contract: only findings this invocation actually
|
|
460
|
+
// presented to the coder (the mode-matching `openFindings` set — open blocking
|
|
461
|
+
// for a required response, open non-blocking for an optional one) may be
|
|
462
|
+
// dispositioned. The response `id` schema is an unconstrained `z.string()`, so a
|
|
463
|
+
// planner can name any finding; a response whose `id` is outside `openFindings`
|
|
464
|
+
// — an already-`deferred` banked-debt finding, a prior-round `fixed`/`rejected`
|
|
465
|
+
// finding, a wrong-mode finding (a blocking id in an optional response or the
|
|
466
|
+
// reverse), or an unknown id — is a no-op that never mutates status or
|
|
467
|
+
// coderDisposition. This keeps `toPlanReviewDebt`'s reservation intact (a banked
|
|
468
|
+
// hardening finding cannot be silently un-banked by an out-of-band disposition)
|
|
469
|
+
// and matches the replay harness's open-blocking eligibility guard.
|
|
470
|
+
const openFindingIds = new Set(openFindings.map((finding) => finding.id));
|
|
446
471
|
const findings = state.findings.map((finding) => {
|
|
472
|
+
if (!openFindingIds.has(finding.id)) {
|
|
473
|
+
return finding;
|
|
474
|
+
}
|
|
447
475
|
const response = responseById.get(finding.id);
|
|
448
476
|
if (!response) {
|
|
449
477
|
return finding;
|
|
@@ -460,6 +488,10 @@ export async function runPlanningResponsePhase(state, statePath, phase, logger)
|
|
|
460
488
|
plannerSessionHandle: codex.sessionHandle,
|
|
461
489
|
plannerSessionProtocol: codex.sessionHandle ? activePlannerSessionProtocol : null,
|
|
462
490
|
findings,
|
|
491
|
+
// Reproject the current-negotiation plan-review debt after applying coder
|
|
492
|
+
// dispositions (a re-defer keeps debt; a fix/reject/reopen drops it). Never
|
|
493
|
+
// touches inheritedPlanReviewDebt.
|
|
494
|
+
planReviewDebt: toPlanReviewDebt(findings),
|
|
463
495
|
phase: dirtyWorktreeBlocker || codex.payload.outcome === 'blocked'
|
|
464
496
|
? 'blocked'
|
|
465
497
|
: mode === 'optional'
|
|
@@ -477,6 +509,9 @@ export async function runPlanningResponsePhase(state, statePath, phase, logger)
|
|
|
477
509
|
: state.derivedPlanStatus,
|
|
478
510
|
blockedFromPhase: dirtyWorktreeBlocker || codex.payload.outcome === 'blocked' ? phase : null,
|
|
479
511
|
pendingPlanReviewGuidance: null,
|
|
512
|
+
// Clear any stale reason as we advance to running/done; the recoverable
|
|
513
|
+
// coder-authored blocked landing re-persists it in finalizeBlockedPlanReviewResponse.
|
|
514
|
+
blockerReason: null,
|
|
480
515
|
});
|
|
481
516
|
await writeExecutionArtifacts(nextState);
|
|
482
517
|
await logger?.event('phase.complete', {
|
|
@@ -486,13 +521,15 @@ export async function runPlanningResponsePhase(state, statePath, phase, logger)
|
|
|
486
521
|
nextPhase: nextState.phase,
|
|
487
522
|
});
|
|
488
523
|
if (nextState.status === 'blocked') {
|
|
489
|
-
const blocker = getDerivedPlanBlockedReason(
|
|
524
|
+
const blocker = getDerivedPlanBlockedReason(derivedPlanReview, dirtyWorktreeBlocker ||
|
|
490
525
|
codex.payload.blocker?.trim() ||
|
|
491
526
|
codex.payload.summary.trim() ||
|
|
492
527
|
(mode === 'optional'
|
|
493
528
|
? 'The coder reported a blocker while considering non-blocking plan findings'
|
|
494
529
|
: 'The coder reported a blocker during plan response'));
|
|
495
|
-
|
|
530
|
+
// The coder authored the block unless the safety net tripped on a dirtied
|
|
531
|
+
// non-plan worktree; the cause drives the recoverable-vs-terminal landing.
|
|
532
|
+
return finalizeBlockedPlanReviewResponse(nextState, statePath, derivedPlanReview, blocker, dirtyWorktreeBlocker ? 'dirty_worktree' : 'coder_authored', logger);
|
|
496
533
|
}
|
|
497
534
|
if (mode === 'optional' && shouldNotifyDerivedPlanAcceptance(state, nextState)) {
|
|
498
535
|
return flushDerivedPlanNotifications(nextState, statePath, logger);
|