@navels/neal 0.1.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 (170) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +527 -0
  3. package/SECURITY.md +91 -0
  4. package/config.yml +104 -0
  5. package/dist/neal/activity-footer.js +177 -0
  6. package/dist/neal/activity-reporting.js +1 -0
  7. package/dist/neal/adjudicator/artifacts.js +58 -0
  8. package/dist/neal/adjudicator/blocked-adjudicator.js +223 -0
  9. package/dist/neal/adjudicator/contracts.js +139 -0
  10. package/dist/neal/adjudicator/execute.js +611 -0
  11. package/dist/neal/adjudicator/final-completion.js +104 -0
  12. package/dist/neal/adjudicator/planning.js +145 -0
  13. package/dist/neal/adjudicator/specs.js +453 -0
  14. package/dist/neal/agents/prompts.js +120 -0
  15. package/dist/neal/agents/rounds.js +706 -0
  16. package/dist/neal/agents/schemas.js +832 -0
  17. package/dist/neal/agents/structured-coder.js +82 -0
  18. package/dist/neal/agents/structured-json.js +528 -0
  19. package/dist/neal/agents.js +4 -0
  20. package/dist/neal/atomic-write.js +18 -0
  21. package/dist/neal/blocked-guidance.js +406 -0
  22. package/dist/neal/cli.js +471 -0
  23. package/dist/neal/commands/check.js +401 -0
  24. package/dist/neal/commands/compat.js +807 -0
  25. package/dist/neal/commands/interactive-activity.js +57 -0
  26. package/dist/neal/commands/new-run.js +79 -0
  27. package/dist/neal/commands/plan-and-execute.js +44 -0
  28. package/dist/neal/commands/recovery-guidance.js +217 -0
  29. package/dist/neal/commands/resume-run.js +395 -0
  30. package/dist/neal/commands/review.js +21 -0
  31. package/dist/neal/commands/runtime.js +557 -0
  32. package/dist/neal/commands/setup.js +596 -0
  33. package/dist/neal/commands/squash.js +113 -0
  34. package/dist/neal/commands/status.js +33 -0
  35. package/dist/neal/commands/writer-exit-codes.js +42 -0
  36. package/dist/neal/commit-message.js +17 -0
  37. package/dist/neal/config.js +432 -0
  38. package/dist/neal/context/artifacts.js +140 -0
  39. package/dist/neal/context/context.js +324 -0
  40. package/dist/neal/context/inline-review-context.js +131 -0
  41. package/dist/neal/context/reviewer-context.js +166 -0
  42. package/dist/neal/context/shared.js +117 -0
  43. package/dist/neal/context/types.js +1 -0
  44. package/dist/neal/diagnostic.js +208 -0
  45. package/dist/neal/execute-finalization.js +5 -0
  46. package/dist/neal/final-completion-review.js +188 -0
  47. package/dist/neal/final-completion.js +229 -0
  48. package/dist/neal/git.js +339 -0
  49. package/dist/neal/index.js +135 -0
  50. package/dist/neal/interactive-controls.js +85 -0
  51. package/dist/neal/logger.js +102 -0
  52. package/dist/neal/manual-gates.js +121 -0
  53. package/dist/neal/orchestrator/artifacts.js +70 -0
  54. package/dist/neal/orchestrator/completion.js +531 -0
  55. package/dist/neal/orchestrator/failures.js +31 -0
  56. package/dist/neal/orchestrator/notifications.js +175 -0
  57. package/dist/neal/orchestrator/phases/coder.js +516 -0
  58. package/dist/neal/orchestrator/phases/planning.js +540 -0
  59. package/dist/neal/orchestrator/phases/recovery.js +798 -0
  60. package/dist/neal/orchestrator/phases/review.js +136 -0
  61. package/dist/neal/orchestrator/phases/shared.js +279 -0
  62. package/dist/neal/orchestrator/run-loop.js +113 -0
  63. package/dist/neal/orchestrator/split-plan.js +235 -0
  64. package/dist/neal/orchestrator/transitions.js +309 -0
  65. package/dist/neal/orchestrator.js +215 -0
  66. package/dist/neal/phase-display.js +27 -0
  67. package/dist/neal/plan-doc.js +154 -0
  68. package/dist/neal/plan-queue.js +1092 -0
  69. package/dist/neal/plan-refinement.js +39 -0
  70. package/dist/neal/plan-validation.js +525 -0
  71. package/dist/neal/progress.js +237 -0
  72. package/dist/neal/prompts/assert-builder.js +13 -0
  73. package/dist/neal/prompts/execute.js +290 -0
  74. package/dist/neal/prompts/guidance.js +70 -0
  75. package/dist/neal/prompts/planning.js +313 -0
  76. package/dist/neal/prompts/review-doctrine.js +142 -0
  77. package/dist/neal/prompts/shared.js +101 -0
  78. package/dist/neal/prompts/specialized.js +212 -0
  79. package/dist/neal/prompts/specs.js +572 -0
  80. package/dist/neal/providers/anthropic-claude.js +1599 -0
  81. package/dist/neal/providers/detection.js +139 -0
  82. package/dist/neal/providers/generic-agentic-tools.js +586 -0
  83. package/dist/neal/providers/generic-agentic.js +1238 -0
  84. package/dist/neal/providers/liveness.js +151 -0
  85. package/dist/neal/providers/openai-codex.js +1014 -0
  86. package/dist/neal/providers/openai-compatible.js +654 -0
  87. package/dist/neal/providers/registry.js +389 -0
  88. package/dist/neal/providers/telemetry.js +208 -0
  89. package/dist/neal/providers/types.js +21 -0
  90. package/dist/neal/recovery-artifacts.js +50 -0
  91. package/dist/neal/resume-decision.js +220 -0
  92. package/dist/neal/resume-planner.js +265 -0
  93. package/dist/neal/retrospective.js +391 -0
  94. package/dist/neal/review-debt.js +18 -0
  95. package/dist/neal/review-findings/artifacts.js +173 -0
  96. package/dist/neal/review-findings/prompts.js +172 -0
  97. package/dist/neal/review-findings/provider.js +330 -0
  98. package/dist/neal/review-findings/run.js +373 -0
  99. package/dist/neal/review-findings/types.js +1 -0
  100. package/dist/neal/review-mode.js +67 -0
  101. package/dist/neal/review.js +137 -0
  102. package/dist/neal/run-lock.js +334 -0
  103. package/dist/neal/run-metrics.js +355 -0
  104. package/dist/neal/run-narrative-types.js +1 -0
  105. package/dist/neal/run-narrative.js +1374 -0
  106. package/dist/neal/run-registry.js +218 -0
  107. package/dist/neal/run-status.js +25 -0
  108. package/dist/neal/scopes.js +451 -0
  109. package/dist/neal/sensitive-text.js +8 -0
  110. package/dist/neal/squash-message.js +379 -0
  111. package/dist/neal/squash.js +591 -0
  112. package/dist/neal/state-invariants.js +496 -0
  113. package/dist/neal/state-views.js +344 -0
  114. package/dist/neal/state.js +887 -0
  115. package/dist/neal/status-footer.js +258 -0
  116. package/dist/neal/status.js +1260 -0
  117. package/dist/neal/storage-paths.js +57 -0
  118. package/dist/neal/support.js +58 -0
  119. package/dist/neal/terminal-narrator.js +435 -0
  120. package/dist/neal/types.js +1 -0
  121. package/dist/neal/verification-events.js +81 -0
  122. package/dist/neal/version.js +37 -0
  123. package/dist/neal/worktree-status.js +137 -0
  124. package/dist/notifier.js +44 -0
  125. package/docs/ADJUDICATOR_INVENTORY.md +310 -0
  126. package/docs/PROMPT_SPECS.md +266 -0
  127. package/docs/README.md +22 -0
  128. package/docs/architecture.md +113 -0
  129. package/docs/assets/neal-execution-flow.png +0 -0
  130. package/docs/automation.md +65 -0
  131. package/docs/comparison.md +105 -0
  132. package/docs/compat.md +269 -0
  133. package/docs/compatible-models.md +135 -0
  134. package/docs/demo.md +55 -0
  135. package/docs/maintenance.md +64 -0
  136. package/docs/plan-format.md +213 -0
  137. package/docs/providers.md +751 -0
  138. package/docs/release.md +147 -0
  139. package/docs/state-machine.md +266 -0
  140. package/docs/storage.md +207 -0
  141. package/docs/troubleshooting.md +152 -0
  142. package/examples/compat/add-edit-verify/PLAN.md +29 -0
  143. package/examples/compat/add-edit-verify/broken.diff +8 -0
  144. package/examples/compat/add-edit-verify/good.diff +8 -0
  145. package/examples/compat/add-edit-verify/package.json +5 -0
  146. package/examples/compat/add-edit-verify/src/add.js +2 -0
  147. package/examples/compat/add-edit-verify/test/add.test.js +9 -0
  148. package/examples/compat/is-even-add-test/PLAN.md +30 -0
  149. package/examples/compat/is-even-add-test/broken.diff +11 -0
  150. package/examples/compat/is-even-add-test/good.diff +11 -0
  151. package/examples/compat/is-even-add-test/package.json +5 -0
  152. package/examples/compat/is-even-add-test/src/is-even.js +3 -0
  153. package/examples/compat/is-even-add-test/test/is-even.test.js +9 -0
  154. package/examples/compat/manifest.json +60 -0
  155. package/examples/compat/plan-greeting/ISSUE.md +25 -0
  156. package/examples/compat/plan-greeting/package.json +5 -0
  157. package/examples/compat/plan-greeting/src/greet.js +2 -0
  158. package/examples/compat/plan-greeting/test/greet.test.js +8 -0
  159. package/examples/compat/reverse-grep-edit/PLAN.md +32 -0
  160. package/examples/compat/reverse-grep-edit/broken.diff +12 -0
  161. package/examples/compat/reverse-grep-edit/good.diff +12 -0
  162. package/examples/compat/reverse-grep-edit/package.json +5 -0
  163. package/examples/compat/reverse-grep-edit/src/strings.js +10 -0
  164. package/examples/compat/reverse-grep-edit/test/strings.test.js +15 -0
  165. package/examples/issue-triage-js/PLAN.md +83 -0
  166. package/examples/issue-triage-js/README.md +76 -0
  167. package/examples/issue-triage-js/package.json +9 -0
  168. package/examples/issue-triage-js/src/issue-triage.js +87 -0
  169. package/examples/issue-triage-js/test/issue-triage.test.js +107 -0
  170. package/package.json +70 -0
