@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
@@ -11,8 +11,8 @@ import { getFinalCompletionReviewArtifactPath, readFinalCompletionUnstructuredOu
11
11
  import { EXECUTE_FINALIZATION_PHASE } from '../execute-finalization.js';
12
12
  import { getChangedFilesForRange, getCommitRange, getCommitMessage, getCommitSubjects, getHeadCommit, getStagedChangedFiles, getWorktreeStatus, assertNoIgnoredChangedFiles, cleanUntracked, resetHard, squashCommits as createScopeFinalizationCommit, stagePath, } from '../git.js';
13
13
  import { notifyBlocked, notifyComplete, notifyScopeAccepted } from './notifications.js';
14
- import { inspectPlanDocDisposition } from '../plan-doc.js';
15
- import { appendDerivedSubScopeAndParentCompletion, appendParentCompletionFromAcceptedDerivedScopes, computeNextScopeStateAfterExecuteFinalization, computeNextScopeStateAfterParentAdvance, } from './transitions.js';
14
+ import { inspectPlanDocDisposition, withPlanDocPreserved } from '../plan-doc.js';
15
+ import { appendDerivedSubScopeAndParentCompletion, appendParentCompletionFromAcceptedDerivedScopes, computeNextScopeStateAfterExecuteFinalization, computeNextScopeStateAfterParentAdvance, createNextScopeEntryReset, } from './transitions.js';
16
16
  import { writePlanProgressArtifacts } from '../progress.js';
17
17
  import { writeCheckpointRetrospective } from '../retrospective.js';
18
18
  import { renderReviewMarkdown, writeReviewMarkdown } from '../review.js';
@@ -21,7 +21,7 @@ import { saveState } from '../state.js';
21
21
  import { getFinalCompletionReviewerScratchDir, getScopeReviewerScratchDir } from '../storage-paths.js';
22
22
  import { requireFinalCompletionView, resolveFinalCompletionReviewAction, } from '../state-views.js';
23
23
  import { filterAllowedDirtyPathStatus, filterWrapperOwnedWorktreeStatus, formatDirtyWorktreeDiagnostic, } from '../worktree-status.js';
24
- import { shouldNotifyFailure } from './failures.js';
24
+ import { REVIEWER_CONTENT_REFUSED_BLOCK_REASON, shouldNotifyFailure } from './failures.js';
25
25
  import { persistUnattendedBlockUnresolvedFailure } from './phases/shared.js';
26
26
  async function writeFailedFinalCompletionReviewArtifact(args) {
27
27
  const unstructuredOutput = await readFinalCompletionUnstructuredOutput({
@@ -52,6 +52,38 @@ async function persistFailedFinalCompletionReview(args) {
52
52
  });
53
53
  return failedState;
54
54
  }
55
+ // Sibling of persistFailedFinalCompletionReview for the terminal blocked
56
+ // landing of a reviewer content-safety refusal (`content_refused`). Only a
57
+ // ReviewerRoundError can carry that kind; FinalCompletionReviewerVerdictError
58
+ // never does, so persistFailedFinalCompletionReview keeps serving the
59
+ // verdict-invalid path unchanged. Persists a terminal
60
+ // phase:'blocked'/status:'blocked' state so the run ends blocked (exit 2)
61
+ // rather than as a generic terminal failure, still writing the failed
62
+ // final-completion review artifact and execution artifacts.
63
+ async function persistBlockedFinalCompletionReview(args) {
64
+ const blockedState = await saveState(args.statePath, {
65
+ ...args.state,
66
+ phase: 'blocked',
67
+ reviewerSessionHandle: null,
68
+ status: 'blocked',
69
+ blockedFromPhase: null,
70
+ blockerReason: REVIEWER_CONTENT_REFUSED_BLOCK_REASON,
71
+ });
72
+ await args.runtime.writeExecutionArtifacts(blockedState);
73
+ await writeFailedFinalCompletionReviewArtifact({
74
+ state: blockedState,
75
+ source: 'reviewer_verdict',
76
+ sessionHandle: args.error.sessionHandle,
77
+ });
78
+ await args.logger?.event('phase.error', {
79
+ phase: 'final_completion_review',
80
+ sessionHandle: args.error.sessionHandle,
81
+ subtype: args.error.subtype,
82
+ errorKind: args.error.kind,
83
+ message: args.error.message,
84
+ });
85
+ return blockedState;
86
+ }
55
87
  function normalizeFinalCommitMessage(message) {
56
88
  const normalizedNewlines = message.replace(/\r\n/g, '\n');
57
89
  const convertedEscapes = normalizedNewlines.replace(/\\n(?=- )/g, '\n');
@@ -105,7 +137,12 @@ function getFinalCompletionReviewBlockReason(args) {
105
137
  return `final_completion_review: reviewer blocked completion for operator guidance. ${args.rationale} One available next step is to inspect with \`neal status\` and provide guidance with \`neal resume --message "..."\` only if the run is waiting for operator guidance.`;
106
138
  }
107
139
  async function discardFinalCompletionReviewerWorktreeChanges(args) {
108
- const statusOutput = filterWrapperOwnedWorktreeStatus(await getWorktreeStatus(args.state.cwd, { untrackedFiles: 'all' }));
140
+ // The plan document is a wrapper-owned overlay, not reviewer dirt: it may be
141
+ // an uncommitted modification of a tracked file (a reset --hard would
142
+ // silently swap in the committed content — a different plan — mid-run) or an
143
+ // untracked seed. Exclude it from the trigger and the post-check, and
144
+ // preserve its bytes across the destructive cleanup.
145
+ const statusOutput = filterAllowedDirtyPathStatus(args.state.cwd, filterWrapperOwnedWorktreeStatus(await getWorktreeStatus(args.state.cwd, { untrackedFiles: 'all' })), [args.state.planDoc]);
109
146
  if (!statusOutput.trim()) {
110
147
  return;
111
148
  }
@@ -115,9 +152,11 @@ async function discardFinalCompletionReviewerWorktreeChanges(args) {
115
152
  statusOutput,
116
153
  message: 'Final completion review is read-only; discarding reviewer-created worktree changes before state transition.',
117
154
  });
118
- await resetHard(args.state.cwd, headCommit);
119
- await cleanUntracked(args.state.cwd, ['.neal/', '.forge/', 'CURRENT_PLAN.md']);
120
- const remainingStatusOutput = filterWrapperOwnedWorktreeStatus(await getWorktreeStatus(args.state.cwd, { untrackedFiles: 'all' }));
155
+ await withPlanDocPreserved(args.state.planDoc, async () => {
156
+ await resetHard(args.state.cwd, headCommit);
157
+ await cleanUntracked(args.state.cwd, ['.neal/', '.forge/', 'CURRENT_PLAN.md']);
158
+ });
159
+ const remainingStatusOutput = filterAllowedDirtyPathStatus(args.state.cwd, filterWrapperOwnedWorktreeStatus(await getWorktreeStatus(args.state.cwd, { untrackedFiles: 'all' })), [args.state.planDoc]);
121
160
  if (remainingStatusOutput.trim()) {
122
161
  throw new Error(`Final completion review left a dirty worktree Neal could not restore:\n${remainingStatusOutput}`);
123
162
  }
@@ -149,7 +188,8 @@ async function runParentAdvanceFinalization(args) {
149
188
  `Neal-created commits: ${formatCommitList(state.createdCommits)}`,
150
189
  ].join('\n'));
151
190
  }
152
- await resetHard(state.cwd, state.baseCommit);
191
+ const parentBaseCommit = state.baseCommit;
192
+ await withPlanDocPreserved(state.planDoc, () => resetHard(state.cwd, parentBaseCommit));
153
193
  }
