@navels/neal 0.1.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.
Files changed (120) hide show
  1. package/README.md +206 -209
  2. package/SECURITY.md +16 -19
  3. package/dist/neal/adjudicator/{blocked-adjudicator.js → consultant.js} +21 -21
  4. package/dist/neal/adjudicator/execute.js +33 -33
  5. package/dist/neal/adjudicator/final-completion.js +2 -31
  6. package/dist/neal/adjudicator/planning.js +264 -24
  7. package/dist/neal/agents/prompts.js +7 -15
  8. package/dist/neal/agents/rounds.js +51 -73
  9. package/dist/neal/agents/schemas.js +615 -502
  10. package/dist/neal/agents/structured-json.js +36 -0
  11. package/dist/neal/blocked-guidance.js +16 -9
  12. package/dist/neal/cli.js +2 -2
  13. package/dist/neal/commands/check.js +2 -2
  14. package/dist/neal/commands/compat.js +100 -36
  15. package/dist/neal/commands/interactive-activity.js +2 -2
  16. package/dist/neal/commands/recovery-guidance.js +20 -8
  17. package/dist/neal/commands/runtime.js +4 -2
  18. package/dist/neal/config.js +40 -7
  19. package/dist/neal/context/inline-review-context.js +13 -28
  20. package/dist/neal/context/reviewer-context.js +19 -14
  21. package/dist/neal/eval/reviewer-eval-manifest.js +86 -0
  22. package/dist/neal/eval/reviewer-eval.js +155 -0
  23. package/dist/neal/git.js +9 -3
  24. package/dist/neal/interactive-controls.js +5 -0
  25. package/dist/neal/orchestrator/completion.js +215 -113
  26. package/dist/neal/orchestrator/failures.js +7 -0
  27. package/dist/neal/orchestrator/notifications.js +9 -9
  28. package/dist/neal/orchestrator/phases/planning.js +124 -112
  29. package/dist/neal/orchestrator/phases/recovery.js +151 -115
  30. package/dist/neal/orchestrator/phases/review.js +28 -1
  31. package/dist/neal/orchestrator/split-plan.js +12 -11
  32. package/dist/neal/orchestrator/transitions.js +36 -71
  33. package/dist/neal/orchestrator.js +3 -0
  34. package/dist/neal/plan-doc.js +24 -1
  35. package/dist/neal/plan-queue.js +29 -1
  36. package/dist/neal/prompts/assert-builder.js +8 -1
  37. package/dist/neal/prompts/execute.js +11 -19
  38. package/dist/neal/prompts/planning.js +64 -55
  39. package/dist/neal/prompts/review-doctrine.js +13 -50
  40. package/dist/neal/prompts/specialized.js +28 -22
  41. package/dist/neal/prompts/specs.js +223 -17
  42. package/dist/neal/providers/agent-settings-isolation.js +35 -0
  43. package/dist/neal/providers/anthropic-claude.js +309 -248
  44. package/dist/neal/providers/detection.js +3 -7
  45. package/dist/neal/providers/git-config-isolation.js +83 -0
  46. package/dist/neal/providers/openai-codex.js +102 -204
  47. package/dist/neal/providers/{generic-agentic-tools.js → openai-compatible-tools.js} +11 -5
  48. package/dist/neal/providers/openai-compatible.js +1079 -403
  49. package/dist/neal/providers/openrouter-routing.js +68 -0
  50. package/dist/neal/providers/pricing.js +124 -0
  51. package/dist/neal/providers/rate-card.js +2275 -0
  52. package/dist/neal/providers/registry.js +6 -17
  53. package/dist/neal/providers/telemetry.js +4 -0
  54. package/dist/neal/providers/types.js +12 -0
  55. package/dist/neal/resume-decision.js +7 -6
  56. package/dist/neal/resume-planner.js +9 -0
  57. package/dist/neal/retrospective.js +36 -7
  58. package/dist/neal/review-debt.js +30 -0
  59. package/dist/neal/review-findings/provider.js +17 -29
  60. package/dist/neal/review.js +17 -1
  61. package/dist/neal/run-metrics.js +74 -9
  62. package/dist/neal/state-invariants.js +11 -3
  63. package/dist/neal/state-views.js +27 -5
  64. package/dist/neal/state.js +59 -15
  65. package/dist/neal/status.js +28 -1
  66. package/dist/neal/support.js +3 -3
  67. package/docs/README.md +22 -18
  68. package/docs/{ADJUDICATOR_INVENTORY.md → adjudicator-inventory.md} +58 -57
  69. package/docs/architecture.md +79 -83
  70. package/docs/automation.md +11 -11
  71. package/docs/compat.md +91 -61
  72. package/docs/compatible-models.md +145 -110
  73. package/docs/demo.md +15 -5
  74. package/docs/maintenance.md +74 -32
  75. package/docs/plan-format.md +61 -33
  76. package/docs/prompt-evals.md +96 -0
  77. package/docs/{PROMPT_SPECS.md → prompt-specs.md} +52 -48
  78. package/docs/providers.md +330 -283
  79. package/docs/release.md +43 -40
  80. package/docs/review-convergence.md +246 -0
  81. package/docs/state-machine.md +61 -59
  82. package/docs/storage.md +40 -31
  83. package/docs/troubleshooting.md +32 -29
  84. package/examples/compat/add-edit-verify/PLAN.md +2 -0
  85. package/examples/compat/add-edit-verify/broken.diff +2 -2
  86. package/examples/compat/add-edit-verify/good.diff +2 -2
  87. package/examples/compat/add-edit-verify/src/add.js +1 -1
  88. package/examples/compat/manifest.json +25 -29
  89. package/examples/compat/sum-grep-edit/PLAN.md +33 -0
  90. package/examples/compat/sum-grep-edit/broken.diff +12 -0
  91. package/examples/compat/sum-grep-edit/good.diff +12 -0
  92. package/examples/compat/sum-grep-edit/package.json +5 -0
  93. package/examples/compat/sum-grep-edit/src/numbers.js +9 -0
  94. package/examples/compat/sum-grep-edit/test/numbers.test.js +15 -0
  95. package/examples/issue-triage-js/README.md +0 -15
  96. package/examples/reviewer-eval/clean-doc/base/src/clamp.ts +3 -0
  97. package/examples/reviewer-eval/clean-doc/change.diff +9 -0
  98. package/examples/reviewer-eval/clean-extract/base/src/greet.ts +3 -0
  99. package/examples/reviewer-eval/clean-extract/change.diff +13 -0
  100. package/examples/reviewer-eval/dropped-error/base/src/load.ts +4 -0
  101. package/examples/reviewer-eval/dropped-error/change.diff +15 -0
  102. package/examples/reviewer-eval/manifest.json +45 -0
  103. package/examples/reviewer-eval/off-by-one/base/src/paginate.ts +5 -0
  104. package/examples/reviewer-eval/off-by-one/change.diff +11 -0
  105. package/{config.yml → neal.yml} +1 -1
  106. package/package.json +20 -15
  107. package/dist/neal/providers/generic-agentic.js +0 -1238
  108. package/docs/comparison.md +0 -105
  109. package/examples/compat/is-even-add-test/PLAN.md +0 -30
  110. package/examples/compat/is-even-add-test/broken.diff +0 -11
  111. package/examples/compat/is-even-add-test/good.diff +0 -11
  112. package/examples/compat/is-even-add-test/package.json +0 -5
  113. package/examples/compat/is-even-add-test/src/is-even.js +0 -3
  114. package/examples/compat/is-even-add-test/test/is-even.test.js +0 -9
  115. package/examples/compat/reverse-grep-edit/PLAN.md +0 -32
  116. package/examples/compat/reverse-grep-edit/broken.diff +0 -12
  117. package/examples/compat/reverse-grep-edit/good.diff +0 -12
  118. package/examples/compat/reverse-grep-edit/package.json +0 -5
  119. package/examples/compat/reverse-grep-edit/src/strings.js +0 -10
  120. 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 { findCanonicalId, getNextCanonicalIndex, getOpenBlockingCanonicalSet, getReopenedCanonical, hasRepeatedUnresolvedBlockingCanonicals, isOpenBlockingFinding, isOpenNonBlockingFinding, mapDecisionToStatus, } from '../../adjudicator/execute.js';
