@navels/neal 0.1.0 → 0.2.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.
@@ -28,6 +28,42 @@ export function buildStructuredJsonPrompt(basePrompt, protocol) {
28
28
  }
29
29
  return lines.join('\n');
30
30
  }
31
+ // Base-prompt output-format phrasings that contradict the neal-json transport
32
+ // contract appended by buildStructuredJsonPrompt (which requires a final fenced
33
+ // ```neal-json block, optionally preceded by prose). A structured-JSON base
34
+ // prompt must not carry its own "no fences / no prose outside the JSON object"
35
+ // instruction, because that flatly contradicts the transport for whichever
36
+ // provider renders it. This mirrors the assertNoReadPromptInstructionText /
37
+ // NO_READ_PROMPT_FORBIDDEN_MARKERS precedent in
38
+ // src/neal/context/inline-review-context.ts: a shared marker list plus a
39
+ // render-time guard, so a reintroduced contradiction throws at build time
40
+ // instead of silently shipping. The markers are chosen so they never match the
41
+ // transport wrapper's own lines above (no bare 'fence'/'fenced' marker).
42
+ export const CONFLICTING_OUTPUT_FORMAT_MARKERS = [
43
+ 'do not include markdown fences',
44
+ 'markdown fences or prose outside',
45
+ 'outside the JSON object',
46
+ ];
47
+ // Implementation-side guard for builder-owned static output-format instruction
48
+ // text in structured-JSON base prompts. Call it only on Neal-authored
49
+ // instruction lines (never on dynamic content such as JSON.stringify segments
50
+ // or coder-authored summaries, which may legitimately mention these phrases).
51
+ export function assertNoConflictingOutputFormatInstruction(text, label) {
52
+ const lowered = text.toLowerCase();
53
+ const violations = CONFLICTING_OUTPUT_FORMAT_MARKERS.filter((marker) => lowered.includes(marker.toLowerCase()));
54
+ if (violations.length > 0) {
55
+ throw new Error(`${label} produced a structured-JSON base prompt with output-format instructions that conflict with the neal-json transport contract: ${violations.join(', ')}`);
56
+ }
57
+ }
58
+ // Single guarded-assembly seam for structured-JSON base-prompt output-format
59
+ // lines: asserts the assembled lines carry no transport-conflicting phrasing,
60
+ // then returns a defensive copy. Base-prompt builders funnel their
61
+ // output-format instructions through this so a reintroduced contradiction
62
+ // throws at render, and tests can inject a marker through this one unit.
63
+ export function guardStructuredJsonOutputFormatLines(lines, label) {
64
+ assertNoConflictingOutputFormatInstruction(lines.join('\n'), label);
65
+ return [...lines];
66
+ }
31
67
  export function extractStructuredJsonPayload(assistantText) {
32
68
  const blocks = findNealJsonBlocks(assistantText);
33
69
  if (blocks.length > 1) {
@@ -346,6 +346,28 @@ function parseOpenAICompatibleHeaders(value) {
346
346
  }
347
347
  return headers;
348
348
  }
349
+ function parseOpenAICompatiblePricing(value) {
350
+ if (value === undefined || value === null) {
351
+ return null;
352
+ }
353
+ if (typeof value !== 'object' || Array.isArray(value)) {
354
+ throw new Error('Invalid providers.openai_compatible.pricing: expected a map with input_per_million, ' +
355
+ 'cached_input_per_million, and output_per_million rates.');
356
+ }
357
+ const record = value;
358
+ const parseRate = (key) => {
359
+ const rate = record[key];
360
+ if (typeof rate !== 'number' || !Number.isFinite(rate) || rate < 0) {
361
+ throw new Error(`Invalid providers.openai_compatible.pricing: "${key}" must be a finite non-negative number.`);
362
+ }
363
+ return rate;
364
+ };
365
+ return {
366
+ inputPerMillion: parseRate('input_per_million'),
367
+ cachedInputPerMillion: parseRate('cached_input_per_million'),
368
+ outputPerMillion: parseRate('output_per_million'),
369
+ };
370
+ }
349
371
  export function getOpenAICompatibleSettings(cwd = process.cwd(), env = process.env) {
350
372
  const config = loadConfigFile(cwd).providers?.openai_compatible;
351
373
  const baseUrl = parseStringValue(config?.base_url) ??
@@ -357,12 +379,14 @@ export function getOpenAICompatibleSettings(cwd = process.cwd(), env = process.e
357
379
  parseStringValue(env.OPENAI_COMPATIBLE_MODEL) ??
358
380
  null;
359
381
  const headers = parseOpenAICompatibleHeaders(config?.headers);
382
+ const pricing = parseOpenAICompatiblePricing(config?.pricing);
360
383
  return {
361
384
  baseUrl,
362
385
  apiKeyEnv,
363
386
  apiKey,
364
387
  defaultModel,
365
388
  headers,
389
+ pricing,
366
390
  };
367
391
  }
368
392
  export function getDefaultCoderProvider(cwd = process.cwd()) {
package/dist/neal/git.js CHANGED
@@ -1,8 +1,14 @@
1
1
  import { execFile, spawn } from 'node:child_process';
2
2
  import { isAbsolute, resolve } from 'node:path';
3
+ // Git output (diffs, status) scales with scope artifacts — a scope that
4
+ // vendors or generates a large file produces a multi-megabyte diff, and
5
+ // Node's default execFile maxBuffer (1 MiB) kills the run with
6
+ // "stdout maxBuffer length exceeded". 64 MiB clears any realistic scope
7
+ // diff while still bounding a runaway.
8
+ const GIT_MAX_BUFFER = 64 * 1024 * 1024;
3
9
  function runGit(args, cwd) {
4
10
  return new Promise((resolvePromise, rejectPromise) => {
5
- execFile('git', args, { cwd }, (error, stdout, stderr) => {
11
+ execFile('git', args, { cwd, maxBuffer: GIT_MAX_BUFFER }, (error, stdout, stderr) => {
6
12
  if (error) {
7
13
  rejectPromise(new Error(stderr.trim() || error.message));
8
14
  return;
@@ -13,7 +19,7 @@ function runGit(args, cwd) {
13
19
  }
14
20
  function runGitOptionalConfig(args, cwd) {
15
21
  return new Promise((resolvePromise, rejectPromise) => {
16
- execFile('git', args, { cwd }, (error, stdout, stderr) => {
22
+ execFile('git', args, { cwd, maxBuffer: GIT_MAX_BUFFER }, (error, stdout, stderr) => {
17
23
  if (!error) {
18
24
  resolvePromise(stdout.trim());
19
25
  return;
@@ -158,7 +164,7 @@ export async function resolveCommitRef(cwd, ref) {
158
164
  }
159
165
  export async function isAncestorCommit(cwd, ancestor, descendant) {
160
166
  return new Promise((resolvePromise, rejectPromise) => {
161
- execFile('git', ['merge-base', '--is-ancestor', ancestor, descendant], { cwd }, (error, _stdout, stderr) => {
167
+ execFile('git', ['merge-base', '--is-ancestor', ancestor, descendant], { cwd, maxBuffer: GIT_MAX_BUFFER }, (error, _stdout, stderr) => {
162
168
  if (!error) {
163
169
  resolvePromise(true);
164
170
  return;
@@ -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';
@@ -105,7 +105,12 @@ function getFinalCompletionReviewBlockReason(args) {
105
105
  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
106
  }
107
107
  async function discardFinalCompletionReviewerWorktreeChanges(args) {
108
- const statusOutput = filterWrapperOwnedWorktreeStatus(await getWorktreeStatus(args.state.cwd, { untrackedFiles: 'all' }));
108
+ // The plan document is a wrapper-owned overlay, not reviewer dirt: it may be
109
+ // an uncommitted modification of a tracked file (a reset --hard would
110
+ // silently swap in the committed content — a different plan — mid-run) or an
111
+ // untracked seed. Exclude it from the trigger and the post-check, and
112
+ // preserve its bytes across the destructive cleanup.
113
+ const statusOutput = filterAllowedDirtyPathStatus(args.state.cwd, filterWrapperOwnedWorktreeStatus(await getWorktreeStatus(args.state.cwd, { untrackedFiles: 'all' })), [args.state.planDoc]);
109
114
  if (!statusOutput.trim()) {
110
115
  return;
111
116
  }
@@ -115,9 +120,11 @@ async function discardFinalCompletionReviewerWorktreeChanges(args) {
115
120
  statusOutput,
116
121
  message: 'Final completion review is read-only; discarding reviewer-created worktree changes before state transition.',
117
122
  });
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' }));
123
+ await withPlanDocPreserved(args.state.planDoc, async () => {
124
+ await resetHard(args.state.cwd, headCommit);
125
+ await cleanUntracked(args.state.cwd, ['.neal/', '.forge/', 'CURRENT_PLAN.md']);
126
+ });
127
+ const remainingStatusOutput = filterAllowedDirtyPathStatus(args.state.cwd, filterWrapperOwnedWorktreeStatus(await getWorktreeStatus(args.state.cwd, { untrackedFiles: 'all' })), [args.state.planDoc]);
121
128
  if (remainingStatusOutput.trim()) {
122
129
  throw new Error(`Final completion review left a dirty worktree Neal could not restore:\n${remainingStatusOutput}`);
123
130
  }
@@ -149,7 +156,8 @@ async function runParentAdvanceFinalization(args) {
149
156
  `Neal-created commits: ${formatCommitList(state.createdCommits)}`,
150
157
  ].join('\n'));
151
158
  }
152
- await resetHard(state.cwd, state.baseCommit);
159
+ const parentBaseCommit = state.baseCommit;
160
+ await withPlanDocPreserved(state.planDoc, () => resetHard(state.cwd, parentBaseCommit));
153
161
  }
154
162
  const finalCommit = state.baseCommit;
155
163
  const archivedReviewPath = join(state.runDir, `REVIEW-${finalCommit}.md`);
@@ -203,11 +211,10 @@ async function runParentAdvanceFinalization(args) {
203
211
  }, parentScope?.commitSubject ?? `Parent scope ${parentScopeLabel} complete via derived plan`, logger);
204
212
  return nextState;
205
213
  }
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 });
214
+ // Dirty-worktree admission for execute finalization: capture the head commit,
215
+ // filter the worktree status down to disallowed dirt, and refuse to finalize
216
+ // (with the expected-scratch-dir diagnostic) when any remains.
217
+ async function admitFinalizationWorktree(state) {
211
218
  const headCommit = await getHeadCommit(state.cwd);
212
219
  const statusOutput = filterAllowedDirtyPathStatus(state.cwd, filterWrapperOwnedWorktreeStatus(await getWorktreeStatus(state.cwd)), state.allowedDirtyPaths);
213
220
  if (statusOutput) {
@@ -217,6 +224,132 @@ export async function runExecuteFinalizationPhase(state, statePath, logger, runt
217
224
  });
218
225
  throw new Error([`Cannot finalize with a dirty worktree:\n${statusOutput}`, diagnostic].filter(Boolean).join('\n\n'));
219
226
  }
227
+ return { headCommit, statusOutput };
228
+ }
229
+ // Raw material for final-commit message synthesis: the full message of the
230
+ // latest Neal-created commit, falling back to the last recorded subject (with
231
+ // any leading hash stripped) and then to the fixed default.
232
+ async function readRawFinalCommitMessage(cwd, createdCommits) {
233
+ const commitSubjects = await getCommitSubjects(cwd, createdCommits);
234
+ const latestCreatedCommit = createdCommits.at(-1) ?? null;
235
+ return latestCreatedCommit
236
+ ? await getCommitMessage(cwd, latestCreatedCommit)
237
+ : commitSubjects.at(-1)?.replace(/^[a-f0-9]+\s+/, '') || 'Finalize scope work';
238
+ }
239
+ // Final-commit message synthesis: strip the scope prefix, normalize newlines
240
+ // (falling back to the fixed default when stripping leaves nothing), apply the
241
+ // plan-doc-only special case, and derive the subject line.
242
+ function synthesizeFinalCommitMessage(args) {
243
+ const strippedFinalMessage = stripScopePrefixFromCommitMessage(args.rawFinalMessage);
244
+ const finalMessage = normalizeFinalCommitMessage(strippedFinalMessage.trim() ? strippedFinalMessage : 'Finalize scope work');
245
+ const onlyChangedFile = args.finalizationChangedFiles.length === 1 ? args.finalizationChangedFiles[0] : null;
246
+ const onlyChangedPlanDoc = onlyChangedFile !== null &&
247
+ args.planDocRepoRelativePath !== null &&
248
+ onlyChangedFile === args.planDocRepoRelativePath;
249
+ const finalCommitMessage = onlyChangedPlanDoc ? 'Record Neal plan document\n' : finalMessage;
250
+ const finalSubject = finalCommitMessage.split(/\r?\n/, 1)[0] || 'Finalize scope work';
251
+ return { finalCommitMessage, finalSubject };
252
+ }
253
+ // The one shared failure-persistence path for both summary-adjudication catch
254
+ // branches. Ordering is pinned by test/execute-finalization-failure.test.ts:
255
+ // failed state save, execution artifacts, failed final-completion review
256
+ // artifact (coder-round branch only), checkpoint retrospective, phase.error,
257
+ // gated notification (coder-round branch only).
258
+ async function persistExecuteFinalizationFailure(args) {
259
+ const { state, coderRoundExtras } = args;
260
+ const failedState = await saveState(args.statePath, {
261
+ ...state,
262
+ finalCommit: args.finalCommit,
263
+ archivedReviewPath: args.archivedReviewPath,
264
+ completedScopes: args.completedScopes,
265
+ ...(coderRoundExtras
266
+ ? {
267
+ coderSessionHandle: coderRoundExtras.sessionHandleOverride,
268
+ coderSessionProtocol: coderRoundExtras.sessionHandleOverride
269
+ ? state.coderSessionProtocol ?? 'structured_json_v1'
270
+ : null,
271
+ }
272
+ : {}),
273
+ status: 'failed',
274
+ });
275
+ await args.runtime.writeExecutionArtifacts(failedState);
276
+ if (coderRoundExtras) {
277
+ await writeFailedFinalCompletionReviewArtifact({
278
+ state: failedState,
279
+ source: 'coder_summary',
280
+ sessionHandle: coderRoundExtras.sessionHandleOverride,
281
+ });
282
+ }
283
+ await writeCheckpointRetrospective({
284
+ ...failedState,
285
+ finalCompletionSummary: args.finalCompletionSummary,
286
+ }, 'failed');
287
+ await args.logger?.event('phase.error', {
288
+ phase: EXECUTE_FINALIZATION_PHASE,
289
+ sessionHandle: coderRoundExtras ? coderRoundExtras.sessionHandleOverride : state.coderSessionHandle,
290
+ message: args.errorMessage,
291
+ });
292
+ if (coderRoundExtras?.notify) {
293
+ await notifyBlocked(failedState, args.errorMessage, args.logger);
294
+ }
295
+ }
296
+ // Summary adjudication for a terminal finalization: archive the review, build
297
+ // the final-completion packet, run the coder final-completion summary round,
298
+ // and route BOTH failure branches through the shared persistence helper
299
+ // before rethrowing.
300
+ async function adjudicateFinalCompletionSummary(args) {
301
+ const { state, logger } = args;
302
+ await writeTextAtomic(args.archivedReviewPath, renderReviewMarkdown({ ...args.archivedReviewState, finalCompletionSummary: null, finalCompletionReviewVerdict: null }));
303
+ const packet = await buildFinalCompletionPacket({
304
+ state: args.retrospectiveState,
305
+ terminalScope: {
306
+ finalCommit: args.finalCommit,
307
+ commitSubject: args.finalSubject,
308
+ changedFiles: args.finalizationChangedFiles,
309
+ archivedReviewPath: args.archivedReviewPath,
310
+ marker: state.lastScopeMarker,
311
+ },
312
+ });
313
+ try {
314
+ const { summary: finalCompletion } = await runFinalCompletionSummaryAdjudication({
315
+ state,
316
+ packet,
317
+ logger,
318
+ });
319
+ return finalCompletion.summary;
320
+ }
321
+ catch (error) {
322
+ const coderRoundFailure = error instanceof CoderRoundError ? error : null;
323
+ await persistExecuteFinalizationFailure({
324
+ state,
325
+ statePath: args.statePath,
326
+ runtime: args.runtime,
327
+ logger,
328
+ finalCommit: args.finalCommit,
329
+ archivedReviewPath: args.archivedReviewPath,
330
+ completedScopes: args.completedScopes,
331
+ finalCompletionSummary: args.finalCompletionSummary,
332
+ errorMessage: coderRoundFailure
333
+ ? coderRoundFailure.message
334
+ : error instanceof Error
335
+ ? error.message
336
+ : String(error),
337
+ coderRoundExtras: coderRoundFailure
338
+ ? {
339
+ sessionHandleOverride: coderRoundFailure.sessionHandle ?? state.coderSessionHandle,
340
+ notify: shouldNotifyFailure(coderRoundFailure),
341
+ }
342
+ : null,
343
+ });
344
+ throw error;
345
+ }
346
+ }
347
+ export async function runExecuteFinalizationPhase(state, statePath, logger, runtime) {
348
+ if (!state.baseCommit) {
349
+ throw new Error('Cannot finalize without a baseCommit');
350
+ }
351
+ await logger?.event('phase.start', { phase: EXECUTE_FINALIZATION_PHASE });
352
+ const { headCommit, statusOutput } = await admitFinalizationWorktree(state);
220
353
  if (state.currentScopeMeaningfulProgressVerdict?.action === 'advance_parent') {
221
354
  return runParentAdvanceFinalization({
222
355
  state,
@@ -227,13 +360,7 @@ export async function runExecuteFinalizationPhase(state, statePath, logger, runt
227
360
  statusOutput,
228
361
  });
229
362
  }
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');
363
+ const rawFinalMessage = await readRawFinalCommitMessage(state.cwd, state.createdCommits);
237
364
  const planDocInspection = await inspectPlanDocDisposition(state.cwd, state.planDoc);
238
365
  if (planDocInspection.eligibleForCommit && planDocInspection.repoRelativePath) {
239
366
  await stagePath(state.cwd, planDocInspection.repoRelativePath);
@@ -242,12 +369,11 @@ export async function runExecuteFinalizationPhase(state, statePath, logger, runt
242
369
  const stagedChangedFiles = await getStagedChangedFiles(state.cwd);
243
370
  const finalizationChangedFiles = unionChangedFiles(changedFilesSinceBase, stagedChangedFiles);
244
371
  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';
372
+ const { finalCommitMessage, finalSubject } = synthesizeFinalCommitMessage({
373
+ rawFinalMessage,
374
+ finalizationChangedFiles,
375
+ planDocRepoRelativePath: planDocInspection.repoRelativePath,
376
+ });
251
377
  const finalCommit = finalizationChangedFiles.length > 0
252
378
  ? await createScopeFinalizationCommit(state.cwd, state.baseCommit, finalCommitMessage)
253
379
  : headCommit;
@@ -278,78 +404,20 @@ export async function runExecuteFinalizationPhase(state, statePath, logger, runt
278
404
  const initialFinalCompletion = requireFinalCompletionView(state, 'run execute finalization');
279
405
  let finalCompletionSummary = initialFinalCompletion.summary;
280
406
  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
- },
407
+ finalCompletionSummary = await adjudicateFinalCompletionSummary({
408
+ state,
409
+ statePath,
410
+ logger,
411
+ runtime,
412
+ finalCommit,
413
+ archivedReviewPath,
414
+ archivedReviewState,
415
+ retrospectiveState,
416
+ completedScopes,
417
+ finalSubject,
418
+ finalizationChangedFiles,
419
+ finalCompletionSummary,
291
420
  });
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
421
  }
354
422
  const nextState = await saveState(statePath, continueScopes
355
423
  ? {
@@ -462,32 +530,19 @@ export async function runFinalCompletionReviewPhase(state, statePath, logger, ru
462
530
  : actionResolution.effectiveAction === 'continue_execution'
463
531
  ? await saveState(statePath, {
464
532
  ...baseState,
465
- baseCommit: state.finalCommit,
466
- finalCommit: null,
467
- archivedReviewPath: null,
468
- coderSessionHandle: null,
469
- coderSessionProtocol: null,
533
+ ...createNextScopeEntryReset(state.finalCommit),
470
534
  coderRetryCount: 0,
471
535
  currentScopeNumber: shouldAdvanceTopLevelScopeNumber(state)
472
536
  ? state.currentScopeNumber + 1
473
537
  : state.currentScopeNumber,
474
- lastScopeMarker: null,
475
- currentScopeProgressJustification: null,
476
- currentScopeMeaningfulProgressVerdict: null,
477
- finalCompletionSummary: null,
478
- rounds: [],
479
- recentBlocks: [],
480
538
  // The reopened follow-on scope is a fresh scope boundary: per-scope
481
539
  // budgets reset here exactly as they do at every scope-advance
482
540
  // transition, so an earlier scope's adjudication or split-plan
483
541
  // consumption never exhausts the reopened scope's budget.
484
- reviewStuckArbiterCount: 0,
542
+ // (`reviewStuckArbiterCount` resets via the shared next-scope
543
+ // reset spread above.)
485
544
  splitPlanCountForCurrentScope: 0,
486
- findings: [],
487
- createdCommits: [],
488
545
  blockedFromPhase: null,
489
- phase: 'coder_scope',
490
- status: 'running',
491
546
  })
492
547
  : unattendedFinalCompletionBlock
493
548
  ? await persistUnattendedBlockUnresolvedFailure({
@@ -2,7 +2,7 @@ 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
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';
5
+ import { isDerivedPlanReviewState, plannerProviderStartsFreshSessions, resolvePlanningAdjudicationContext, resolvePlanReviewDisposition, runPlanningResponseAdjudication, runPlanningReviewerAdjudication, } from '../../adjudicator/planning.js';
6
6
  import { assertAdjudicationTransitionSignal } from '../../adjudicator/specs.js';
7
7
  import { getReviewStuckWindow } from '../../config.js';
8
8
  import { writeDiagnostic } from '../../diagnostic.js';
@@ -323,42 +323,21 @@ export async function runPlanReviewPhase(state, statePath, logger) {
323
323
  : reachedMaxRounds && hasBlockingFindings
324
324
  ? getDerivedPlanBlockedReason(state, `reached max review rounds (${roundLimit}) with blocking findings still open`)
325
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');
326
+ const disposition = resolvePlanReviewDisposition({
327
+ shouldBlockForConvergence,
328
+ hasBlockingFindings,
329
+ reachedMaxRounds,
330
+ hasOpenNonBlockingFindings,
331
+ derivedPlanReview,
332
+ currentDerivedPlanStatus: state.derivedPlanStatus,
333
+ });
334
+ assertAdjudicationTransitionSignal(context.spec, disposition.planningSignal, 'orchestrator:reviewer_plan');
336
335
  const nextState = await saveState(statePath, {
337
336
  ...state,
338
337
  reviewerSessionHandle: claude.sessionHandle,
339
338
  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',
339
+ phase: disposition.phase,
340
+ status: disposition.status,
362
341
  rounds: [
363
342
  ...state.rounds,
364
343
  {
@@ -378,12 +357,8 @@ export async function runPlanReviewPhase(state, statePath, logger) {
378
357
  },
379
358
  ],
380
359
  findings: mergedFindings,
381
- derivedPlanStatus: derivedPlanReview && !shouldBlockForConvergence && !hasBlockingFindings && !hasOpenNonBlockingFindings
382
- ? 'accepted'
383
- : state.derivedPlanStatus,
384
- blockedFromPhase: shouldBlockForConvergence || (hasBlockingFindings && reachedMaxRounds)
385
- ? 'reviewer_plan'
386
- : null,
360
+ derivedPlanStatus: disposition.derivedPlanStatus,
361
+ blockedFromPhase: disposition.blockedFromPhase,
387
362
  });
388
363
  await writeExecutionArtifacts(nextState);
389
364
  await logger?.event('phase.complete', {
@@ -2,12 +2,14 @@ import { readFile } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
3
  import { writeTextAtomic } from '../atomic-write.js';
4
4
  import { cleanUntracked, getStagedDiff, getUnstagedDiff, getUntrackedFiles, getWorktreeStatus, resetHard, } from '../git.js';
5
+ import { withPlanDocPreserved } from '../plan-doc.js';
5
6
  import { validatePlanDocument } from '../plan-validation.js';
6
7
  import { saveState } from '../state.js';
7
8
  import { getDerivedPlanCountersView } from '../state-views.js';
8
- import { filterWrapperOwnedWorktreeStatus, isWrapperOwnedPath, } from '../worktree-status.js';
9
+ import { filterAllowedDirtyPathStatus, filterWrapperOwnedWorktreeStatus, isWrapperOwnedPath, } from '../worktree-status.js';
9
10
  import { flushDerivedPlanNotifications, notifyBlocked } from './notifications.js';
10
11
  import { enterInteractiveBlockedRecovery, shouldNotifyInteractiveBlockedRecoveryEntry, } from './phases/recovery.js';
12
+ import { createScopeBoundaryReset } from './transitions.js';
11
13
  const MAX_SPLIT_PLANS_PER_SCOPE = 10;
12
14
  function getSplitPlanArtifactPaths(state) {
13
15
  return {
@@ -108,9 +110,14 @@ async function discardScopeWorktree(state) {
108
110
  if (!state.baseCommit) {
109
111
  throw new Error('Cannot discard scope worktree without a baseCommit');
110
112
  }
111
- await resetHard(state.cwd, state.baseCommit);
112
- await cleanUntracked(state.cwd, ['.neal', '.forge']);
113
- const remainingStatus = filterWrapperOwnedWorktreeStatus(await getWorktreeStatus(state.cwd));
113
+ // Preserve the plan-doc overlay (tracked-and-modified or untracked seed)
114
+ // across the reset; it is wrapper-owned, not scope work being discarded.
115
+ const scopeBaseCommit = state.baseCommit;
116
+ await withPlanDocPreserved(state.planDoc, async () => {
117
+ await resetHard(state.cwd, scopeBaseCommit);
118
+ await cleanUntracked(state.cwd, ['.neal', '.forge']);
119
+ });
120
+ const remainingStatus = filterAllowedDirtyPathStatus(state.cwd, filterWrapperOwnedWorktreeStatus(await getWorktreeStatus(state.cwd)), [state.planDoc]);
114
121
  if (remainingStatus) {
115
122
  throw new Error(`Failed to restore worktree to scope base ${state.baseCommit}:\n${remainingStatus}`);
116
123
  }
@@ -200,12 +207,11 @@ export async function persistSplitPlanRecovery(state, statePath, args, deps) {
200
207
  await discardScopeWorktree(state);
201
208
  const nextState = await saveState(statePath, {
202
209
  ...state,
210
+ ...createScopeBoundaryReset(),
203
211
  lastScopeMarker: 'AUTONOMY_SPLIT_PLAN',
204
212
  phase: 'reviewer_plan',
205
213
  status: 'running',
206
214
  blockedFromPhase: null,
207
- currentScopeProgressJustification: null,
208
- currentScopeMeaningfulProgressVerdict: null,
209
215
  interactiveBlockedRecovery: null,
210
216
  derivedPlanPath,
211
217
  derivedFromScopeNumber: state.currentScopeNumber,
@@ -215,11 +221,6 @@ export async function persistSplitPlanRecovery(state, statePath, args, deps) {
215
221
  splitPlanBlockedNotified: false,
216
222
  derivedScopeIndex: null,
217
223
  splitPlanCountForCurrentScope: state.splitPlanCountForCurrentScope + 1,
218
- rounds: [],
219
- recentBlocks: [],
220
- reviewStuckArbiterCount: 0,
221
- findings: [],
222
- createdCommits: [],
223
224
  coderRetryCount: 0,
224
225
  reviewerSessionHandle: null,
225
226
  });