@navels/neal 0.2.0 → 0.3.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.
Files changed (114) 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 +230 -38
  7. package/dist/neal/agents/prompts.js +6 -17
  8. package/dist/neal/agents/rounds.js +43 -73
  9. package/dist/neal/agents/schemas.js +47 -13
  10. package/dist/neal/blocked-guidance.js +16 -9
  11. package/dist/neal/cli.js +2 -2
  12. package/dist/neal/commands/check.js +2 -2
  13. package/dist/neal/commands/compat.js +100 -36
  14. package/dist/neal/commands/interactive-activity.js +2 -2
  15. package/dist/neal/commands/recovery-guidance.js +20 -8
  16. package/dist/neal/commands/runtime.js +4 -2
  17. package/dist/neal/config.js +16 -7
  18. package/dist/neal/context/inline-review-context.js +13 -28
  19. package/dist/neal/context/reviewer-context.js +19 -14
  20. package/dist/neal/eval/reviewer-eval-manifest.js +86 -0
  21. package/dist/neal/eval/reviewer-eval.js +155 -0
  22. package/dist/neal/interactive-controls.js +5 -0
  23. package/dist/neal/orchestrator/completion.js +49 -2
  24. package/dist/neal/orchestrator/failures.js +7 -0
  25. package/dist/neal/orchestrator/notifications.js +9 -9
  26. package/dist/neal/orchestrator/phases/planning.js +117 -80
  27. package/dist/neal/orchestrator/phases/recovery.js +151 -115
  28. package/dist/neal/orchestrator/phases/review.js +28 -1
  29. package/dist/neal/orchestrator/transitions.js +7 -1
  30. package/dist/neal/orchestrator.js +3 -0
  31. package/dist/neal/plan-queue.js +29 -1
  32. package/dist/neal/prompts/execute.js +7 -19
  33. package/dist/neal/prompts/planning.js +64 -55
  34. package/dist/neal/prompts/review-doctrine.js +13 -50
  35. package/dist/neal/prompts/specialized.js +7 -17
  36. package/dist/neal/prompts/specs.js +180 -32
  37. package/dist/neal/providers/agent-settings-isolation.js +35 -0
  38. package/dist/neal/providers/anthropic-claude.js +18 -1
  39. package/dist/neal/providers/detection.js +3 -7
  40. package/dist/neal/providers/git-config-isolation.js +83 -0
  41. package/dist/neal/providers/openai-codex.js +25 -3
  42. package/dist/neal/providers/{generic-agentic-tools.js → openai-compatible-tools.js} +11 -5
  43. package/dist/neal/providers/openai-compatible.js +1078 -430
  44. package/dist/neal/providers/openrouter-routing.js +68 -0
  45. package/dist/neal/providers/pricing.js +1 -1
  46. package/dist/neal/providers/rate-card.js +6 -32
  47. package/dist/neal/providers/registry.js +6 -17
  48. package/dist/neal/providers/types.js +12 -0
  49. package/dist/neal/resume-decision.js +7 -6
  50. package/dist/neal/resume-planner.js +9 -0
  51. package/dist/neal/retrospective.js +3 -3
  52. package/dist/neal/review-debt.js +30 -0
  53. package/dist/neal/review-findings/provider.js +17 -29
  54. package/dist/neal/review.js +17 -1
  55. package/dist/neal/state-invariants.js +11 -3
  56. package/dist/neal/state-views.js +27 -5
  57. package/dist/neal/state.js +59 -15
  58. package/dist/neal/status.js +28 -1
  59. package/dist/neal/support.js +3 -3
  60. package/docs/README.md +22 -18
  61. package/docs/{ADJUDICATOR_INVENTORY.md → adjudicator-inventory.md} +58 -57
  62. package/docs/architecture.md +79 -83
  63. package/docs/automation.md +11 -11
  64. package/docs/compat.md +91 -61
  65. package/docs/compatible-models.md +145 -121
  66. package/docs/demo.md +15 -5
  67. package/docs/maintenance.md +65 -34
  68. package/docs/plan-format.md +61 -33
  69. package/docs/prompt-evals.md +96 -0
  70. package/docs/{PROMPT_SPECS.md → prompt-specs.md} +52 -48
  71. package/docs/providers.md +279 -349
  72. package/docs/release.md +63 -81
  73. package/docs/review-convergence.md +246 -0
  74. package/docs/state-machine.md +61 -59
  75. package/docs/storage.md +40 -31
  76. package/docs/troubleshooting.md +32 -29
  77. package/examples/compat/add-edit-verify/PLAN.md +2 -0
  78. package/examples/compat/add-edit-verify/broken.diff +2 -2
  79. package/examples/compat/add-edit-verify/good.diff +2 -2
  80. package/examples/compat/add-edit-verify/src/add.js +1 -1
  81. package/examples/compat/manifest.json +25 -29
  82. package/examples/compat/sum-grep-edit/PLAN.md +33 -0
  83. package/examples/compat/sum-grep-edit/broken.diff +12 -0
  84. package/examples/compat/sum-grep-edit/good.diff +12 -0
  85. package/examples/compat/sum-grep-edit/package.json +5 -0
  86. package/examples/compat/sum-grep-edit/src/numbers.js +9 -0
  87. package/examples/compat/sum-grep-edit/test/numbers.test.js +15 -0
  88. package/examples/issue-triage-js/README.md +0 -15
  89. package/examples/reviewer-eval/clean-doc/base/src/clamp.ts +3 -0
  90. package/examples/reviewer-eval/clean-doc/change.diff +9 -0
  91. package/examples/reviewer-eval/clean-extract/base/src/greet.ts +3 -0
  92. package/examples/reviewer-eval/clean-extract/change.diff +13 -0
  93. package/examples/reviewer-eval/dropped-error/base/src/load.ts +4 -0
  94. package/examples/reviewer-eval/dropped-error/change.diff +15 -0
  95. package/examples/reviewer-eval/manifest.json +45 -0
  96. package/examples/reviewer-eval/off-by-one/base/src/paginate.ts +5 -0
  97. package/examples/reviewer-eval/off-by-one/change.diff +11 -0
  98. package/{config.yml → neal.yml} +1 -1
  99. package/package.json +15 -14
  100. package/dist/neal/providers/generic-agentic.js +0 -1256
  101. package/docs/comparison.md +0 -105
  102. package/docs/issue-pipeline.md +0 -124
  103. package/examples/compat/is-even-add-test/PLAN.md +0 -30
  104. package/examples/compat/is-even-add-test/broken.diff +0 -11
  105. package/examples/compat/is-even-add-test/good.diff +0 -11
  106. package/examples/compat/is-even-add-test/package.json +0 -5
  107. package/examples/compat/is-even-add-test/src/is-even.js +0 -3
  108. package/examples/compat/is-even-add-test/test/is-even.test.js +0 -9
  109. package/examples/compat/reverse-grep-edit/PLAN.md +0 -32
  110. package/examples/compat/reverse-grep-edit/broken.diff +0 -12
  111. package/examples/compat/reverse-grep-edit/good.diff +0 -12
  112. package/examples/compat/reverse-grep-edit/package.json +0 -5
  113. package/examples/compat/reverse-grep-edit/src/strings.js +0 -10
  114. 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, resolvePlanReviewDisposition, 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,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
- 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,
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
- 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);
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
- // 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
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
- 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) {
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(state, dirtyWorktreeBlocker ||
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
- 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);
496
533
  }
497
534
  if (mode === 'optional' && shouldNotifyDerivedPlanAcceptance(state, nextState)) {
498
535
  return flushDerivedPlanNotifications(nextState, statePath, logger);