5
- import { isDerivedPlanReviewState, plannerProviderStartsFreshSessions, resolvePlanningAdjudicationContext, runPlanningResponseAdjudication, runPlanningReviewerAdjudication, } from '../../adjudicator/planning.js';
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
- // Site C: the top-level plan-review gate (`neal run`/`neal plan` plan stage).
130
- // Attended runs leave `status:'blocked'` to be resumed via
131
- // `pendingPlanReviewGuidance`; unattended runs have no operator to answer, so
132
- // run the shared terminal-fail action instead of notifying + leaving the run
133
- // blocked. The review-round/convergence cap already bounded the autonomous
134
- // push, so there is no auto-resume here. Any plan artifact is preserved
135
- // unsubmitted by the shared action, exactly as today's failed runs leave it.
136
- // Gate structurally on the persisted flag, never on guidance text.
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,100 +332,40 @@ 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
- let nextCanonicalIndex = getNextCanonicalIndex(state.findings);
294
- const findings = normalizedFindingInputs.map((finding, index) => {
295
- const canonicalId = findCanonicalId(state.findings, finding) ?? `C${nextCanonicalIndex++}`;
296
- return {
297
- ...finding,
298
- id: `R${round}-F${index + 1}`,
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,
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),
341
+ derivedPlanReview,
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
+ },
316
354
  });
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 planningSignal = shouldBlockForConvergence || (hasBlockingFindings && reachedMaxRounds)
327
- ? 'block_for_operator'
328
- : hasBlockingFindings
329
- ? 'request_revision'
330
- : hasOpenNonBlockingFindings
331
- ? 'optional_revision'
332
- : derivedPlanReview
333
- ? 'accept_derived_plan'
334
- : 'accept_plan';
335
- assertAdjudicationTransitionSignal(context.spec, planningSignal, 'orchestrator:reviewer_plan');
355
+ assertAdjudicationTransitionSignal(context.spec, disposition.planningSignal, 'orchestrator:reviewer_plan');
336
356
  const nextState = await saveState(statePath, {
337
357
  ...state,
338
358
  reviewerSessionHandle: claude.sessionHandle,
339
359
  executionShape: synthesizedReview.executionShape,
340
- phase: shouldBlockForConvergence
341
- ? 'blocked'
342
- : hasBlockingFindings
343
- ? reachedMaxRounds
344
- ? 'blocked'
345
- : 'coder_plan_response'
346
- : hasOpenNonBlockingFindings
347
- ? 'coder_plan_optional_response'
348
- : derivedPlanReview
349
- ? 'awaiting_derived_plan_execution'
350
- : 'done',
351
- status: shouldBlockForConvergence
352
- ? 'blocked'
353
- : hasBlockingFindings
354
- ? reachedMaxRounds
355
- ? 'blocked'
356
- : 'running'
357
- : hasOpenNonBlockingFindings
358
- ? 'running'
359
- : derivedPlanReview
360
- ? 'running'
361
- : 'done',
362
- rounds: [
363
- ...state.rounds,
364
- {
365
- round,
366
- reviewerSessionHandle: claude.sessionHandle,
367
- reviewedPlanPath: synthesizedReview.reviewedPlanPath,
368
- normalizationApplied: preparedReview.validation.normalization.applied,
369
- normalizationOperations: preparedReview.validation.normalization.operations,
370
- normalizationScopeLabelMappings: preparedReview.validation.normalization.scopeLabelMappings,
371
- commitRange: {
372
- base: state.baseCommit ?? '',
373
- head: state.finalCommit ?? state.baseCommit ?? '',
374
- },
375
- openBlockingCanonicalCount,
376
- openBlockingCanonicalIds,
377
- findings: findings.map((finding) => finding.id),
378
- },
379
- ],
360
+ phase: disposition.phase,
361
+ status: disposition.status,
362
+ rounds: [...state.rounds, roundRecord],
380
363
  findings: mergedFindings,
381
- derivedPlanStatus: derivedPlanReview && !shouldBlockForConvergence && !hasBlockingFindings && !hasOpenNonBlockingFindings
382
- ? 'accepted'
383
- : state.derivedPlanStatus,
384
- blockedFromPhase: shouldBlockForConvergence || (hasBlockingFindings && reachedMaxRounds)
385
- ? 'reviewer_plan'
386
- : null,
364
+ // Recompute the current-negotiation plan-review debt projection from the
365
+ // post-conversion merged findings. Never touches inheritedPlanReviewDebt.
366
+ planReviewDebt,
367
+ derivedPlanStatus: disposition.derivedPlanStatus,
368
+ blockedFromPhase: disposition.blockedFromPhase,
387
369
  });