154
194
  const finalCommit = state.baseCommit;
155
195
  const archivedReviewPath = join(state.runDir, `REVIEW-${finalCommit}.md`);
@@ -203,11 +243,10 @@ async function runParentAdvanceFinalization(args) {
203
243
  }, parentScope?.commitSubject ?? `Parent scope ${parentScopeLabel} complete via derived plan`, logger);
204
244
  return nextState;
205
245
  }
206
- export async function runExecuteFinalizationPhase(state, statePath, logger, runtime) {
207
- if (!state.baseCommit) {
208
- throw new Error('Cannot finalize without a baseCommit');
209
- }
210
- await logger?.event('phase.start', { phase: EXECUTE_FINALIZATION_PHASE });
246
+ // Dirty-worktree admission for execute finalization: capture the head commit,
247
+ // filter the worktree status down to disallowed dirt, and refuse to finalize
248
+ // (with the expected-scratch-dir diagnostic) when any remains.
249
+ async function admitFinalizationWorktree(state) {
211
250
  const headCommit = await getHeadCommit(state.cwd);
212
251
  const statusOutput = filterAllowedDirtyPathStatus(state.cwd, filterWrapperOwnedWorktreeStatus(await getWorktreeStatus(state.cwd)), state.allowedDirtyPaths);
213
252
  if (statusOutput) {
@@ -217,6 +256,132 @@ export async function runExecuteFinalizationPhase(state, statePath, logger, runt
217
256
  });
218
257
  throw new Error([`Cannot finalize with a dirty worktree:\n${statusOutput}`, diagnostic].filter(Boolean).join('\n\n'));
219
258
  }
259
+ return { headCommit, statusOutput };
260
+ }
261
+ // Raw material for final-commit message synthesis: the full message of the
262
+ // latest Neal-created commit, falling back to the last recorded subject (with
263
+ // any leading hash stripped) and then to the fixed default.
264
+ async function readRawFinalCommitMessage(cwd, createdCommits) {
265
+ const commitSubjects = await getCommitSubjects(cwd, createdCommits);
266
+ const latestCreatedCommit = createdCommits.at(-1) ?? null;
267
+ return latestCreatedCommit
268
+ ? await getCommitMessage(cwd, latestCreatedCommit)
269
+ : commitSubjects.at(-1)?.replace(/^[a-f0-9]+\s+/, '') || 'Finalize scope work';
270
+ }
271
+ // Final-commit message synthesis: strip the scope prefix, normalize newlines
272
+ // (falling back to the fixed default when stripping leaves nothing), apply the
273
+ // plan-doc-only special case, and derive the subject line.
274
+ function synthesizeFinalCommitMessage(args) {
275
+ const strippedFinalMessage = stripScopePrefixFromCommitMessage(args.rawFinalMessage);
276
+ const finalMessage = normalizeFinalCommitMessage(strippedFinalMessage.trim() ? strippedFinalMessage : 'Finalize scope work');
277
+ const onlyChangedFile = args.finalizationChangedFiles.length === 1 ? args.finalizationChangedFiles[0] : null;
278
+ const onlyChangedPlanDoc = onlyChangedFile !== null &&
279
+ args.planDocRepoRelativePath !== null &&
280
+ onlyChangedFile === args.planDocRepoRelativePath;
281
+ const finalCommitMessage = onlyChangedPlanDoc ? 'Record Neal plan document\n' : finalMessage;
282
+ const finalSubject = finalCommitMessage.split(/\r?\n/, 1)[0] || 'Finalize scope work';
283
+ return { finalCommitMessage, finalSubject };
284
+ }
285
+ // The one shared failure-persistence path for both summary-adjudication catch
286
+ // branches. Ordering is pinned by test/execute-finalization-failure.test.ts:
287
+ // failed state save, execution artifacts, failed final-completion review
288
+ // artifact (coder-round branch only), checkpoint retrospective, phase.error,
289
+ // gated notification (coder-round branch only).
290
+ async function persistExecuteFinalizationFailure(args) {
291
+ const { state, coderRoundExtras } = args;
292
+ const failedState = await saveState(args.statePath, {
293
+ ...state,
294
+ finalCommit: args.finalCommit,
295
+ archivedReviewPath: args.archivedReviewPath,
296
+ completedScopes: args.completedScopes,
297
+ ...(coderRoundExtras
298
+ ? {
299
+ coderSessionHandle: coderRoundExtras.sessionHandleOverride,
300
+ coderSessionProtocol: coderRoundExtras.sessionHandleOverride
301
+ ? state.coderSessionProtocol ?? 'structured_json_v1'
302
+ : null,
303
+ }
304
+ : {}),
305
+ status: 'failed',
306
+ });
307
+ await args.runtime.writeExecutionArtifacts(failedState);
308
+ if (coderRoundExtras) {
309
+ await writeFailedFinalCompletionReviewArtifact({
310
+ state: failedState,
311
+ source: 'coder_summary',
312
+ sessionHandle: coderRoundExtras.sessionHandleOverride,
313
+ });
314
+ }
315
+ await writeCheckpointRetrospective({
316
+ ...failedState,
317
+ finalCompletionSummary: args.finalCompletionSummary,
318
+ }, 'failed');
319
+ await args.logger?.event('phase.error', {
320
+ phase: EXECUTE_FINALIZATION_PHASE,
321
+ sessionHandle: coderRoundExtras ? coderRoundExtras.sessionHandleOverride : state.coderSessionHandle,
322
+ message: args.errorMessage,
323
+ });
324
+ if (coderRoundExtras?.notify) {
325
+ await notifyBlocked(failedState, args.errorMessage, args.logger);
326
+ }
327
+ }
328
+ // Summary adjudication for a terminal finalization: archive the review, build
329
+ // the final-completion packet, run the coder final-completion summary round,
330
+ // and route BOTH failure branches through the shared persistence helper
331
+ // before rethrowing.
332
+ async function adjudicateFinalCompletionSummary(args) {
333
+ const { state, logger } = args;
334
+ await writeTextAtomic(args.archivedReviewPath, renderReviewMarkdown({ ...args.archivedReviewState, finalCompletionSummary: null, finalCompletionReviewVerdict: null }));
335
+ const packet = await buildFinalCompletionPacket({
336
+ state: args.retrospectiveState,
337
+ terminalScope: {
338
+ finalCommit: args.finalCommit,
339
+ commitSubject: args.finalSubject,
340
+ changedFiles: args.finalizationChangedFiles,
341
+ archivedReviewPath: args.archivedReviewPath,
342
+ marker: state.lastScopeMarker,
343
+ },
344
+ });
345
+ try {
346
+ const { summary: finalCompletion } = await runFinalCompletionSummaryAdjudication({
347
+ state,
348
+ packet,
349
+ logger,
350
+ });
351
+ return finalCompletion.summary;
352
+ }
353
+ catch (error) {
354
+ const coderRoundFailure = error instanceof CoderRoundError ? error : null;
355
+ await persistExecuteFinalizationFailure({
356
+ state,
357
+ statePath: args.statePath,
358
+ runtime: args.runtime,
359
+ logger,
360
+ finalCommit: args.finalCommit,
361
+ archivedReviewPath: args.archivedReviewPath,
362
+ completedScopes: args.completedScopes,
363
+ finalCompletionSummary: args.finalCompletionSummary,
364
+ errorMessage: coderRoundFailure
365
+ ? coderRoundFailure.message
366
+ : error instanceof Error
367
+ ? error.message
368
+ : String(error),
369
+ coderRoundExtras: coderRoundFailure
370
+ ? {
371
+ sessionHandleOverride: coderRoundFailure.sessionHandle ?? state.coderSessionHandle,
372
+ notify: shouldNotifyFailure(coderRoundFailure),
373
+ }
374
+ : null,
375
+ });
376
+ throw error;
377
+ }
378
+ }
379
+ export async function runExecuteFinalizationPhase(state, statePath, logger, runtime) {
380
+ if (!state.baseCommit) {
381
+ throw new Error('Cannot finalize without a baseCommit');
382
+ }
383
+ await logger?.event('phase.start', { phase: EXECUTE_FINALIZATION_PHASE });
384
+ const { headCommit, statusOutput } = await admitFinalizationWorktree(state);
220
385
  if (state.currentScopeMeaningfulProgressVerdict?.action === 'advance_parent') {
221
386
  return runParentAdvanceFinalization({
222
387
  state,
@@ -227,13 +392,7 @@ export async function runExecuteFinalizationPhase(state, statePath, logger, runt
227
392
  statusOutput,
228
393
  });
229
394
  }
230
- const commitSubjects = await getCommitSubjects(state.cwd, state.createdCommits);
231
- const latestCreatedCommit = state.createdCommits.at(-1) ?? null;
232
- const rawFinalMessage = latestCreatedCommit
233
- ? await getCommitMessage(state.cwd, latestCreatedCommit)
234
- : commitSubjects.at(-1)?.replace(/^[a-f0-9]+\s+/, '') || 'Finalize scope work';
235
- const strippedFinalMessage = stripScopePrefixFromCommitMessage(rawFinalMessage);
236
- const finalMessage = normalizeFinalCommitMessage(strippedFinalMessage.trim() ? strippedFinalMessage : 'Finalize scope work');
395
+ const rawFinalMessage = await readRawFinalCommitMessage(state.cwd, state.createdCommits);
237
396
  const planDocInspection = await inspectPlanDocDisposition(state.cwd, state.planDoc);
238
397
  if (planDocInspection.eligibleForCommit && planDocInspection.repoRelativePath) {
239
398
  await stagePath(state.cwd, planDocInspection.repoRelativePath);
@@ -242,12 +401,11 @@ export async function runExecuteFinalizationPhase(state, statePath, logger, runt
242
401
  const stagedChangedFiles = await getStagedChangedFiles(state.cwd);
243
402
  const finalizationChangedFiles = unionChangedFiles(changedFilesSinceBase, stagedChangedFiles);
244
403
  await assertNoIgnoredChangedFiles(state.cwd, finalizationChangedFiles, 'Execute finalization');
245
- const onlyChangedFile = finalizationChangedFiles.length === 1 ? finalizationChangedFiles[0] : null;
246
- const onlyChangedPlanDoc = onlyChangedFile !== null &&
247
- planDocInspection.repoRelativePath !== null &&
248
- onlyChangedFile === planDocInspection.repoRelativePath;
249
- const finalCommitMessage = onlyChangedPlanDoc ? 'Record Neal plan document\n' : finalMessage;
250
- const finalSubject = finalCommitMessage.split(/\r?\n/, 1)[0] || 'Finalize scope work';
404
+ const { finalCommitMessage, finalSubject } = synthesizeFinalCommitMessage({
405
+ rawFinalMessage,
406
+ finalizationChangedFiles,
407
+ planDocRepoRelativePath: planDocInspection.repoRelativePath,
408
+ });
251
409
  const finalCommit = finalizationChangedFiles.length > 0
252
410
  ? await createScopeFinalizationCommit(state.cwd, state.baseCommit, finalCommitMessage)
253
411
  : headCommit;
@@ -278,78 +436,20 @@ export async function runExecuteFinalizationPhase(state, statePath, logger, runt
278
436
  const initialFinalCompletion = requireFinalCompletionView(state, 'run execute finalization');
279
437
  let finalCompletionSummary = initialFinalCompletion.summary;
280
438
  if (!continueScopes && !finalCompletionSummary) {
281
- await writeTextAtomic(archivedReviewPath, renderReviewMarkdown({ ...archivedReviewState, finalCompletionSummary: null, finalCompletionReviewVerdict: null }));
282
- const packet = await buildFinalCompletionPacket({
283
- state: retrospectiveState,
284
- terminalScope: {
285
- finalCommit,
286
- commitSubject: finalSubject,
287
- changedFiles: finalizationChangedFiles,
288
- archivedReviewPath,
289
- marker: state.lastScopeMarker,
290
- },
439
+ finalCompletionSummary = await adjudicateFinalCompletionSummary({
440
+ state,
441
+ statePath,
442
+ logger,
443
+ runtime,
444
+ finalCommit,
445
+ archivedReviewPath,
446
+ archivedReviewState,
447
+ retrospectiveState,
448
+ completedScopes,
449
+ finalSubject,
450
+ finalizationChangedFiles,
451
+ finalCompletionSummary,
291
452
  });
292
- try {
293
- const { summary: finalCompletion } = await runFinalCompletionSummaryAdjudication({
294
- state,
295
- packet,
296
- logger,
297
- });
298
- finalCompletionSummary = finalCompletion.summary;
299
- }
300
- catch (error) {
301
- if (error instanceof CoderRoundError) {
302
- const failedState = await saveState(statePath, {
303
- ...state,
304
- finalCommit,
305
- archivedReviewPath,
306
- completedScopes,
307
- coderSessionHandle: error.sessionHandle ?? state.coderSessionHandle,
308
- coderSessionProtocol: (error.sessionHandle ?? state.coderSessionHandle)
309
- ? state.coderSessionProtocol ?? 'structured_json_v1'
310
- : null,
311
- status: 'failed',
312
- });
313
- await runtime.writeExecutionArtifacts(failedState);
314
- await writeFailedFinalCompletionReviewArtifact({
315
- state: failedState,
316
- source: 'coder_summary',
317
- sessionHandle: error.sessionHandle ?? state.coderSessionHandle,
318
- });
319
- await writeCheckpointRetrospective({
320
- ...failedState,
321
- finalCompletionSummary,
322
- }, 'failed');
323
- await logger?.event('phase.error', {
324
- phase: EXECUTE_FINALIZATION_PHASE,
325
- sessionHandle: error.sessionHandle ?? state.coderSessionHandle,
326
- message: error.message,
327
- });
328
- if (shouldNotifyFailure(error)) {
329
- await notifyBlocked(failedState, error.message, logger);
330
- }
331
- }
332
- else {
333
- const failedState = await saveState(statePath, {
334
- ...state,
335
- finalCommit,
336
- archivedReviewPath,
337
- completedScopes,
338
- status: 'failed',
339
- });
340
- await runtime.writeExecutionArtifacts(failedState);
341
- await writeCheckpointRetrospective({
342
- ...failedState,
343
- finalCompletionSummary,
344
- }, 'failed');
345
- await logger?.event('phase.error', {
346
- phase: EXECUTE_FINALIZATION_PHASE,
347
- sessionHandle: state.coderSessionHandle,
348
- message: error instanceof Error ? error.message : String(error),
349
- });
350
- }
351
- throw error;
352
- }
353
453
  }
354
454
  const nextState = await saveState(statePath, continueScopes
355
455
  ? {
@@ -412,6 +512,21 @@ export async function runFinalCompletionReviewPhase(state, statePath, logger, ru
412
512
  }));
413
513
  }
414
514
  catch (error) {
515
+ // A reviewer content-safety refusal ends the run blocked (exit 2) with a
516
+ // durable actionable reason, by RETURNING the terminal blocked state rather
517
+ // than throwing. Only a ReviewerRoundError carries `content_refused`; the
518
+ // verdict-invalid error never does, so it stays on the failed path below.
519
+ if (error instanceof ReviewerRoundError && error.kind === 'content_refused') {
520
+ const blockedState = await persistBlockedFinalCompletionReview({
521
+ state,
522
+ statePath,
523
+ error,
524
+ logger,
525
+ runtime,
526
+ });
527
+ await notifyBlocked(blockedState, REVIEWER_CONTENT_REFUSED_BLOCK_REASON, logger);
528
+ return blockedState;
529
+ }
415
530
  if (error instanceof ReviewerRoundError || error instanceof FinalCompletionReviewerVerdictError) {
416
531
  const failedState = await persistFailedFinalCompletionReview({
417
532
  state,
@@ -462,32 +577,19 @@ export async function runFinalCompletionReviewPhase(state, statePath, logger, ru
462
577
  : actionResolution.effectiveAction === 'continue_execution'
463
578
  ? await saveState(statePath, {
464
579
  ...baseState,
465
- baseCommit: state.finalCommit,
466
- finalCommit: null,
467
- archivedReviewPath: null,
468
- coderSessionHandle: null,
469
- coderSessionProtocol: null,
580
+ ...createNextScopeEntryReset(state.finalCommit),
470
581
  coderRetryCount: 0,
471
582
  currentScopeNumber: shouldAdvanceTopLevelScopeNumber(state)
472
583
  ? state.currentScopeNumber + 1
473
584
  : state.currentScopeNumber,
474
- lastScopeMarker: null,
475
- currentScopeProgressJustification: null,
476
- currentScopeMeaningfulProgressVerdict: null,
477
- finalCompletionSummary: null,
478
- rounds: [],
479
- recentBlocks: [],
480
585
  // The reopened follow-on scope is a fresh scope boundary: per-scope
481
586
  // budgets reset here exactly as they do at every scope-advance
482
587
  // transition, so an earlier scope's adjudication or split-plan
483
588
  // consumption never exhausts the reopened scope's budget.
484
- reviewStuckArbiterCount: 0,
589
+ // (`consultantAttemptCount` resets via the shared next-scope
590
+ // reset spread above.)
485
591
  splitPlanCountForCurrentScope: 0,
486
- findings: [],
487
- createdCommits: [],
488
592
  blockedFromPhase: null,
489
- phase: 'coder_scope',
490
- status: 'running',
491
593
  })
492
594
  : unattendedFinalCompletionBlock
493
595
  ? await persistUnattendedBlockUnresolvedFailure({
@@ -1,4 +1,11 @@
1
1
  import { CoderRoundError } from '../agents.js';
2
+ // Shared, durable blocked reason for a reviewer content-safety refusal
3
+ // (`content_refused`). Defined once so the wording cannot drift across the
4
+ // three reviewer-phase catch sites that route it to a terminal blocked landing.
5
+ export const REVIEWER_CONTENT_REFUSED_BLOCK_REASON = 'The reviewer refused this content on content-safety grounds. The change ' +
6
+ 'under review may read as security-adjacent. Switch the reviewer to a ' +
7
+ 'different provider, rephrase the scope, or seek provider authorization, ' +
8
+ 'then re-run.';
2
9
  export function isCoderTimeoutError(error) {
3
10
  return (error.kind === 'timeout' ||
4
11
  error.kind === 'no_progress_timeout' ||
@@ -3,40 +3,40 @@ import { notify } from '../../notifier.js';
3
3
  import { getCurrentScopeLabel, getExecutionPlanPath, getExecutionPlanScopeCount, getParentScopeLabel, renderScopeProgressSegments, } from '../scopes.js';
4
4
  import { saveState } from '../state.js';
5
5
  import { getDerivedPlanView } from '../state-views.js';
6
- // Concise representation of the read-only blocked-adjudicator advice for the
6
+ // Concise representation of the read-only consultant advice for the
7
7
  // operator notification surface. Present only on an attended run whose active
8
- // interactive-blocked-recovery record carries adjudicator advice (knob > 0,
8
+ // interactive-blocked-recovery record carries consultant advice (knob > 0,
9
9
  // budget available, eligible source phase). Returns '' otherwise so notification
10
10
  // behavior for recovery states without advice — and terminal blocked
11
11
  // notifications, where the active record is already finalized to null — is
12
12
  // unchanged.
13
- function adjudicatorAdviceNotificationSuffix(state) {
14
- const advice = state.interactiveBlockedRecovery?.adjudicatorAdvice;
13
+ function consultantAdviceNotificationSuffix(state) {
14
+ const advice = state.interactiveBlockedRecovery?.consultantAdvice;
15
15
  if (!advice) {
16
16
  return '';
17
17
  }
18
18
  const directive = advice.resolutionDirective.trim() || 'n/a';
19
- return ` | adjudicator advice (read-only): triage ${advice.triageCategory}; suggested directive: ${directive}`;
19
+ return ` | consultant advice (read-only): triage ${advice.triageCategory}; suggested directive: ${directive}`;
20
20
  }
21
21
  async function notifyBlocked(state, reason, logger) {
22
22
  const planName = basename(state.planDoc);
23
- const adviceSuffix = adjudicatorAdviceNotificationSuffix(state);
23
+ const adviceSuffix = consultantAdviceNotificationSuffix(state);
24
24
  await logger?.event('notify.blocked', {
25
25
  reason,
26
26
  planName,
27
- adjudicatorAdvice: state.interactiveBlockedRecovery?.adjudicatorAdvice ?? null,
27
+ consultantAdvice: state.interactiveBlockedRecovery?.consultantAdvice ?? null,
28
28
  });
29
29
  await notify('blocked', `[neal] ${planName}: ${reason}${adviceSuffix}`, state.cwd);
30
30
  }
31
31
  async function notifyInteractiveBlockedRecovery(state, reason, logger) {
32
32
  const planName = basename(state.planDoc);
33
33
  const scopeLabel = getCurrentScopeLabel(state);
34
- const adviceSuffix = adjudicatorAdviceNotificationSuffix(state);
34
+ const adviceSuffix = consultantAdviceNotificationSuffix(state);
35
35
  await logger?.event('notify.interactive_blocked_recovery', {
36
36
  reason,
37
37
  planName,
38
38
  scopeNumber: scopeLabel,
39
- adjudicatorAdvice: state.interactiveBlockedRecovery?.adjudicatorAdvice ?? null,
39
+ consultantAdvice: state.interactiveBlockedRecovery?.consultantAdvice ?? null,
40
40
  });
41
41
  await notify('retry', `[neal] ${planName}: interactive blocked recovery for scope ${scopeLabel}: ${reason}${adviceSuffix}`, state.cwd);
42
42
  }