@@ -0,0 +1,540 @@
1
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import { dirname, join, parse } from 'node:path';
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';
6
+ import { assertAdjudicationTransitionSignal } from '../../adjudicator/specs.js';
7
+ import { getReviewStuckWindow } from '../../config.js';
8
+ import { writeDiagnostic } from '../../diagnostic.js';
9
+ import { getWorktreeStatus } from '../../git.js';
10
+ import { formatPlanRefinementRoundLine, isPlanRefinementState } from '../../plan-refinement.js';
11
+ import { validatePlanDocument } from '../../plan-validation.js';
12
+ import { getCurrentScopeLabel, getExecutionPlanPath } from '../../scopes.js';
13
+ import { saveState } from '../../state.js';
14
+ import { filterAllowedDirtyPathStatus, filterWrapperOwnedWorktreeStatus, } from '../../worktree-status.js';
15
+ import { writeExecutionArtifacts } from '../artifacts.js';
16
+ import { isCoderTimeoutError, shouldNotifyFailure } from '../failures.js';
17
+ import { flushDerivedPlanNotifications, notifyBlocked, notifyComplete, } from '../notifications.js';
18
+ import { adoptAcceptedDerivedPlan, shouldNotifyDerivedPlanAcceptance, transitionPlanReviewWithoutOpenFindings, } from '../transitions.js';
19
+ import { enterInteractiveBlockedRecovery, shouldNotifyInteractiveBlockedRecoveryEntry } from './recovery.js';
20
+ import { bestEffortCleanupTimedOutCoder, persistCoderFailureState, persistUnattendedBlockUnresolvedFailure, printReviewResult, scheduleCoderFreshSessionRetry, shouldRetryCoderWithFreshSession, } from './shared.js';
21
+ function getNormalizedPlanArtifactPath(state, planPath) {
22
+ const parsed = parse(planPath);
23
+ const extension = parsed.ext || '.md';
24
+ return join(state.runDir, `${parsed.name}.normalized${extension}`);
25
+ }
26
+ export async function preparePlanReviewArtifact(args) {
27
+ const planDocument = await readFile(args.planPath, 'utf8');
28
+ const validation = validatePlanDocument(planDocument);
29
+ let reviewedPlanPath = args.planPath;
30
+ if (validation.normalization.applied && args.normalizedPlanPath) {
31
+ await mkdir(dirname(args.normalizedPlanPath), { recursive: true });
32
+ await writeFile(args.normalizedPlanPath, validation.normalization.normalizedDocument, 'utf8');
33
+ reviewedPlanPath = args.normalizedPlanPath;
34
+ }
35
+ return {
36
+ executionShape: validation.executionShape,
37
+ reviewedPlanPath,
38
+ originalPlanPath: args.planPath,
39
+ validation,
40
+ };
41
+ }
42
+ export async function synthesizePlanReviewFindings(args) {
43
+ const preparedReview = args.preparedReview ?? (await preparePlanReviewArtifact({ planPath: args.planPath }));
44
+ const { validation } = preparedReview;
45
+ if (validation.ok) {
46
+ return {
47
+ executionShape: validation.executionShape,
48
+ reviewedPlanPath: preparedReview.reviewedPlanPath,
49
+ findings: args.findings,
50
+ };
51
+ }
52
+ return {
53
+ executionShape: validation.executionShape,
54
+ reviewedPlanPath: preparedReview.reviewedPlanPath,
55
+ findings: [
56
+ ...args.findings,
57
+ ...validation.errors.map((error) => ({
58
+ round: args.round,
59
+ source: 'plan_structure',
60
+ severity: 'blocking',
61
+ files: [preparedReview.originalPlanPath],
62
+ claim: `Plan document structure is invalid: ${error}`,
63
+ requiredAction: 'Revise the plan document so it satisfies the required execution-shape and execution-queue contract.',
64
+ roundSummary: args.roundSummary,
65
+ })),
66
+ ],
67
+ };
68
+ }
69
+ function getPlanningCompletionProblem(marker) {
70
+ if (marker === 'AUTONOMY_BLOCKED') {
71
+ return null;
72
+ }
73
+ return marker === 'AUTONOMY_DONE' ? null : 'Planning mode must end with AUTONOMY_DONE or AUTONOMY_BLOCKED.';
74
+ }
75
+ function getDerivedPlanBlockedReason(state, reason) {
76
+ if (!isDerivedPlanReviewState(state)) {
77
+ return reason;
78
+ }
79
+ return `split-plan recovery failed to converge: ${reason}`;
80
+ }
81
+ async function getPlanPhaseDirtyWorktreeBlocker(state, phase) {
82
+ const allowedDirtyPaths = [...new Set([...state.allowedDirtyPaths, state.planDoc])];
83
+ let statusOutput;
84
+ try {
85
+ statusOutput = await getWorktreeStatus(state.cwd, { untrackedFiles: 'all' });
86
+ }
87
+ catch (error) {
88
+ if (error instanceof Error && /not a git repository/.test(error.message)) {
89
+ return null;
90
+ }
91
+ throw error;
92
+ }
93
+ const dirtyStatus = filterAllowedDirtyPathStatus(state.cwd, filterWrapperOwnedWorktreeStatus(statusOutput), allowedDirtyPaths);
94
+ if (dirtyStatus.trim() === '') {
95
+ return null;
96
+ }
97
+ return [
98
+ `Planner dirtied non-plan files during ${phase}.`,
99
+ 'Plan phases may only update the plan artifact; implementation changes must happen during execution.',
100
+ '',
101
+ 'Dirty files:',
102
+ dirtyStatus,
103
+ ].join('\n');
104
+ }
105
+ async function finalizePlanReviewResponseWithoutOpenFindings(state, statePath, phase, logger) {
106
+ const { spec, reviewMode } = resolvePlanningAdjudicationContext(state);
107
+ const signal = reviewMode === 'derived-plan' ? 'accept_derived_plan' : 'accept_plan';
108
+ assertAdjudicationTransitionSignal(spec, signal, `orchestrator:${phase}:settled`);
109
+ let nextState = await saveState(statePath, {
110
+ ...transitionPlanReviewWithoutOpenFindings(state, reviewMode),
111
+ pendingPlanReviewGuidance: null,
112
+ });
113
+ await writeExecutionArtifacts(nextState);
114
+ await logger?.event('phase.complete', {
115
+ phase,
116
+ openFindings: 0,
117
+ nextPhase: nextState.phase,
118
+ });
119
+ nextState = await flushDerivedPlanNotifications(nextState, statePath, logger);
120
+ if (nextState.status === 'done') {
121
+ await notifyComplete(nextState, 'Plan review converged', logger);
122
+ }
123
+ return nextState;
124
+ }
125
+ export async function finalizeBlockedPlanReviewResponse(state, statePath, derivedPlanReview, blocker, logger) {
126
+ const { spec } = resolvePlanningAdjudicationContext(state);
127
+ assertAdjudicationTransitionSignal(spec, 'block_for_operator', 'orchestrator:planning:block_for_operator');
128
+ 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.
137
+ if (state.unattended) {
138
+ return persistUnattendedBlockUnresolvedFailure(state, statePath, 'reviewer_plan', logger);
139
+ }
140
+ if (!derivedPlanReview) {
141
+ await notifyBlocked(state, blocker, logger);
142
+ }
143
+ return flushDerivedPlanNotifications(state, statePath, logger, blocker);
144
+ }
145
+ const persistedState = await enterInteractiveBlockedRecovery(state, statePath, blocker, logger);
146
+ if (!derivedPlanReview && shouldNotifyInteractiveBlockedRecoveryEntry(persistedState)) {
147
+ await notifyBlocked(persistedState, blocker, logger);
148
+ }
149
+ return flushDerivedPlanNotifications(persistedState, statePath, logger, blocker);
150
+ }
151
+ export function getPlanningResponseRetryPhase(mode) {
152
+ return mode === 'optional' ? 'coder_plan_optional_response' : 'coder_plan_response';
153
+ }
154
+ export async function runCoderPlanPhase(state, statePath, logger) {
155
+ await logger?.event('phase.start', { phase: 'coder_plan' });
156
+ if (isPlanRefinementState(state)) {
157
+ writeDiagnostic(`${formatPlanRefinementRoundLine({ round: state.rounds.length + 1, maxRounds: state.maxRounds })}\n`, logger);
158
+ }
159
+ let workingState = state;
160
+ let codex;
161
+ if (state.plannerSessionHandle && state.plannerSessionProtocol === null) {
162
+ throw new Error('Cannot resume planner planning phase without plannerSessionProtocol.');
163
+ }
164
+ const activePlannerSessionProtocol = state.plannerSessionHandle
165
+ ? state.plannerSessionProtocol
166
+ : 'structured_json_v1';
167
+ try {
168
+ codex = await runCoderPlanRound({
169
+ coder: state.agentConfig.planner,
170
+ cwd: state.cwd,
171
+ planDoc: state.planDoc,
172
+ sessionHandle: state.plannerSessionHandle,
173
+ coderSessionProtocol: state.plannerSessionProtocol,
174
+ unattended: state.unattended,
175
+ // Reinforce an author-declared `one_shot` only for the top-level authored plan; the
176
+ // coder_plan phase only runs for top-level `neal plan` refinement (derived-plan
177
+ // revisions run through coder_plan_response).
178
+ authoredOneShot: !isDerivedPlanReviewState(state) && state.authoredExecutionShape === 'one_shot',
179
+ onSessionStarted: async (sessionHandle) => {
180
+ state.plannerSessionHandle = sessionHandle;
181
+ state.plannerSessionProtocol = activePlannerSessionProtocol;
182
+ workingState = await saveState(statePath, {
183
+ ...workingState,
184
+ plannerSessionHandle: sessionHandle,
185
+ plannerSessionProtocol: activePlannerSessionProtocol,
186
+ });
187
+ },
188
+ logger,
189
+ });
190
+ }
191
+ catch (error) {
192
+ if (error instanceof CoderRoundError) {
193
+ if (shouldRetryCoderWithFreshSession(workingState, 'coder_plan', error)) {
194
+ return scheduleCoderFreshSessionRetry(workingState, statePath, 'coder_plan', error, logger);
195
+ }
196
+ if (isCoderTimeoutError(error)) {
197
+ await bestEffortCleanupTimedOutCoder(error.sessionHandle ?? workingState.plannerSessionHandle, logger);
198
+ }
199
+ const failedState = await persistCoderFailureState(workingState, statePath, 'coder_plan', error, logger);
200
+ if (shouldNotifyFailure(error)) {
201
+ await notifyBlocked(failedState, error.message, logger);
202
+ }
203
+ }
204
+ throw error;
205
+ }
206
+ const completionProblem = getPlanningCompletionProblem(codex.marker);
207
+ const dirtyWorktreeBlocker = await getPlanPhaseDirtyWorktreeBlocker(workingState, 'coder_plan');
208
+ const nextState = await saveState(statePath, {
209
+ ...workingState,
210
+ plannerSessionHandle: codex.sessionHandle,
211
+ plannerSessionProtocol: codex.sessionHandle ? activePlannerSessionProtocol : null,
212
+ lastScopeMarker: codex.marker,
213
+ phase: codex.marker === 'AUTONOMY_BLOCKED' || completionProblem || dirtyWorktreeBlocker ? 'blocked' : 'reviewer_plan',
214
+ status: codex.marker === 'AUTONOMY_BLOCKED' || completionProblem || dirtyWorktreeBlocker ? 'blocked' : 'running',
215
+ blockedFromPhase: codex.marker === 'AUTONOMY_BLOCKED' || completionProblem || dirtyWorktreeBlocker ? 'coder_plan' : null,
216
+ });
217
+ await writeExecutionArtifacts(nextState);
218
+ await logger?.event('phase.complete', {
219
+ phase: 'coder_plan',
220
+ marker: codex.marker,
221
+ sessionHandle: codex.sessionHandle,
222
+ nextPhase: nextState.phase,
223
+ });
224
+ if (nextState.status === 'blocked') {
225
+ const reason = dirtyWorktreeBlocker ?? completionProblem ?? 'The coder reported a blocker during plan revision';
226
+ if (nextState.topLevelMode !== 'execute') {
227
+ await notifyBlocked(nextState, reason, logger);
228
+ return nextState;
229
+ }
230
+ const persistedState = await enterInteractiveBlockedRecovery(nextState, statePath, reason, logger);
231
+ if (shouldNotifyInteractiveBlockedRecoveryEntry(persistedState)) {
232
+ await notifyBlocked(persistedState, reason, logger);
233
+ }
234
+ return persistedState;
235
+ }
236
+ return nextState;
237
+ }
238
+ export async function runPlanReviewPhase(state, statePath, logger) {
239
+ await logger?.event('phase.start', { phase: 'reviewer_plan', round: state.rounds.length + 1 });
240
+ const round = state.rounds.length + 1;
241
+ const normalizedPlanPath = getNormalizedPlanArtifactPath(state, resolvePlanningAdjudicationContext(state).reviewTargetPath);
242
+ let claude;
243
+ let context;
244
+ let preparedReview;
245
+ let synthesizedReview;
246
+ try {
247
+ ({
248
+ context,
249
+ preparedReview,
250
+ reviewerResult: claude,
251
+ synthesizedReview,
252
+ } = await runPlanningReviewerAdjudication({
253
+ state,
254
+ round,
255
+ reviewMarkdownPath: state.reviewMarkdownPath,
256
+ normalizedPlanPath,
257
+ logger,
258
+ preparePlanReviewArtifact,
259
+ synthesizePlanReviewFindings,
260
+ }));
261
+ }
262
+ catch (error) {
263
+ if (error instanceof ReviewerRoundError) {
264
+ const failedState = await saveState(statePath, {
265
+ ...state,
266
+ reviewerSessionHandle: null,
267
+ status: 'failed',
268
+ });
269
+ await writeExecutionArtifacts(failedState);
270
+ await logger?.event('phase.error', {
271
+ phase: 'reviewer_plan',
272
+ round,
273
+ sessionHandle: error.sessionHandle,
274
+ subtype: error.subtype,
275
+ message: error.message,
276
+ });
277
+ if (shouldNotifyFailure(error)) {
278
+ await notifyBlocked(failedState, error.message, logger);
279
+ }
280
+ }
281
+ throw error;
282
+ }
283
+ const { derivedPlanReview, roundLimit } = context;
284
+ // Plan refinement adopts the refined execution shape directly. An earlier hard clamp —
285
+ // forcing an author-declared `one_shot` plan back to `one_shot` and injecting a blocking
286
+ // shape-restoration finding when refinement expanded it — caused plan-review
287
+ // non-convergence on complex plans: the planner could not produce an accurate
288
+ // single-scope plan, so the reviewer kept (correctly) rejecting until the round cap
289
+ // failed the run. The over-decomposition concern the clamp targeted is better handled
290
+ // as a soft planner-prompt bias than a hard shape clamp.
291
+ const normalizedFindingInputs = synthesizedReview.findings;
292
+ 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 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');
336
+ const nextState = await saveState(statePath, {
337
+ ...state,
338
+ reviewerSessionHandle: claude.sessionHandle,
339
+ 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
+ ],
380
+ findings: mergedFindings,
381
+ derivedPlanStatus: derivedPlanReview && !shouldBlockForConvergence && !hasBlockingFindings && !hasOpenNonBlockingFindings
382
+ ? 'accepted'
383
+ : state.derivedPlanStatus,
384
+ blockedFromPhase: shouldBlockForConvergence || (hasBlockingFindings && reachedMaxRounds)
385
+ ? 'reviewer_plan'
386
+ : null,
387
+ });
388
+ await writeExecutionArtifacts(nextState);
389
+ await logger?.event('phase.complete', {
390
+ phase: 'reviewer_plan',
391
+ round,
392
+ sessionHandle: claude.sessionHandle,
393
+ findings: findings.length,
394
+ blockingFindings: findings.filter((finding) => finding.severity === 'blocking').length,
395
+ nextPhase: nextState.phase,
396
+ });
397
+ if (nextState.status === 'blocked' && blockReason) {
398
+ return finalizeBlockedPlanReviewResponse(nextState, statePath, derivedPlanReview, blockReason, logger);
399
+ }
400
+ if (shouldNotifyDerivedPlanAcceptance(state, nextState)) {
401
+ return flushDerivedPlanNotifications(nextState, statePath, logger);
402
+ }
403
+ if (nextState.status === 'done') {
404
+ await notifyComplete(nextState, 'Plan review converged', logger);
405
+ }
406
+ return nextState;
407
+ }
408
+ export async function runPlanningResponsePhase(state, statePath, phase, logger) {
409
+ const planningContext = resolvePlanningAdjudicationContext(state);
410
+ const { spec, derivedPlanReview } = planningContext;
411
+ // Top-level `neal plan` refinement (reviewMode === 'plan', i.e. !derivedPlanReview) is normally
412
+ // authored by a persisted planner session, so a missing handle stays a hard error for
413
+ // resume-capable planner providers. Derived-plan revisions are authored by the coder and create
414
+ // 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
416
+ // fresh planner session instead.
417
+ if (!derivedPlanReview &&
418
+ !state.plannerSessionHandle &&
419
+ !plannerProviderStartsFreshSessions(state.agentConfig.planner)) {
420
+ throw new Error(`Cannot run ${phase} phase without an existing planner session`);
421
+ }
422
+ // Mirror the coder.ts:150-152 pattern: keep the existing protocol for a resumed planner
423
+ // session, otherwise mint 'structured_json_v1' for a freshly created derived-plan session so a
424
+ // non-null sessionHandle is never persisted with a null plannerSessionProtocol.
425
+ const activePlannerSessionProtocol = state.plannerSessionHandle ? state.plannerSessionProtocol : 'structured_json_v1';
426
+ const mode = phase === 'coder_plan_optional_response' ? 'optional' : 'required';
427
+ await logger?.event('phase.start', { phase });
428
+ if (isPlanRefinementState(state)) {
429
+ writeDiagnostic(`${formatPlanRefinementRoundLine({ round: state.rounds.length + 1, maxRounds: state.maxRounds })}\n`, logger);
430
+ }
431
+ const openFindings = state.findings.filter(mode === 'optional' ? isOpenNonBlockingFinding : isOpenBlockingFinding);
432
+ if (openFindings.length === 0) {
433
+ return finalizePlanReviewResponseWithoutOpenFindings(state, statePath, phase, logger);
434
+ }
435
+ let codex;
436
+ try {
437
+ ({ response: codex } = await runPlanningResponseAdjudication({
438
+ state,
439
+ mode: mode === 'optional' ? 'optional' : undefined,
440
+ openFindings: openFindings.map((finding) => ({
441
+ id: finding.id,
442
+ source: finding.source,
443
+ claim: finding.claim,
444
+ requiredAction: finding.requiredAction,
445
+ severity: finding.severity,
446
+ files: finding.files,
447
+ roundSummary: finding.roundSummary,
448
+ })),
449
+ logger,
450
+ }));
451
+ }
452
+ catch (error) {
453
+ if (error instanceof CoderRoundError) {
454
+ const retryPhase = getPlanningResponseRetryPhase(mode);
455
+ if (shouldRetryCoderWithFreshSession(state, retryPhase, error)) {
456
+ return scheduleCoderFreshSessionRetry(state, statePath, retryPhase, error, logger);
457
+ }
458
+ if (isCoderTimeoutError(error)) {
459
+ await bestEffortCleanupTimedOutCoder(error.sessionHandle ?? state.plannerSessionHandle, logger);
460
+ }
461
+ const failedState = await persistCoderFailureState(state, statePath, retryPhase, error, logger);
462
+ if (shouldNotifyFailure(error)) {
463
+ await notifyBlocked(failedState, error.message, logger);
464
+ }
465
+ }
466
+ throw error;
467
+ }
468
+ const responseById = new Map(codex.payload.responses.map((response) => [response.id, response]));
469
+ assertAdjudicationTransitionSignal(spec, codex.payload.outcome === 'blocked' ? 'block_for_operator' : mode === 'optional' ? 'optional_revision' : 'request_revision', `orchestrator:${phase}`);
470
+ const dirtyWorktreeBlocker = await getPlanPhaseDirtyWorktreeBlocker(state, phase);
471
+ const findings = state.findings.map((finding) => {
472
+ const response = responseById.get(finding.id);
473
+ if (!response) {
474
+ return finding;
475
+ }
476
+ return {
477
+ ...finding,
478
+ status: mapDecisionToStatus(response.decision),
479
+ coderDisposition: response.summary,
480
+ coderCommit: null,
481
+ };
482
+ });
483
+ const nextState = await saveState(statePath, {
484
+ ...state,
485
+ plannerSessionHandle: codex.sessionHandle,
486
+ plannerSessionProtocol: codex.sessionHandle ? activePlannerSessionProtocol : null,
487
+ findings,
488
+ phase: dirtyWorktreeBlocker || codex.payload.outcome === 'blocked'
489
+ ? 'blocked'
490
+ : mode === 'optional'
491
+ ? derivedPlanReview
492
+ ? 'awaiting_derived_plan_execution'
493
+ : 'done'
494
+ : 'reviewer_plan',
495
+ status: dirtyWorktreeBlocker || codex.payload.outcome === 'blocked'
496
+ ? 'blocked'
497
+ : mode === 'optional' && !derivedPlanReview
498
+ ? 'done'
499
+ : 'running',
500
+ derivedPlanStatus: mode === 'optional' && codex.payload.outcome !== 'blocked' && derivedPlanReview
501
+ ? 'accepted'
502
+ : state.derivedPlanStatus,
503
+ blockedFromPhase: dirtyWorktreeBlocker || codex.payload.outcome === 'blocked' ? phase : null,
504
+ pendingPlanReviewGuidance: null,
505
+ });
506
+ await writeExecutionArtifacts(nextState);
507
+ await logger?.event('phase.complete', {
508
+ phase,
509
+ outcome: codex.payload.outcome,
510
+ respondedFindings: codex.payload.responses.length,
511
+ nextPhase: nextState.phase,
512
+ });
513
+ if (nextState.status === 'blocked') {
514
+ const blocker = getDerivedPlanBlockedReason(state, dirtyWorktreeBlocker ||
515
+ codex.payload.blocker?.trim() ||
516
+ codex.payload.summary.trim() ||
517
+ (mode === 'optional'
518
+ ? 'The coder reported a blocker while considering non-blocking plan findings'
519
+ : 'The coder reported a blocker during plan response'));
520
+ return finalizeBlockedPlanReviewResponse(nextState, statePath, derivedPlanReview, blocker, logger);
521
+ }
522
+ if (mode === 'optional' && shouldNotifyDerivedPlanAcceptance(state, nextState)) {
523
+ return flushDerivedPlanNotifications(nextState, statePath, logger);
524
+ }
525
+ if (mode === 'optional' && nextState.status === 'done') {
526
+ await notifyComplete(nextState, 'Plan review converged', logger);
527
+ }
528
+ return nextState;
529
+ }
530
+ export async function runAwaitingDerivedPlanExecutionPhase(state, statePath, logger) {
531
+ const nextState = await saveState(statePath, adoptAcceptedDerivedPlan(state));
532
+ await writeExecutionArtifacts(nextState);
533
+ await logger?.event('phase.complete', {
534
+ phase: 'awaiting_derived_plan_execution',
535
+ nextPhase: nextState.phase,
536
+ scopeNumber: getCurrentScopeLabel(nextState),
537
+ planDoc: getExecutionPlanPath(nextState),
538
+ });
539
+ return nextState;
540
+ }