@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.
- package/dist/neal/adjudicator/planning.js +48 -0
- package/dist/neal/agents/prompts.js +3 -0
- package/dist/neal/agents/rounds.js +8 -0
- package/dist/neal/agents/schemas.js +575 -496
- package/dist/neal/agents/structured-json.js +36 -0
- package/dist/neal/config.js +24 -0
- package/dist/neal/git.js +9 -3
- package/dist/neal/orchestrator/completion.js +167 -112
- package/dist/neal/orchestrator/phases/planning.js +14 -39
- package/dist/neal/orchestrator/split-plan.js +12 -11
- package/dist/neal/orchestrator/transitions.js +30 -71
- package/dist/neal/plan-doc.js +24 -1
- package/dist/neal/prompts/assert-builder.js +8 -1
- package/dist/neal/prompts/execute.js +4 -0
- package/dist/neal/prompts/specialized.js +22 -6
- package/dist/neal/prompts/specs.js +58 -0
- package/dist/neal/providers/anthropic-claude.js +291 -247
- package/dist/neal/providers/generic-agentic.js +18 -0
- package/dist/neal/providers/openai-codex.js +77 -201
- package/dist/neal/providers/openai-compatible.js +33 -5
- package/dist/neal/providers/pricing.js +124 -0
- package/dist/neal/providers/rate-card.js +2301 -0
- package/dist/neal/providers/telemetry.js +4 -0
- package/dist/neal/retrospective.js +33 -4
- package/dist/neal/run-metrics.js +74 -9
- package/docs/compatible-models.md +11 -0
- package/docs/issue-pipeline.md +124 -0
- package/docs/maintenance.md +30 -19
- package/docs/providers.md +117 -0
- package/docs/release.md +29 -25
- package/package.json +7 -3
|
@@ -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) {
|
package/dist/neal/config.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
|
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
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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:
|
|
341
|
-
|
|
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:
|
|
382
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
const
|
|
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
|
});
|