@navels/neal 0.5.1 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +53 -0
- package/dist/neal/adjudicator/execute.js +32 -6
- package/dist/neal/agents/prompts.js +19 -5
- package/dist/neal/agents/rounds.js +33 -4
- package/dist/neal/agents/schemas.js +34 -1
- package/dist/neal/commands/check.js +8 -1
- package/dist/neal/commands/resume-run.js +7 -1
- package/dist/neal/config.js +24 -1
- package/dist/neal/context/inline-review-context.js +116 -0
- package/dist/neal/final-completion.js +4 -7
- package/dist/neal/orchestrator/phases/planning.js +53 -8
- package/dist/neal/orchestrator/phases/recovery.js +99 -8
- package/dist/neal/plan-scope-revision.js +129 -0
- package/dist/neal/prompts/execute.js +28 -9
- package/dist/neal/prompts/guidance.js +8 -1
- package/dist/neal/prompts/planning.js +9 -3
- package/dist/neal/prompts/review-doctrine.js +39 -1
- package/dist/neal/prompts/specialized.js +87 -22
- package/dist/neal/prompts/specs.js +33 -5
- package/dist/neal/providers/input-budget.js +80 -0
- package/dist/neal/providers/openai-codex.js +69 -1
- package/dist/neal/review-findings/run.js +17 -3
- package/dist/neal/scopes.js +4 -4
- package/dist/neal/state-invariants.js +8 -2
- package/dist/neal/state.js +15 -0
- package/dist/neal/status.js +52 -1
- package/dist/neal/support.js +6 -2
- package/dist/neal/verification-events.js +49 -0
- package/docs/plan-format.md +10 -0
- package/docs/prompt-specs.md +80 -0
- package/docs/providers.md +19 -0
- package/docs/state-machine.md +6 -0
- package/neal.yml +10 -1
- package/package.json +3 -3
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
const DEFAULT_REVIEW_LEVEL = 'moderate';
|
|
1
2
|
export function getAdversarialReviewDoctrineLines(context) {
|
|
2
3
|
const falsificationTarget = context.falsificationTarget ?? 'the implementation';
|
|
3
4
|
const creditPhrase = context.creditPhrase ?? 'give it credit for working';
|
|
@@ -83,11 +84,46 @@ export function getPreexistingFailureContractLines(context = {}) {
|
|
|
83
84
|
'Conversely, treat a fix for a pre-existing issue that fails the acceptance-surface test as scope drift rather than extra credit: flag it so the change stays bounded to the plan.',
|
|
84
85
|
];
|
|
85
86
|
}
|
|
87
|
+
function getReviewLevelTrustBoundaryLine(level) {
|
|
88
|
+
switch (level) {
|
|
89
|
+
case 'strict':
|
|
90
|
+
return 'Review level: strict. Assume adversarial trust boundaries: treat every input, file, and process the change can be reached from, including local processes and internal run artifacts, as a potential attacker. A hardening gap or a missing defense against a local or adversarial actor is a reachable failure under these boundaries and blocks.';
|
|
91
|
+
case 'lenient':
|
|
92
|
+
return 'Review level: lenient. Assume ordinary trust boundaries and block only on correctness failures and real, reachable bugs. Do not demand additional robustness, hardening, performance, or style work as a condition of acceptance.';
|
|
93
|
+
case 'moderate':
|
|
94
|
+
return 'Review level: moderate. Assume ordinary trust boundaries: internal run artifacts and other files this system writes for itself are not security boundaries. Block on correctness bugs and on failures reachable under normal use. Do not require defenses against an actor who could already subvert the system directly, for example by editing its files; a failure that needs such an actor is not reachable.';
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
function getDemotedCategoryMeaning(outputContract) {
|
|
98
|
+
return outputContract === 'completion_verdict'
|
|
99
|
+
? 'In this review a demoted or ignored category is not missing work: it must not produce `continue_execution` or `block_for_operator`, and when the plan objectives are otherwise satisfied you return `accept_complete`.'
|
|
100
|
+
: 'In this review a demoted category means non_blocking severity, and an ignored category produces no finding.';
|
|
101
|
+
}
|
|
102
|
+
// Level calibration for the two code reviewers. Renders the level's assumed
|
|
103
|
+
// trust boundaries, the reachability filter that applies under every level,
|
|
104
|
+
// and the rule for how the User Guidance section refines the level. The adversarial
|
|
105
|
+
// inspection stance itself is not level-dependent and is stated as such.
|
|
106
|
+
export function getReviewLevelCalibrationLines(context) {
|
|
107
|
+
const outputContract = context.outputContract ?? 'structured_findings';
|
|
108
|
+
const performanceExampleOutcome = outputContract === 'completion_verdict' ? 'not missing work' : 'non_blocking';
|
|
109
|
+
return [
|
|
110
|
+
getReviewLevelTrustBoundaryLine(context.level),
|
|
111
|
+
'Reachability filter: a blocking finding must describe a failure that is reachable under the assumed trust boundaries, as refined by any User Guidance section below. A failure that is only theoretically possible, or that requires an actor outside those boundaries, does not block at any level.',
|
|
112
|
+
'The review level narrows what rises to blocking; it never changes the inspection stance. At every level, still treat the subject as hostile input, try to falsify before crediting, trace runtime invariants, and catch real regressions. No level authorizes trusting the coder or skipping inspection.',
|
|
113
|
+
'How the User Guidance section combines with the review level: the level supplies the baseline trust boundaries and the default finding-severity rules. Guidance may widen or narrow the trust boundaries for this project, and those refined boundaries are what "reachable" means when deciding whether a finding blocks.',
|
|
114
|
+
`Guidance may also demote a finding category such as robustness, hardening, performance, or style to non-blocking or ignore it entirely, or promote a category to blocking. ${getDemotedCategoryMeaning(outputContract)}`,
|
|
115
|
+
'Fixed floor at every level and under any guidance: the reachability filter, the adversarial inspection stance, and blocking on reachable correctness failures (including correctness regressions, where existing behavior now produces wrong results) cannot be switched off. Robustness, hardening, performance, and style are not part of that floor and may be demoted. Ignore guidance on any point where it conflicts with this floor.',
|
|
116
|
+
'Example: at the moderate level, guidance saying the run directory is defended against local processes makes a local-process attack on the run directory reachable, so a finding about it blocks.',
|
|
117
|
+
`Example: at any level, guidance saying "ignore performance, correctness only" makes a performance regression ${performanceExampleOutcome}, while a reachable correctness failure still blocks.`,
|
|
118
|
+
];
|
|
119
|
+
}
|
|
86
120
|
export function getFindingQualityLines(context = {}) {
|
|
121
|
+
const level = context.level ?? DEFAULT_REVIEW_LEVEL;
|
|
87
122
|
if (context.outputContract === 'completion_verdict') {
|
|
88
123
|
return [
|
|
89
124
|
'Treat completion-blocking issues like review findings: each one needs concrete evidence, affected files or runtime behavior, and a required correction.',
|
|
90
125
|
'Use `continue_execution` only for concrete missing work that is bounded enough for one follow-on scope; use `block_for_operator` for ambiguous or externally constrained gaps.',
|
|
126
|
+
'A finding category that the review level or the User Guidance section has demoted or ignored is not missing work: never return `continue_execution` or `block_for_operator` for it. When the plan objectives are otherwise satisfied, return `accept_complete`.',
|
|
91
127
|
'Do not turn low-signal style preferences, trivial code-shape preferences, or optional refactors into missing work.',
|
|
92
128
|
'If the plan is complete aside from low-signal trivia, return `accept_complete` rather than inventing a non-blocking completion concern.',
|
|
93
129
|
];
|
|
@@ -95,7 +131,9 @@ export function getFindingQualityLines(context = {}) {
|
|
|
95
131
|
return [
|
|
96
132
|
'Produce only structured review findings.',
|
|
97
133
|
'Use blocking severity for correctness, regression, or missing-verification issues.',
|
|
98
|
-
|
|
134
|
+
level === 'lenient'
|
|
135
|
+
? 'Robustness or performance regressions introduced by the implementation are non_blocking unless they are a reachable correctness failure; use blocking severity only in that case.'
|
|
136
|
+
: 'Also use blocking severity for substantive robustness or performance regressions introduced by the implementation, especially in infrastructure, config, parser, caching, retry, or orchestration code.',
|
|
99
137
|
'Use non_blocking severity for suggestions that do not block acceptance.',
|
|
100
138
|
'Only emit non_blocking findings when they identify a concrete maintenance, observability, or testability issue that is genuinely worth a later follow-up turn.',
|
|
101
139
|
'Do not emit non_blocking findings for formatting, whitespace, naming preferences, trivial code-shape preferences, or optional refactors.',
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { guardStructuredJsonOutputFormatLines } from '../agents/structured-json.js';
|
|
2
|
-
import { renderInlinedRangeDiffSection } from '../context/inline-review-context.js';
|
|
2
|
+
import { AGENT_FREE_TEXT_SECTION_MAX_CHARS, boundChangedFileList, boundCommitSubjectList, boundFreeTextValues, GIT_SUMMARY_SECTION_MAX_CHARS, renderInlinedRangeDiffSection, truncateInlineSectionBody, } from '../context/inline-review-context.js';
|
|
3
3
|
import { assertPromptBuilder } from './assert-builder.js';
|
|
4
4
|
import { getUserGuidanceLines } from './guidance.js';
|
|
5
|
-
import { getAdversarialReviewDoctrineLines, getCodeReviewFalsificationLines, getFindingQualityLines, getPreexistingFailureContractLines, getRegressionPreservationLines, getVerificationSkepticismLines, } from './review-doctrine.js';
|
|
5
|
+
import { getAdversarialReviewDoctrineLines, getCodeReviewFalsificationLines, getFindingQualityLines, getPreexistingFailureContractLines, getRegressionPreservationLines, getReviewLevelCalibrationLines, getVerificationSkepticismLines, } from './review-doctrine.js';
|
|
6
6
|
const PROMPT_MODULE_PATH = 'src/neal/prompts/specialized.ts';
|
|
7
7
|
// Guarded output-format instruction block shared by the two structured-JSON
|
|
8
8
|
// completion base prompts. It emits only JSON-only framing and a
|
|
@@ -28,9 +28,8 @@ export function buildFinalCompletionSummaryPrompt(args) {
|
|
|
28
28
|
if (!finalCompletionVariant) {
|
|
29
29
|
throw new Error('Prompt spec completion_coder is missing a final_completion variant');
|
|
30
30
|
}
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
: 'null';
|
|
31
|
+
const boundedLastScope = boundLastImplementationScope(args.packet.lastNonEmptyImplementationScope);
|
|
32
|
+
const lastImplementationScope = boundedLastScope ? JSON.stringify(boundedLastScope, null, 2) : 'null';
|
|
34
33
|
return [
|
|
35
34
|
`Summarize whether the execute-mode plan at ${args.planDoc} is complete as a whole.`,
|
|
36
35
|
'',
|
|
@@ -52,19 +51,19 @@ export function buildFinalCompletionSummaryPrompt(args) {
|
|
|
52
51
|
currentScopeLabel: args.packet.currentScopeLabel,
|
|
53
52
|
acceptedScopeRecordCount: args.packet.acceptedScopeCount,
|
|
54
53
|
blockedScopeCount: args.packet.blockedScopeCount,
|
|
55
|
-
scopeAccountingSummary: args.packet.scopeAccountingSummary,
|
|
54
|
+
scopeAccountingSummary: boundScopeAccountingSummary(args.packet.scopeAccountingSummary),
|
|
56
55
|
verificationOnlyCompletion: args.packet.verificationOnlyCompletion,
|
|
57
|
-
aggregateReviewContext: args.packet.aggregateReviewContext,
|
|
58
|
-
completedScopeSummary: args.packet.completedScopeSummary,
|
|
56
|
+
aggregateReviewContext: boundAggregateReviewContext(args.packet.aggregateReviewContext),
|
|
57
|
+
completedScopeSummary: boundCompletedScopeSummary(args.packet.completedScopeSummary),
|
|
59
58
|
terminalChangedFilesSummary: args.packet.terminalChangedFilesSummary,
|
|
60
59
|
planChangedFilesSummary: args.packet.planChangedFilesSummary,
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
lastNonEmptyImplementationScope: args.packet.lastNonEmptyImplementationScope,
|
|
60
|
+
verificationTally: args.packet.verificationTally,
|
|
61
|
+
lastNonEmptyImplementationScope: boundedLastScope,
|
|
64
62
|
continueExecutionCount: args.packet.continueExecutionCount,
|
|
65
63
|
continueExecutionMax: args.packet.continueExecutionMax,
|
|
66
64
|
}, null, 2),
|
|
67
65
|
'',
|
|
66
|
+
'`verificationTally` is a bounded summary of the run\'s recorded verification commands; the complete per-command record is in the run directory\'s events.ndjson.',
|
|
68
67
|
'If the completion is verification-only, say so directly in `whatChangedOverall` or `remainingKnownGaps` instead of pretending there was a terminal implementation diff.',
|
|
69
68
|
...getUserGuidanceLines('coder'),
|
|
70
69
|
'',
|
|
@@ -72,6 +71,71 @@ export function buildFinalCompletionSummaryPrompt(args) {
|
|
|
72
71
|
lastImplementationScope,
|
|
73
72
|
].join('\n');
|
|
74
73
|
}
|
|
74
|
+
// Maximum remaining-known-gap entries rendered per completion prompt. Gaps
|
|
75
|
+
// beyond the limit collapse to one explicit overflow entry so the rendered
|
|
76
|
+
// list stays bounded as the gap count grows. Also keeps the summary's
|
|
77
|
+
// free-text value count far under boundFreeTextValues' cardinality bound.
|
|
78
|
+
const REMAINING_GAPS_PROMPT_ITEM_LIMIT = 100;
|
|
79
|
+
// Render-only view of the coder's completion summary: planGoalSatisfied is
|
|
80
|
+
// copied exactly, the free-text fields and the first
|
|
81
|
+
// REMAINING_GAPS_PROMPT_ITEM_LIMIT gap entries share the fixed aggregate
|
|
82
|
+
// free-text budget, and any further gaps collapse to one explicit overflow
|
|
83
|
+
// entry. The stored summary keeps its full text.
|
|
84
|
+
function boundCompletionSummaryForPrompt(summary) {
|
|
85
|
+
const gaps = summary.remainingKnownGaps.slice(0, REMAINING_GAPS_PROMPT_ITEM_LIMIT);
|
|
86
|
+
const bounded = boundFreeTextValues([summary.whatChangedOverall, summary.verificationSummary, ...gaps]);
|
|
87
|
+
const remainingKnownGaps = bounded.slice(2);
|
|
88
|
+
const omittedGaps = summary.remainingKnownGaps.length - gaps.length;
|
|
89
|
+
if (omittedGaps > 0) {
|
|
90
|
+
remainingKnownGaps.push(`(+${omittedGaps} more remaining known gaps omitted from this prompt)`);
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
planGoalSatisfied: summary.planGoalSatisfied,
|
|
94
|
+
whatChangedOverall: bounded[0],
|
|
95
|
+
verificationSummary: bounded[1],
|
|
96
|
+
remainingKnownGaps,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
// Prompt-render view of the completion packet's aggregate review context with
|
|
100
|
+
// its run-scaling fields bounded: the commit-subject and changed-file lists
|
|
101
|
+
// collapse past their limits and the diff stat truncates with a marker. The
|
|
102
|
+
// packet keeps the full values for non-prompt consumers.
|
|
103
|
+
function boundAggregateReviewContext(context) {
|
|
104
|
+
return {
|
|
105
|
+
...context,
|
|
106
|
+
commitSubjects: boundCommitSubjectList(context.commitSubjects),
|
|
107
|
+
diffStat: truncateInlineSectionBody(context.diffStat, GIT_SUMMARY_SECTION_MAX_CHARS),
|
|
108
|
+
changedFiles: boundChangedFileList(context.changedFiles),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
// Prompt-render view of the packet's completed-scope summary. The summary
|
|
112
|
+
// carries agent-authored blocker and residual-debt text for every completed
|
|
113
|
+
// scope, so it shares the fixed agent free-text cap; the packet keeps the full
|
|
114
|
+
// string for non-prompt consumers.
|
|
115
|
+
function boundCompletedScopeSummary(summary) {
|
|
116
|
+
return truncateInlineSectionBody(summary, AGENT_FREE_TEXT_SECTION_MAX_CHARS);
|
|
117
|
+
}
|
|
118
|
+
// Prompt-render view of the packet's last non-empty implementation scope: the
|
|
119
|
+
// changed-file list is bounded, and the agent-authored commit subject gets the
|
|
120
|
+
// fixed free-text cap while a null subject stays null. The packet keeps the
|
|
121
|
+
// full values for non-prompt consumers.
|
|
122
|
+
function boundLastImplementationScope(scope) {
|
|
123
|
+
if (!scope) {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
return {
|
|
127
|
+
...scope,
|
|
128
|
+
commitSubject: scope.commitSubject === null ? null : boundFreeTextValues([scope.commitSubject])[0],
|
|
129
|
+
changedFiles: boundChangedFileList(scope.changedFiles),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
// Prompt-render view of the packet's scope-accounting summary. The summary
|
|
133
|
+
// grows with derived-plan replacements (one path per replaced parent scope),
|
|
134
|
+
// so it shares the fixed agent free-text cap; the packet keeps the full string
|
|
135
|
+
// for non-prompt consumers.
|
|
136
|
+
function boundScopeAccountingSummary(summary) {
|
|
137
|
+
return truncateInlineSectionBody(summary, AGENT_FREE_TEXT_SECTION_MAX_CHARS);
|
|
138
|
+
}
|
|
75
139
|
export function buildFinalCompletionReviewerPrompt(args) {
|
|
76
140
|
const spec = assertPromptBuilder('completion_reviewer', 'buildFinalCompletionReviewerPrompt', PROMPT_MODULE_PATH);
|
|
77
141
|
const finalCompletionVariant = spec.variants.find((variant) => variant.kind === 'final_completion');
|
|
@@ -79,15 +143,15 @@ export function buildFinalCompletionReviewerPrompt(args) {
|
|
|
79
143
|
throw new Error('Prompt spec completion_reviewer is missing a final_completion variant');
|
|
80
144
|
}
|
|
81
145
|
const accessMode = args.accessMode ?? 'tool-access';
|
|
146
|
+
const reviewLevel = args.reviewLevel ?? 'moderate';
|
|
82
147
|
// A collected diff may legitimately be the empty string (a range with no
|
|
83
148
|
// changes); distinguish "collected" (any string, including '') from "not
|
|
84
149
|
// collected" (null/undefined) so an empty diff still rides the inlined channel
|
|
85
150
|
// instead of falling back to git_diff-tool phrasing the reviewer cannot use.
|
|
86
151
|
const rangeDiffInlined = accessMode === 'read-only' && args.inlinedRangeDiff !== null && args.inlinedRangeDiff !== undefined;
|
|
87
152
|
const completionSummary = args.summary;
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
: 'null';
|
|
153
|
+
const boundedLastScope = boundLastImplementationScope(args.packet.lastNonEmptyImplementationScope);
|
|
154
|
+
const lastImplementationScope = boundedLastScope ? JSON.stringify(boundedLastScope, null, 2) : 'null';
|
|
91
155
|
const aggregateRange = args.packet.aggregateReviewContext.range;
|
|
92
156
|
const falsificationLines = getCodeReviewFalsificationLines({
|
|
93
157
|
rangeLabel: aggregateRange ? `aggregate range ${aggregateRange}` : null,
|
|
@@ -138,6 +202,7 @@ export function buildFinalCompletionReviewerPrompt(args) {
|
|
|
138
202
|
judgmentTarget: 'whole-plan completion',
|
|
139
203
|
proofTarget: 'the aggregate implementation satisfies the plan',
|
|
140
204
|
}),
|
|
205
|
+
...getReviewLevelCalibrationLines({ level: reviewLevel, outputContract: 'completion_verdict' }),
|
|
141
206
|
...falsificationLines,
|
|
142
207
|
...scratchLines,
|
|
143
208
|
'Falsify cross-scope runtime invariants and integration behavior before accepting completion, especially paths that individual scope reviews could not see together.',
|
|
@@ -145,7 +210,7 @@ export function buildFinalCompletionReviewerPrompt(args) {
|
|
|
145
210
|
...skepticismLines,
|
|
146
211
|
...regressionLines,
|
|
147
212
|
...preexistingLines,
|
|
148
|
-
...getFindingQualityLines({ outputContract: 'completion_verdict' }),
|
|
213
|
+
...getFindingQualityLines({ outputContract: 'completion_verdict', level: reviewLevel }),
|
|
149
214
|
'Review the whole-plan result for correctness and completeness against the plan objectives, regressions or missing behavior, cross-scope integration issues that may not have been visible in individual scope reviews, code quality, maintainability, and consistency of the final implementation, and adequacy of test coverage and verification for the total change.',
|
|
150
215
|
...getReviewerContextLines(args.reviewerContext),
|
|
151
216
|
'Do not treat prior per-scope acceptance as sufficient evidence that the whole plan is complete or that the aggregate code quality is acceptable.',
|
|
@@ -166,7 +231,7 @@ export function buildFinalCompletionReviewerPrompt(args) {
|
|
|
166
231
|
'- Invalid example: subject "Finish scope 4 cleanup"; bullets ["Summarize per-scope plan work", "Describe reviewer process"].',
|
|
167
232
|
'',
|
|
168
233
|
'Coder whole-plan completion summary:',
|
|
169
|
-
JSON.stringify(completionSummary, null, 2),
|
|
234
|
+
JSON.stringify(boundCompletionSummaryForPrompt(completionSummary), null, 2),
|
|
170
235
|
'',
|
|
171
236
|
'Whole-plan completion packet:',
|
|
172
237
|
JSON.stringify({
|
|
@@ -174,20 +239,20 @@ export function buildFinalCompletionReviewerPrompt(args) {
|
|
|
174
239
|
currentScopeLabel: args.packet.currentScopeLabel,
|
|
175
240
|
acceptedScopeRecordCount: args.packet.acceptedScopeCount,
|
|
176
241
|
blockedScopeCount: args.packet.blockedScopeCount,
|
|
177
|
-
scopeAccountingSummary: args.packet.scopeAccountingSummary,
|
|
242
|
+
scopeAccountingSummary: boundScopeAccountingSummary(args.packet.scopeAccountingSummary),
|
|
178
243
|
verificationOnlyCompletion: args.packet.verificationOnlyCompletion,
|
|
179
|
-
aggregateReviewContext: args.packet.aggregateReviewContext,
|
|
244
|
+
aggregateReviewContext: boundAggregateReviewContext(args.packet.aggregateReviewContext),
|
|
180
245
|
finalCommit: args.packet.finalCommit,
|
|
181
|
-
completedScopeSummary: args.packet.completedScopeSummary,
|
|
246
|
+
completedScopeSummary: boundCompletedScopeSummary(args.packet.completedScopeSummary),
|
|
182
247
|
terminalChangedFilesSummary: args.packet.terminalChangedFilesSummary,
|
|
183
248
|
planChangedFilesSummary: args.packet.planChangedFilesSummary,
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
lastNonEmptyImplementationScope: args.packet.lastNonEmptyImplementationScope,
|
|
249
|
+
verificationTally: args.packet.verificationTally,
|
|
250
|
+
lastNonEmptyImplementationScope: boundedLastScope,
|
|
187
251
|
continueExecutionCount: args.packet.continueExecutionCount,
|
|
188
252
|
continueExecutionMax: args.packet.continueExecutionMax,
|
|
189
253
|
}, null, 2),
|
|
190
254
|
'',
|
|
255
|
+
'`verificationTally` is a bounded summary of the run\'s recorded verification commands; the complete per-command record is in the run directory\'s events.ndjson.',
|
|
191
256
|
'If this was a verification-only terminal scope, judge the whole-plan result directly instead of pretending there was a final implementation diff.',
|
|
192
257
|
...getUserGuidanceLines('reviewer'),
|
|
193
258
|
'',
|
|
@@ -67,6 +67,7 @@ const SCOPE_REVIEWER_CONTEXT = context('ScopeReviewerPromptContext', [
|
|
|
67
67
|
field('scratchDir', 'run_artifact', true, 'Run-local reviewer scratch directory for temporary verification artifacts.'),
|
|
68
68
|
field('earlierScopeChanges', 'repository_state', false, 'Files in the current scope diff that an earlier accepted scope also changed, each with that scope number, commit range, and per-file diff. Computed from completedScopes in run state; omitted when there is no overlap.'),
|
|
69
69
|
field('accessMode', 'orchestrator_state', false, "Two-way reviewer doctrine access mode derived from the reviewer provider's structured-advisor tool access: 'tool-access' (inspect and execute) or 'read-only' (read tools only; no command execution, test runs, or scratch work). Defaults to 'tool-access' when absent."),
|
|
70
|
+
field('reviewLevel', 'orchestrator_state', false, "Reviewer strictness ('strict', 'moderate', or 'lenient') from the `neal.review_level` config key, resolved by rounds.ts via getReviewLevel(cwd). Defaults to 'moderate' when absent."),
|
|
70
71
|
]);
|
|
71
72
|
const COMPLETION_CODER_CONTEXT = context('CompletionCoderPromptContext', [
|
|
72
73
|
field('planDoc', 'prompt_argument', true, 'Path to the execute-mode plan being evaluated for final completion.'),
|
|
@@ -80,6 +81,7 @@ const COMPLETION_REVIEWER_CONTEXT = context('CompletionReviewerPromptContext', [
|
|
|
80
81
|
field('scratchDir', 'run_artifact', true, 'Run-local final-completion reviewer scratch directory for temporary verification artifacts.'),
|
|
81
82
|
field('repositoryState', 'repository_state', true, 'Current repository state used to judge whole-plan completion.'),
|
|
82
83
|
field('accessMode', 'orchestrator_state', false, "Two-way reviewer doctrine access mode derived from the reviewer provider's structured-advisor tool access: 'tool-access' (inspect and execute) or 'read-only' (read tools only; no command execution, test runs, or scratch work). Defaults to 'tool-access' when absent."),
|
|
84
|
+
field('reviewLevel', 'orchestrator_state', false, "Reviewer strictness ('strict', 'moderate', or 'lenient') from the `neal.review_level` config key, resolved by rounds.ts via getReviewLevel(cwd). Defaults to 'moderate' when absent."),
|
|
83
85
|
]);
|
|
84
86
|
const CONSULTANT_CONTEXT = context('ConsultantPromptContext', [
|
|
85
87
|
field('blockedReason', 'prompt_argument', true, 'Blocked reason reported by the stalled coder or reviewer turn.'),
|
|
@@ -88,7 +90,7 @@ const CONSULTANT_CONTEXT = context('ConsultantPromptContext', [
|
|
|
88
90
|
export const PROMPT_SPECS = [
|
|
89
91
|
{
|
|
90
92
|
id: 'plan_author',
|
|
91
|
-
version:
|
|
93
|
+
version: 5,
|
|
92
94
|
changelog: [
|
|
93
95
|
{
|
|
94
96
|
version: 1,
|
|
@@ -106,6 +108,10 @@ export const PROMPT_SPECS = [
|
|
|
106
108
|
version: 4,
|
|
107
109
|
renderSha: 'e9f5b2dd5f66df87986b2e2f884c560d02a235db053b544221dcb43512e057a4',
|
|
108
110
|
},
|
|
111
|
+
{
|
|
112
|
+
version: 5,
|
|
113
|
+
renderSha: '148ed793be3f39c4a5505289ab3045ff124d8c44a53bf5fa9ccc8c3c5f20d408',
|
|
114
|
+
},
|
|
109
115
|
],
|
|
110
116
|
role: 'coder',
|
|
111
117
|
purpose: 'Author or revise concise, human-reviewable Neal-executable plans at moderate-to-high-level implementation detail.',
|
|
@@ -283,7 +289,7 @@ export const PROMPT_SPECS = [
|
|
|
283
289
|
},
|
|
284
290
|
{
|
|
285
291
|
id: 'scope_coder',
|
|
286
|
-
version:
|
|
292
|
+
version: 3,
|
|
287
293
|
changelog: [
|
|
288
294
|
{
|
|
289
295
|
version: 1,
|
|
@@ -293,6 +299,10 @@ export const PROMPT_SPECS = [
|
|
|
293
299
|
version: 2,
|
|
294
300
|
renderSha: '0ce921ee7d0acc4042bacf31e1509968f7e76df8db8417eb4b724ce7842794ac',
|
|
295
301
|
},
|
|
302
|
+
{
|
|
303
|
+
version: 3,
|
|
304
|
+
renderSha: '8fb94430bf9d9abcb11f905106a3a04fc1b81bd101104c88f84a8693f705c5ea',
|
|
305
|
+
},
|
|
296
306
|
],
|
|
297
307
|
role: 'coder',
|
|
298
308
|
purpose: 'Execute exactly one bounded implementation scope and respond to in-scope review feedback without starting new scopes.',
|
|
@@ -396,7 +406,7 @@ export const PROMPT_SPECS = [
|
|
|
396
406
|
},
|
|
397
407
|
{
|
|
398
408
|
id: 'scope_reviewer',
|
|
399
|
-
version:
|
|
409
|
+
version: 5,
|
|
400
410
|
changelog: [
|
|
401
411
|
{
|
|
402
412
|
version: 1,
|
|
@@ -414,6 +424,10 @@ export const PROMPT_SPECS = [
|
|
|
414
424
|
version: 4,
|
|
415
425
|
renderSha: 'da87b19f2401ffdca21e3cefec1037c6470b3e74810b6152d07c55fa4924047f',
|
|
416
426
|
},
|
|
427
|
+
{
|
|
428
|
+
version: 5,
|
|
429
|
+
renderSha: 'e8dcb976d0ea22e9026e09a87d7dde93c8513334f62ad89472ebefe91771754b',
|
|
430
|
+
},
|
|
417
431
|
],
|
|
418
432
|
role: 'reviewer',
|
|
419
433
|
purpose: 'Review execute-scope results for correctness, verification coverage, and meaningful progress toward the active parent objective.',
|
|
@@ -471,6 +485,7 @@ export const PROMPT_SPECS = [
|
|
|
471
485
|
field('parentScopeLabel', 'orchestrator_state', true, 'Active parent objective label.'),
|
|
472
486
|
field('scratchDir', 'run_artifact', true, 'Run-local scratch directory for reviewer verification artifacts.'),
|
|
473
487
|
field('accessMode', 'orchestrator_state', false, "Optional explicit doctrine access mode ('tool-access' or 'read-only'); defaults to 'tool-access' when absent."),
|
|
488
|
+
field('reviewLevel', 'orchestrator_state', false, "Optional reviewer strictness ('strict', 'moderate', or 'lenient') from `neal.review_level`, resolved by rounds.ts via getReviewLevel(cwd); defaults to 'moderate' when absent."),
|
|
474
489
|
]),
|
|
475
490
|
},
|
|
476
491
|
schemaTarget: {
|
|
@@ -505,12 +520,16 @@ export const PROMPT_SPECS = [
|
|
|
505
520
|
},
|
|
506
521
|
{
|
|
507
522
|
id: 'completion_coder',
|
|
508
|
-
version:
|
|
523
|
+
version: 2,
|
|
509
524
|
changelog: [
|
|
510
525
|
{
|
|
511
526
|
version: 1,
|
|
512
527
|
renderSha: 'f88b43d206de28212ad7f6f3ae84576718934b0a5e36953e35afee89c4d7ce5e',
|
|
513
528
|
},
|
|
529
|
+
{
|
|
530
|
+
version: 2,
|
|
531
|
+
renderSha: '85f36db1245090baa3c8f74e5fe2d3de774664dcb6a3b1b84dcc49425cc3c646',
|
|
532
|
+
},
|
|
514
533
|
],
|
|
515
534
|
role: 'coder',
|
|
516
535
|
purpose: 'Summarize whole-plan completion state in compact structured JSON.',
|
|
@@ -565,7 +584,7 @@ export const PROMPT_SPECS = [
|
|
|
565
584
|
},
|
|
566
585
|
{
|
|
567
586
|
id: 'completion_reviewer',
|
|
568
|
-
version:
|
|
587
|
+
version: 5,
|
|
569
588
|
changelog: [
|
|
570
589
|
{
|
|
571
590
|
version: 1,
|
|
@@ -579,6 +598,14 @@ export const PROMPT_SPECS = [
|
|
|
579
598
|
version: 3,
|
|
580
599
|
renderSha: '127097db4b0d06cba8943681d12d93fbb07e577e078c99c1d81036399bedb66a',
|
|
581
600
|
},
|
|
601
|
+
{
|
|
602
|
+
version: 4,
|
|
603
|
+
renderSha: 'c47009016178fc29c34440e06636ba7b21c6beb59202dd3fe63e365cb32a75cf',
|
|
604
|
+
},
|
|
605
|
+
{
|
|
606
|
+
version: 5,
|
|
607
|
+
renderSha: '7930cec95560ad880bba94a7ae48e68c621805787261e295c4c405d09235c87b',
|
|
608
|
+
},
|
|
582
609
|
],
|
|
583
610
|
role: 'reviewer',
|
|
584
611
|
purpose: 'Judge whole-plan completion and decide whether Neal should accept completion, continue execution, or block for operator input.',
|
|
@@ -625,6 +652,7 @@ export const PROMPT_SPECS = [
|
|
|
625
652
|
field('summary', 'review_history', true, 'Coder-authored completion summary.'),
|
|
626
653
|
field('scratchDir', 'run_artifact', true, 'Run-local scratch directory for final-completion reviewer artifacts.'),
|
|
627
654
|
field('accessMode', 'orchestrator_state', false, "Optional explicit doctrine access mode ('tool-access' or 'read-only'); defaults to 'tool-access' when absent."),
|
|
655
|
+
field('reviewLevel', 'orchestrator_state', false, "Optional reviewer strictness ('strict', 'moderate', or 'lenient') from `neal.review_level`, resolved by rounds.ts via getReviewLevel(cwd); defaults to 'moderate' when absent."),
|
|
628
656
|
]),
|
|
629
657
|
},
|
|
630
658
|
schemaTarget: {
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { NealProviderError } from './types.js';
|
|
2
|
+
const REPORTED_SECTION_COUNT = 3;
|
|
3
|
+
const SECTION_NAME_MAX_CHARS = 60;
|
|
4
|
+
// Measures the prompt as contiguous sections split on `## ` headings, the
|
|
5
|
+
// heading level Neal's prompt builders use for top-level sections. Text before
|
|
6
|
+
// the first heading is labeled "instructions". Section sizes include the
|
|
7
|
+
// heading line and sum exactly to the prompt length.
|
|
8
|
+
export function measurePromptSections(prompt) {
|
|
9
|
+
const boundaries = [];
|
|
10
|
+
for (const match of prompt.matchAll(/^## (.*)$/gm)) {
|
|
11
|
+
boundaries.push({
|
|
12
|
+
index: match.index,
|
|
13
|
+
name: match[1].trim() === '' ? 'untitled section' : match[1].trim(),
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
const sections = [];
|
|
17
|
+
const leadingChars = boundaries.length === 0 ? prompt.length : boundaries[0].index;
|
|
18
|
+
if (leadingChars > 0) {
|
|
19
|
+
sections.push({ name: 'instructions', chars: leadingChars });
|
|
20
|
+
}
|
|
21
|
+
for (const [position, boundary] of boundaries.entries()) {
|
|
22
|
+
const end = position + 1 < boundaries.length ? boundaries[position + 1].index : prompt.length;
|
|
23
|
+
sections.push({ name: boundary.name, chars: end - boundary.index });
|
|
24
|
+
}
|
|
25
|
+
if (sections.length === 0) {
|
|
26
|
+
sections.push({ name: 'instructions', chars: prompt.length });
|
|
27
|
+
}
|
|
28
|
+
return sections;
|
|
29
|
+
}
|
|
30
|
+
function formatChars(value) {
|
|
31
|
+
return value.toLocaleString('en-US');
|
|
32
|
+
}
|
|
33
|
+
function formatSectionName(name) {
|
|
34
|
+
return name.length > SECTION_NAME_MAX_CHARS
|
|
35
|
+
? `${name.slice(0, SECTION_NAME_MAX_CHARS - 3)}...`
|
|
36
|
+
: name;
|
|
37
|
+
}
|
|
38
|
+
export function buildInputTooLargeMessage(args) {
|
|
39
|
+
const largest = [...args.sections]
|
|
40
|
+
.sort((a, b) => b.chars - a.chars)
|
|
41
|
+
.slice(0, REPORTED_SECTION_COUNT);
|
|
42
|
+
const sectionReport = largest
|
|
43
|
+
.map((section) => `"${formatSectionName(section.name)}" ${formatChars(section.chars)} chars`)
|
|
44
|
+
.join('; ');
|
|
45
|
+
return [
|
|
46
|
+
`Prompt is ${formatChars(args.promptChars)} chars; ${args.provider} accepts at most ${formatChars(args.maxInputChars)} input chars per turn.`,
|
|
47
|
+
`Largest sections: ${sectionReport}.`,
|
|
48
|
+
].join('\n');
|
|
49
|
+
}
|
|
50
|
+
// Reads the largest section name back out of a message produced by
|
|
51
|
+
// buildInputTooLargeMessage, so `neal status` can name the input to shrink in
|
|
52
|
+
// its Next Action. Provider-authored input_too_large rejections carry no
|
|
53
|
+
// section report, so this returns null for them and the caller falls back to
|
|
54
|
+
// generic wording. Kept next to the builder so the format and its one parser
|
|
55
|
+
// stay in lockstep.
|
|
56
|
+
export function largestSectionNameFromInputTooLargeMessage(message) {
|
|
57
|
+
const match = message.match(/Largest sections: "([^"]+)"/);
|
|
58
|
+
return match ? match[1] : null;
|
|
59
|
+
}
|
|
60
|
+
// No-op when the role declares no budget or the prompt fits. Throws before any
|
|
61
|
+
// SDK work otherwise; the error is non-retryable by construction, so it never
|
|
62
|
+
// consumes API-retry budget.
|
|
63
|
+
export function assertPromptWithinInputBudget(args) {
|
|
64
|
+
if (args.maxInputChars === undefined || args.prompt.length <= args.maxInputChars) {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
throw new NealProviderError({
|
|
68
|
+
message: buildInputTooLargeMessage({
|
|
69
|
+
provider: args.provider,
|
|
70
|
+
promptChars: args.prompt.length,
|
|
71
|
+
maxInputChars: args.maxInputChars,
|
|
72
|
+
sections: measurePromptSections(args.prompt),
|
|
73
|
+
}),
|
|
74
|
+
provider: args.provider,
|
|
75
|
+
role: args.role,
|
|
76
|
+
sessionHandle: args.sessionHandle ?? null,
|
|
77
|
+
kind: 'input_too_large',
|
|
78
|
+
retryable: false,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
@@ -1,10 +1,25 @@
|
|
|
1
1
|
import { Codex } from '@openai/codex-sdk';
|
|
2
|
-
import { runStructuredJsonProtocol } from '../agents/structured-json.js';
|
|
2
|
+
import { buildStructuredJsonPrompt, runStructuredJsonProtocol } from '../agents/structured-json.js';
|
|
3
3
|
import { agentSettingsIsolated } from './agent-settings-isolation.js';
|
|
4
4
|
import { agentSubprocessEnv } from './git-config-isolation.js';
|
|
5
|
+
import { assertPromptWithinInputBudget } from './input-budget.js';
|
|
5
6
|
import { resolveRateCost } from './pricing.js';
|
|
6
7
|
import { isContentSafetyRefusalMessage, NealProviderError } from './types.js';
|
|
7
8
|
const OPENAI_CODEX_PROVIDER_ID = 'openai-codex';
|
|
9
|
+
// Codex's app-server rejects any single turn whose input exceeds this size
|
|
10
|
+
// (JSON-RPC code -32602, input_error_code `input_too_large`). Declared as
|
|
11
|
+
// `maxInputChars` on both role capabilities and enforced by the preflight at
|
|
12
|
+
// every turn/round entry point below.
|
|
13
|
+
const OPENAI_CODEX_MAX_INPUT_CHARS = 1_048_576;
|
|
14
|
+
function assertCodexInputBudget(args) {
|
|
15
|
+
assertPromptWithinInputBudget({
|
|
16
|
+
prompt: args.prompt,
|
|
17
|
+
maxInputChars: OPENAI_CODEX_MAX_INPUT_CHARS,
|
|
18
|
+
provider: OPENAI_CODEX_PROVIDER_ID,
|
|
19
|
+
role: args.role,
|
|
20
|
+
sessionHandle: args.sessionHandle,
|
|
21
|
+
});
|
|
22
|
+
}
|
|
8
23
|
class CodexInactivityTimeoutError extends Error {
|
|
9
24
|
constructor(timeoutMs) {
|
|
10
25
|
super(`Codex timed out after ${Math.round(timeoutMs / 1000)}s without progress`);
|
|
@@ -76,6 +91,12 @@ function inferCodexErrorKind(error, message, fallback) {
|
|
|
76
91
|
if (isContentSafetyRefusalMessage(message)) {
|
|
77
92
|
return 'content_refused';
|
|
78
93
|
}
|
|
94
|
+
// Codex's app-server rejects an over-limit turn with JSON-RPC code -32602
|
|
95
|
+
// and input_error_code `input_too_large`; matching the stable error code
|
|
96
|
+
// means a limit the preflight did not predict still classifies correctly.
|
|
97
|
+
if (text.includes('input_too_large')) {
|
|
98
|
+
return 'input_too_large';
|
|
99
|
+
}
|
|
79
100
|
if (text.includes('permission') || text.includes('denied') || text.includes('forbidden') || text.includes('not authorized')) {
|
|
80
101
|
return 'permission_denied';
|
|
81
102
|
}
|
|
@@ -568,6 +589,11 @@ class OpenAICodexCoderAdapter {
|
|
|
568
589
|
async runPrompt(args) {
|
|
569
590
|
let thread = null;
|
|
570
591
|
try {
|
|
592
|
+
assertCodexInputBudget({
|
|
593
|
+
prompt: args.prompt,
|
|
594
|
+
role: 'coder',
|
|
595
|
+
sessionHandle: args.resumeHandle ?? null,
|
|
596
|
+
});
|
|
571
597
|
const createThread = this.options.createThread ?? createCodexThread;
|
|
572
598
|
thread = createThread({
|
|
573
599
|
cwd: args.cwd,
|
|
@@ -620,6 +646,16 @@ class OpenAICodexCoderAdapter {
|
|
|
620
646
|
while (true) {
|
|
621
647
|
let thread = null;
|
|
622
648
|
try {
|
|
649
|
+
// Preflight the exact text the initial SDK turn will send: the
|
|
650
|
+
// protocol-wrapped prompt. buildStructuredJsonPrompt is the same pure
|
|
651
|
+
// builder runStructuredJsonProtocol applies to the same inputs below,
|
|
652
|
+
// so the checked text is byte-identical to the sent text. Repair
|
|
653
|
+
// prompts are checked in runRepair before their thread is created.
|
|
654
|
+
assertCodexInputBudget({
|
|
655
|
+
prompt: buildStructuredJsonPrompt(args.prompt, args.structuredJsonProtocol),
|
|
656
|
+
role: 'coder',
|
|
657
|
+
sessionHandle: args.resumeHandle ?? null,
|
|
658
|
+
});
|
|
623
659
|
const createThread = this.options.createThread ?? createCodexThread;
|
|
624
660
|
thread = createThread({
|
|
625
661
|
cwd: args.cwd,
|
|
@@ -650,6 +686,14 @@ class OpenAICodexCoderAdapter {
|
|
|
650
686
|
};
|
|
651
687
|
},
|
|
652
688
|
runRepair: async (prompt) => {
|
|
689
|
+
// A generated repair prompt embeds the invalid payload and the
|
|
690
|
+
// original response, so it can exceed the budget even when the
|
|
691
|
+
// initial prompt fit; preflight it before creating its thread.
|
|
692
|
+
assertCodexInputBudget({
|
|
693
|
+
prompt,
|
|
694
|
+
role: 'coder',
|
|
695
|
+
sessionHandle: thread.id ?? args.resumeHandle ?? null,
|
|
696
|
+
});
|
|
653
697
|
// Repair runs on a fresh prompt-only thread whose prompt forbids
|
|
654
698
|
// tool use entirely, so nothing in the repair flow needs write
|
|
655
699
|
// access; the read-only sandbox enforces that mechanically
|
|
@@ -740,6 +784,20 @@ class OpenAICodexStructuredAdvisorAdapter {
|
|
|
740
784
|
while (true) {
|
|
741
785
|
let thread = null;
|
|
742
786
|
try {
|
|
787
|
+
// Preflight the exact text the initial SDK turn will send: the
|
|
788
|
+
// protocol-wrapped prompt on the local-JSON path
|
|
789
|
+
// (buildStructuredJsonPrompt is the same pure builder
|
|
790
|
+
// runStructuredJsonProtocol applies to the same inputs below, so the
|
|
791
|
+
// checked text is byte-identical to the sent text), the bare prompt on
|
|
792
|
+
// the provider-native path. Repair prompts are checked in runRepair
|
|
793
|
+
// before their thread is created.
|
|
794
|
+
assertCodexInputBudget({
|
|
795
|
+
prompt: args.structuredJsonProtocol?.protocol === 'neal-json-block-v1'
|
|
796
|
+
? buildStructuredJsonPrompt(args.prompt, args.structuredJsonProtocol)
|
|
797
|
+
: args.prompt,
|
|
798
|
+
role: 'structured-advisor',
|
|
799
|
+
sessionHandle: args.resumeHandle ?? null,
|
|
800
|
+
});
|
|
743
801
|
thread = createThread({
|
|
744
802
|
cwd: args.cwd,
|
|
745
803
|
sessionHandle: args.resumeHandle,
|
|
@@ -770,6 +828,14 @@ class OpenAICodexStructuredAdvisorAdapter {
|
|
|
770
828
|
};
|
|
771
829
|
},
|
|
772
830
|
runRepair: async (prompt) => {
|
|
831
|
+
// A generated repair prompt embeds the invalid payload and the
|
|
832
|
+
// original response, so it can exceed the budget even when the
|
|
833
|
+
// initial prompt fit; preflight it before creating its thread.
|
|
834
|
+
assertCodexInputBudget({
|
|
835
|
+
prompt,
|
|
836
|
+
role: 'structured-advisor',
|
|
837
|
+
sessionHandle: thread.id ?? args.resumeHandle ?? null,
|
|
838
|
+
});
|
|
773
839
|
const repairThread = createThread({
|
|
774
840
|
cwd: args.cwd,
|
|
775
841
|
model: this.options.model ?? undefined,
|
|
@@ -875,6 +941,7 @@ export const openAICodexProviderDefinition = {
|
|
|
875
941
|
write: true,
|
|
876
942
|
shell: true,
|
|
877
943
|
},
|
|
944
|
+
maxInputChars: OPENAI_CODEX_MAX_INPUT_CHARS,
|
|
878
945
|
supportsSessionResume: true,
|
|
879
946
|
supportsModelOverride: true,
|
|
880
947
|
supportsStructuredOutput: true,
|
|
@@ -891,6 +958,7 @@ export const openAICodexProviderDefinition = {
|
|
|
891
958
|
write: false,
|
|
892
959
|
shell: false,
|
|
893
960
|
},
|
|
961
|
+
maxInputChars: OPENAI_CODEX_MAX_INPUT_CHARS,
|
|
894
962
|
supportsSessionResume: true,
|
|
895
963
|
supportsModelOverride: true,
|
|
896
964
|
supportsStructuredOutput: true,
|
|
@@ -344,7 +344,7 @@ async function readRunStates(cwd) {
|
|
|
344
344
|
const runsDir = getRunsDir(cwd);
|
|
345
345
|
let entries;
|
|
346
346
|
try {
|
|
347
|
-
entries = await readdir(runsDir);
|
|
347
|
+
entries = await readdir(runsDir, { withFileTypes: true });
|
|
348
348
|
}
|
|
349
349
|
catch (error) {
|
|
350
350
|
if (isMissingFileError(error)) {
|
|
@@ -353,7 +353,14 @@ async function readRunStates(cwd) {
|
|
|
353
353
|
throw error;
|
|
354
354
|
}
|
|
355
355
|
const states = {};
|
|
356
|
-
|
|
356
|
+
// Only directories are runs. The runs root also collects stray files such as
|
|
357
|
+
// macOS `.DS_Store`, and joining a run-state path onto one of those reads
|
|
358
|
+
// through a non-directory.
|
|
359
|
+
const runDirNames = entries
|
|
360
|
+
.filter((entry) => entry.isDirectory())
|
|
361
|
+
.map((entry) => entry.name)
|
|
362
|
+
.sort();
|
|
363
|
+
for (const entry of runDirNames) {
|
|
357
364
|
const statePath = getRunStatePath(join(runsDir, entry));
|
|
358
365
|
const content = await readOptionalText(statePath);
|
|
359
366
|
if (content !== null) {
|
|
@@ -374,6 +381,13 @@ function assertSameStringMap(before, after, label) {
|
|
|
374
381
|
}
|
|
375
382
|
}
|
|
376
383
|
}
|
|
384
|
+
// A run-state path can be unreadable because nothing is there (`ENOENT`) or
|
|
385
|
+
// because a path segment is not a directory (`ENOTDIR`, e.g. a stray file in the
|
|
386
|
+
// runs root). Both mean "no run state here", never a review failure.
|
|
377
387
|
function isMissingFileError(error) {
|
|
378
|
-
|
|
388
|
+
if (typeof error !== 'object' || error === null || !('code' in error)) {
|
|
389
|
+
return false;
|
|
390
|
+
}
|
|
391
|
+
const code = error.code;
|
|
392
|
+
return code === 'ENOENT' || code === 'ENOTDIR';
|
|
379
393
|
}
|