388
370
  await writeExecutionArtifacts(nextState);
389
371
  await logger?.event('phase.complete', {
@@ -395,7 +377,8 @@ export async function runPlanReviewPhase(state, statePath, logger) {
395
377
  nextPhase: nextState.phase,
396
378
  });
397
379
  if (nextState.status === 'blocked' && blockReason) {
398
- return finalizeBlockedPlanReviewResponse(nextState, statePath, derivedPlanReview, blockReason, logger);
380
+ // A reviewer convergence/cap terminal block, never a coder-authored one.
381
+ return finalizeBlockedPlanReviewResponse(nextState, statePath, derivedPlanReview, blockReason, 'reviewer_convergence', logger);
399
382
  }
400
383
  if (shouldNotifyDerivedPlanAcceptance(state, nextState)) {
401
384
  return flushDerivedPlanNotifications(nextState, statePath, logger);
@@ -412,7 +395,7 @@ export async function runPlanningResponsePhase(state, statePath, phase, logger)
412
395
  // authored by a persisted planner session, so a missing handle stays a hard error for
413
396
  // resume-capable planner providers. Derived-plan revisions are authored by the coder and create
414
397
  // no planner session, and no-resume planner providers (`supportsSessionResume: false`, e.g.
415
- // generic-agentic and openai-compatible) never persist a planner handle at all — both start a
398
+ // openai-compatible) never persist a planner handle at all — they start a
416
399
  // fresh planner session instead.
417
400
  if (!derivedPlanReview &&
418
401
  !state.plannerSessionHandle &&
@@ -429,7 +412,12 @@ export async function runPlanningResponsePhase(state, statePath, phase, logger)
429
412
  writeDiagnostic(`${formatPlanRefinementRoundLine({ round: state.rounds.length + 1, maxRounds: state.maxRounds })}\n`, logger);
430
413
  }
431
414
  const openFindings = state.findings.filter(mode === 'optional' ? isOpenNonBlockingFinding : isOpenBlockingFinding);
432
- if (openFindings.length === 0) {
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) {
433
421
  return finalizePlanReviewResponseWithoutOpenFindings(state, statePath, phase, logger);
434
422
  }
435
423
  let codex;
@@ -468,7 +456,22 @@ export async function runPlanningResponsePhase(state, statePath, phase, logger)
468
456
  const responseById = new Map(codex.payload.responses.map((response) => [response.id, response]));
469
457
  assertAdjudicationTransitionSignal(spec, codex.payload.outcome === 'blocked' ? 'block_for_operator' : mode === 'optional' ? 'optional_revision' : 'request_revision', `orchestrator:${phase}`);
470
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));
471
471
  const findings = state.findings.map((finding) => {
472
+ if (!openFindingIds.has(finding.id)) {
473
+ return finding;
474
+ }
472
475
  const response = responseById.get(finding.id);
473
476
  if (!response) {
474
477
  return finding;
@@ -485,6 +488,10 @@ export async function runPlanningResponsePhase(state, statePath, phase, logger)
485
488
  plannerSessionHandle: codex.sessionHandle,
486
489
  plannerSessionProtocol: codex.sessionHandle ? activePlannerSessionProtocol : null,
487
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),
488
495
  phase: dirtyWorktreeBlocker || codex.payload.outcome === 'blocked'
489
496
  ? 'blocked'
490
497
  : mode === 'optional'
@@ -502,6 +509,9 @@ export async function runPlanningResponsePhase(state, statePath, phase, logger)
502
509
  : state.derivedPlanStatus,
503
510
  blockedFromPhase: dirtyWorktreeBlocker || codex.payload.outcome === 'blocked' ? phase : null,
504
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,
505
515
  });
506
516
  await writeExecutionArtifacts(nextState);
507
517
  await logger?.event('phase.complete', {
@@ -511,13 +521,15 @@ export async function runPlanningResponsePhase(state, statePath, phase, logger)
511
521
  nextPhase: nextState.phase,
512
522
  });
513
523
  if (nextState.status === 'blocked') {
514
- const blocker = getDerivedPlanBlockedReason(state, dirtyWorktreeBlocker ||
524
+ const blocker = getDerivedPlanBlockedReason(derivedPlanReview, dirtyWorktreeBlocker ||
515
525
  codex.payload.blocker?.trim() ||
516
526
  codex.payload.summary.trim() ||
517
527
  (mode === 'optional'
518
528
  ? 'The coder reported a blocker while considering non-blocking plan findings'
519
529
  : 'The coder reported a blocker during plan response'));
520
- return finalizeBlockedPlanReviewResponse(nextState, statePath, derivedPlanReview, blocker, logger);
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);
521
533
  }
522
534
  if (mode === 'optional' && shouldNotifyDerivedPlanAcceptance(state, nextState)) {
523
535
  return flushDerivedPlanNotifications(nextState, statePath, logger);