@navels/neal 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/README.md +206 -209
  2. package/SECURITY.md +16 -19
  3. package/dist/neal/adjudicator/{blocked-adjudicator.js → consultant.js} +21 -21
  4. package/dist/neal/adjudicator/execute.js +33 -33
  5. package/dist/neal/adjudicator/final-completion.js +2 -31
  6. package/dist/neal/adjudicator/planning.js +264 -24
  7. package/dist/neal/agents/prompts.js +7 -15
  8. package/dist/neal/agents/rounds.js +51 -73
  9. package/dist/neal/agents/schemas.js +615 -502
  10. package/dist/neal/agents/structured-json.js +36 -0
  11. package/dist/neal/blocked-guidance.js +16 -9
  12. package/dist/neal/cli.js +2 -2
  13. package/dist/neal/commands/check.js +2 -2
  14. package/dist/neal/commands/compat.js +100 -36
  15. package/dist/neal/commands/interactive-activity.js +2 -2
  16. package/dist/neal/commands/recovery-guidance.js +20 -8
  17. package/dist/neal/commands/runtime.js +4 -2
  18. package/dist/neal/config.js +40 -7
  19. package/dist/neal/context/inline-review-context.js +13 -28
  20. package/dist/neal/context/reviewer-context.js +19 -14
  21. package/dist/neal/eval/reviewer-eval-manifest.js +86 -0
  22. package/dist/neal/eval/reviewer-eval.js +155 -0
  23. package/dist/neal/git.js +9 -3
  24. package/dist/neal/interactive-controls.js +5 -0
  25. package/dist/neal/orchestrator/completion.js +215 -113
  26. package/dist/neal/orchestrator/failures.js +7 -0
  27. package/dist/neal/orchestrator/notifications.js +9 -9
  28. package/dist/neal/orchestrator/phases/planning.js +124 -112
  29. package/dist/neal/orchestrator/phases/recovery.js +151 -115
  30. package/dist/neal/orchestrator/phases/review.js +28 -1
  31. package/dist/neal/orchestrator/split-plan.js +12 -11
  32. package/dist/neal/orchestrator/transitions.js +36 -71
  33. package/dist/neal/orchestrator.js +3 -0
  34. package/dist/neal/plan-doc.js +24 -1
  35. package/dist/neal/plan-queue.js +29 -1
  36. package/dist/neal/prompts/assert-builder.js +8 -1
  37. package/dist/neal/prompts/execute.js +11 -19
  38. package/dist/neal/prompts/planning.js +64 -55
  39. package/dist/neal/prompts/review-doctrine.js +13 -50
  40. package/dist/neal/prompts/specialized.js +28 -22
  41. package/dist/neal/prompts/specs.js +223 -17
  42. package/dist/neal/providers/agent-settings-isolation.js +35 -0
  43. package/dist/neal/providers/anthropic-claude.js +309 -248
  44. package/dist/neal/providers/detection.js +3 -7
  45. package/dist/neal/providers/git-config-isolation.js +83 -0
  46. package/dist/neal/providers/openai-codex.js +102 -204
  47. package/dist/neal/providers/{generic-agentic-tools.js → openai-compatible-tools.js} +11 -5
  48. package/dist/neal/providers/openai-compatible.js +1079 -403
  49. package/dist/neal/providers/openrouter-routing.js +68 -0
  50. package/dist/neal/providers/pricing.js +124 -0
  51. package/dist/neal/providers/rate-card.js +2275 -0
  52. package/dist/neal/providers/registry.js +6 -17
  53. package/dist/neal/providers/telemetry.js +4 -0
  54. package/dist/neal/providers/types.js +12 -0
  55. package/dist/neal/resume-decision.js +7 -6
  56. package/dist/neal/resume-planner.js +9 -0
  57. package/dist/neal/retrospective.js +36 -7
  58. package/dist/neal/review-debt.js +30 -0
  59. package/dist/neal/review-findings/provider.js +17 -29
  60. package/dist/neal/review.js +17 -1
  61. package/dist/neal/run-metrics.js +74 -9
  62. package/dist/neal/state-invariants.js +11 -3
  63. package/dist/neal/state-views.js +27 -5
  64. package/dist/neal/state.js +59 -15
  65. package/dist/neal/status.js +28 -1
  66. package/dist/neal/support.js +3 -3
  67. package/docs/README.md +22 -18
  68. package/docs/{ADJUDICATOR_INVENTORY.md → adjudicator-inventory.md} +58 -57
  69. package/docs/architecture.md +79 -83
  70. package/docs/automation.md +11 -11
  71. package/docs/compat.md +91 -61
  72. package/docs/compatible-models.md +145 -110
  73. package/docs/demo.md +15 -5
  74. package/docs/maintenance.md +74 -32
  75. package/docs/plan-format.md +61 -33
  76. package/docs/prompt-evals.md +96 -0
  77. package/docs/{PROMPT_SPECS.md → prompt-specs.md} +52 -48
  78. package/docs/providers.md +330 -283
  79. package/docs/release.md +43 -40
  80. package/docs/review-convergence.md +246 -0
  81. package/docs/state-machine.md +61 -59
  82. package/docs/storage.md +40 -31
  83. package/docs/troubleshooting.md +32 -29
  84. package/examples/compat/add-edit-verify/PLAN.md +2 -0
  85. package/examples/compat/add-edit-verify/broken.diff +2 -2
  86. package/examples/compat/add-edit-verify/good.diff +2 -2
  87. package/examples/compat/add-edit-verify/src/add.js +1 -1
  88. package/examples/compat/manifest.json +25 -29
  89. package/examples/compat/sum-grep-edit/PLAN.md +33 -0
  90. package/examples/compat/sum-grep-edit/broken.diff +12 -0
  91. package/examples/compat/sum-grep-edit/good.diff +12 -0
  92. package/examples/compat/sum-grep-edit/package.json +5 -0
  93. package/examples/compat/sum-grep-edit/src/numbers.js +9 -0
  94. package/examples/compat/sum-grep-edit/test/numbers.test.js +15 -0
  95. package/examples/issue-triage-js/README.md +0 -15
  96. package/examples/reviewer-eval/clean-doc/base/src/clamp.ts +3 -0
  97. package/examples/reviewer-eval/clean-doc/change.diff +9 -0
  98. package/examples/reviewer-eval/clean-extract/base/src/greet.ts +3 -0
  99. package/examples/reviewer-eval/clean-extract/change.diff +13 -0
  100. package/examples/reviewer-eval/dropped-error/base/src/load.ts +4 -0
  101. package/examples/reviewer-eval/dropped-error/change.diff +15 -0
  102. package/examples/reviewer-eval/manifest.json +45 -0
  103. package/examples/reviewer-eval/off-by-one/base/src/paginate.ts +5 -0
  104. package/examples/reviewer-eval/off-by-one/change.diff +11 -0
  105. package/{config.yml → neal.yml} +1 -1
  106. package/package.json +20 -15
  107. package/dist/neal/providers/generic-agentic.js +0 -1238
  108. package/docs/comparison.md +0 -105
  109. package/examples/compat/is-even-add-test/PLAN.md +0 -30
  110. package/examples/compat/is-even-add-test/broken.diff +0 -11
  111. package/examples/compat/is-even-add-test/good.diff +0 -11
  112. package/examples/compat/is-even-add-test/package.json +0 -5
  113. package/examples/compat/is-even-add-test/src/is-even.js +0 -3
  114. package/examples/compat/is-even-add-test/test/is-even.test.js +0 -9
  115. package/examples/compat/reverse-grep-edit/PLAN.md +0 -32
  116. package/examples/compat/reverse-grep-edit/broken.diff +0 -12
  117. package/examples/compat/reverse-grep-edit/good.diff +0 -12
  118. package/examples/compat/reverse-grep-edit/package.json +0 -5
  119. package/examples/compat/reverse-grep-edit/src/strings.js +0 -10
  120. package/examples/compat/reverse-grep-edit/test/strings.test.js +0 -15
@@ -1,7 +1,6 @@
1
1
  import { anthropicClaudeProviderDefinition } from './anthropic-claude.js';
2
- import { genericAgenticProviderDefinition } from './generic-agentic.js';
3
- import { openAICodexProviderDefinition } from './openai-codex.js';
4
2
  import { openAICompatibleProviderDefinition } from './openai-compatible.js';
3
+ import { openAICodexProviderDefinition } from './openai-codex.js';
5
4
  // Computed lazily instead of as a top-level constant: openai-compatible.ts
6
5
  // imports config.ts, which imports this module, so the definition bindings may
7
6
  // still be in their temporal dead zone while this module body evaluates.
@@ -12,7 +11,6 @@ function getBuiltInProviderDefinitions() {
12
11
  openAICodexProviderDefinition,
13
12
  anthropicClaudeProviderDefinition,
14
13
  openAICompatibleProviderDefinition,
15
- genericAgenticProviderDefinition,
16
14
  ];
17
15
  }
18
16
  const providerCapabilityOverrides = new Map();
@@ -108,15 +106,6 @@ function assertRoleCapabilities(args) {
108
106
  missingCapability: 'read_tool_access',
109
107
  });
110
108
  }
111
- if (args.requirementContext.requireReadToolAccessOrInlineReviewerContext &&
112
- !args.capabilities.toolAccess.read &&
113
- !(args.capabilities.supported && args.capabilities.supportsStructuredOutput)) {
114
- throwProviderCapabilityError({
115
- requirementContext: args.requirementContext,
116
- config: args.config,
117
- missingCapability: 'read_tool_access_or_inline_reviewer_context',
118
- });
119
- }
120
109
  if (args.requirementContext.requireWriteToolAccess && !args.capabilities.toolAccess.write) {
121
110
  throwProviderCapabilityError({
122
111
  requirementContext: args.requirementContext,
@@ -161,9 +150,9 @@ function assertRoleCapabilities(args) {
161
150
  // Registry invariant: every *supported* structured-advisor (reviewer)
162
151
  // capability must be read-only. Reviewers inspect work and return verdicts;
163
152
  // they never mutate the checkout or run shell commands. Only `write`/`shell`
164
- // are constrained here — `read` is unconstrained (a reviewer may read the
165
- // checkout directly, or be a no-read inline-context reviewer). The `coder`
166
- // capability is intentionally not checked: coders keep write/shell.
153
+ // are constrained here — `read` is not constrained by this assertion (the
154
+ // writer-run capability gates separately require reviewer read access). The
155
+ // `coder` capability is intentionally not checked: coders keep write/shell.
167
156
  export function assertStructuredAdvisorReadOnly(definition) {
168
157
  const capability = definition.capabilities['structured-advisor'];
169
158
  if (!capability.supported) {
@@ -280,11 +269,11 @@ export function assertAgentConfigSupportsWriterRun(agentConfig, options = {}) {
280
269
  role: 'reviewer',
281
270
  context,
282
271
  reason: 'Neal writer runs need a reviewer provider that can inspect work and return structured verdicts',
283
- requireReadToolAccessOrInlineReviewerContext: true,
272
+ requireReadToolAccess: true,
284
273
  requireStructuredOutput: true,
285
274
  reasons: {
286
275
  structured_advisor_adapter: 'reviewer, plan-review, support, and final-completion verdicts run through the structured-advisor adapter',
287
- read_tool_access_or_inline_reviewer_context: 'reviewer rounds must either inspect repository state, diffs, and run artifacts directly with read tools, or support structured output so Neal can supply inline reviewer context (full diff, plan document, run artifacts) in the prompt',
276
+ read_tool_access: 'reviewer rounds must inspect repository state, diffs, and run artifacts directly with read tools',
288
277
  structured_output: 'reviewer rounds return schema-validated structured verdicts',
289
278
  model_override: 'a non-null reviewer model override is configured for this writer run',
290
279
  },
@@ -63,6 +63,8 @@ async function writeRunEvent(event, options) {
63
63
  await logger.event('provider.turn_completed', {
64
64
  ...commonEventData(event),
65
65
  ...(event.usage !== undefined ? { usage: event.usage } : {}),
66
+ ...(event.costUsd !== undefined ? { costUsd: event.costUsd } : {}),
67
+ ...(event.costSource !== undefined ? { costSource: event.costSource } : {}),
66
68
  });
67
69
  break;
68
70
  case 'tool_started':
@@ -121,6 +123,8 @@ async function writeRunEvent(event, options) {
121
123
  await logger.event('provider.usage_reported', {
122
124
  ...commonEventData(event),
123
125
  usage: event.usage,
126
+ ...(event.costUsd !== undefined ? { costUsd: event.costUsd } : {}),
127
+ ...(event.costSource !== undefined ? { costSource: event.costSource } : {}),
124
128
  });
125
129
  break;
126
130
  case 'provider_error':
@@ -1,3 +1,15 @@
1
+ // Detects an OpenAI content-safety refusal in a provider-authored error or
2
+ // exception message. OpenAI flags security-adjacent requests with a stable
3
+ // notice ("This content was flagged for possible cybersecurity risk ... join
4
+ // the Trusted Access for Cyber ..."); either substring is specific enough that
5
+ // ordinary review findings mentioning "security" do not trip it. Callers must
6
+ // only pass error-channel text (an embedded gateway error message or an
7
+ // HTTP/transport exception message), never assistant review content.
8
+ export function isContentSafetyRefusalMessage(message) {
9
+ const text = message.toLowerCase();
10
+ return (text.includes('flagged for possible cybersecurity risk') ||
11
+ text.includes('trusted access for cyber'));
12
+ }
1
13
  export class NealProviderError extends Error {
2
14
  provider;
3
15
  role;
@@ -1,7 +1,7 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { planResumeActions } from './resume-planner.js';
4
- import { getInteractiveRecoveryView } from './state-views.js';
4
+ import { getInteractiveRecoveryView, getPlanReviewGuidanceOriginPhase } from './state-views.js';
5
5
  export function decideResumeAction(args) {
6
6
  const resumeCommand = formatResumeCommand(args.selectedRunId);
7
7
  const messageCommand = formatMessageCommand(args.selectedRunId);
@@ -86,11 +86,12 @@ export function decideResumeAction(args) {
86
86
  };
87
87
  }
88
88
  function isWaitingForPlanReviewGuidance(state) {
89
- return (state.topLevelMode === 'plan' &&
90
- state.status === 'blocked' &&
91
- state.phase === 'blocked' &&
92
- state.blockedFromPhase === 'reviewer_plan' &&
93
- state.pendingPlanReviewGuidance === null);
89
+ // Shared discriminator: reviewer_plan plus coder-authored *response* blocks
90
+ // (which carry a durable blockerReason); dirty-worktree safety blocks at the
91
+ // same response phase have blockerReason null and are excluded. Evaluated before
92
+ // planResumeActions in decideResumeAction, so this needs_message decision takes
93
+ // precedence over a bare-resume restore of a genuine coder-authored block.
94
+ return getPlanReviewGuidanceOriginPhase(state) !== null;
94
95
  }
95
96
  function hasPendingPlanReviewGuidance(state) {
96
97
  return state.topLevelMode === 'plan' && state.pendingPlanReviewGuidance !== null;
@@ -132,11 +132,13 @@ function applyPlannedStatusAction(state, action) {
132
132
  ...state,
133
133
  phase: action.phase,
134
134
  status: 'running',
135
+ blockerReason: null,
135
136
  };
136
137
  case 'normalize_stopped_status':
137
138
  return {
138
139
  ...state,
139
140
  status: 'running',
141
+ blockerReason: null,
140
142
  };
141
143
  default:
142
144
  return state;
@@ -173,6 +175,7 @@ function applyPlannedDerivedPlanAction(state, action) {
173
175
  phase: action.phase,
174
176
  status: 'running',
175
177
  blockedFromPhase: null,
178
+ blockerReason: null,
176
179
  };
177
180
  case 'block_rejected_abandoned_derived_plan':
178
181
  return {
@@ -197,6 +200,10 @@ async function restoreResumableBlockedPhase(state, statePath, logger, action) {
197
200
  ...state,
198
201
  phase: action.phase,
199
202
  status: 'running',
203
+ // A coder-authored plan-stage response block persists a durable blockerReason;
204
+ // the resume planner is its first blocked->running writer, so clear it here so
205
+ // the reason never outlives its block (and the state satisfies the invariant).
206
+ blockerReason: null,
200
207
  });
201
208
  await logger?.event('run.resumed_from_blocked', {
202
209
  statePath,
@@ -210,6 +217,7 @@ async function normalizeStoppedStatus(state, statePath, logger, action) {
210
217
  const nextState = await saveState(statePath, {
211
218
  ...state,
212
219
  status: 'running',
220
+ blockerReason: null,
213
221
  });
214
222
  await logger?.event('run.status_normalized_on_resume', {
215
223
  statePath,
@@ -228,6 +236,7 @@ async function promoteUnexecutedDerivedPlan(state, statePath, logger, action) {
228
236
  phase: action.phase,
229
237
  status: 'running',
230
238
  blockedFromPhase: null,
239
+ blockerReason: null,
231
240
  });
232
241
  await logger?.event(action.kind === 'promote_accepted_derived_plan'
233
242
  ? 'run.promoted_accepted_derived_plan_on_resume'
@@ -28,6 +28,26 @@ function getArchivedRetrospectivePath(state, kind) {
28
28
  const suffix = state.finalCommit ? `-${state.finalCommit}` : '';
29
29
  return join(state.runDir, `RETROSPECTIVE-final${suffix}.md`);
30
30
  }
31
+ function getCurrentRunMetricsPath(runDir) {
32
+ return join(runDir, 'RUN_METRICS.json');
33
+ }
34
+ // Mirrors getArchivedRetrospectivePath's scope/kind/commit naming so the
35
+ // machine-readable metrics archive lines up with the retrospective archive.
36
+ function getArchivedRunMetricsPath(state, kind) {
37
+ const scopeLabel = getCurrentScopeLabel(state);
38
+ if (kind === 'scope_accepted') {
39
+ const suffix = state.finalCommit ? `-${state.finalCommit}` : '';
40
+ return join(state.runDir, `RUN_METRICS-scope-${scopeLabel}${suffix}.json`);
41
+ }
42
+ if (kind === 'blocked') {
43
+ return join(state.runDir, `RUN_METRICS-blocked-scope-${scopeLabel}.json`);
44
+ }
45
+ if (kind === 'failed') {
46
+ return join(state.runDir, `RUN_METRICS-failed-scope-${scopeLabel}.json`);
47
+ }
48
+ const suffix = state.finalCommit ? `-${state.finalCommit}` : '';
49
+ return join(state.runDir, `RUN_METRICS-final${suffix}.json`);
50
+ }
31
51
  async function loadRunEvents(runDir) {
32
52
  try {
33
53
  const content = await readFile(getEventsPath(runDir), 'utf8');
@@ -228,7 +248,7 @@ function summarizeBlocker(state) {
228
248
  const persistedBlocker = latestCompletedScope?.blocker?.trim() || null;
229
249
  const recovery = state.interactiveBlockedRecovery;
230
250
  const recoveryBlocker = recovery?.blockedReason?.trim() || null;
231
- const advice = recovery?.adjudicatorAdvice ?? null;
251
+ const advice = recovery?.consultantAdvice ?? null;
232
252
  const lines = [];
233
253
  if (persistedBlocker) {
234
254
  lines.push(`- Final blocker: ${persistedBlocker}`);
@@ -237,9 +257,9 @@ function summarizeBlocker(state) {
237
257
  lines.push(`- Recovery blocker: ${recoveryBlocker}`);
238
258
  }
239
259
  if (advice) {
240
- lines.push(`- Adjudicator triage: ${advice.triageCategory} (recoverable: ${advice.recoverable ? 'yes' : 'no'})`);
260
+ lines.push(`- Consultant triage: ${advice.triageCategory} (recoverable: ${advice.recoverable ? 'yes' : 'no'})`);
241
261
  if (advice.resolutionDirective.trim()) {
242
- lines.push(`- Adjudicator suggested directive: ${advice.resolutionDirective.trim()}`);
262
+ lines.push(`- Consultant suggested directive: ${advice.resolutionDirective.trim()}`);
243
263
  }
244
264
  }
245
265
  if (lines.length === 0) {
@@ -306,7 +326,8 @@ async function renderRetrospective(state, kind) {
306
326
  const changedFiles = await summarizeChangedFiles(state);
307
327
  const verificationSummary = summarizeVerification(scopeEvents);
308
328
  const metricsEvents = kind === 'done' ? events : scopeEvents;
309
- const runMetricsSummary = renderRunMetricsMarkdown(summarizeRunMetrics(metricsEvents));
329
+ const metrics = summarizeRunMetrics(metricsEvents);
330
+ const runMetricsSummary = renderRunMetricsMarkdown(metrics);
310
331
  const assessment = buildAssessment(state, scopeEvents);
311
332
  const narrativeRetrospective = buildNarrativeRetrospective({
312
333
  state,
@@ -320,7 +341,7 @@ async function renderRetrospective(state, kind) {
320
341
  const derivedPlan = getDerivedPlanView(state);
321
342
  const showDerivedPlanContext = Boolean(derivedPlan);
322
343
  const parentScopeLabel = derivedPlan?.executing ? getParentScopeLabel(state) : null;
323
- return [
344
+ const content = [
324
345
  `# Neal Retrospective`,
325
346
  '',
326
347
  `## Outcome`,
@@ -377,15 +398,23 @@ async function renderRetrospective(state, kind) {
377
398
  ...renderInteractiveBlockedRecoveryHistoryLines(state.interactiveBlockedRecoveryHistory),
378
399
  '',
379
400
  ].join('\n');
401
+ return { content, metrics };
380
402
  }
381
403
  async function writeRetrospectiveFile(path, content) {
382
404
  await writeTextAtomic(path, content);
383
405
  }
406
+ async function writeRunMetricsFile(path, metrics) {
407
+ await writeTextAtomic(path, `${JSON.stringify(metrics, null, 2)}\n`);
408
+ }
384
409
  export async function writeCheckpointRetrospective(state, kind) {
385
- const content = await renderRetrospective(state, kind);
410
+ const { content, metrics } = await renderRetrospective(state, kind);
386
411
  const currentPath = getCurrentRetrospectivePath(state.runDir);
387
412
  const archivedPath = getArchivedRetrospectivePath(state, kind);
388
413
  await writeRetrospectiveFile(currentPath, content);
389
414
  await writeRetrospectiveFile(archivedPath, content);
390
- return { currentPath, archivedPath };
415
+ const currentMetricsPath = getCurrentRunMetricsPath(state.runDir);
416
+ const archivedMetricsPath = getArchivedRunMetricsPath(state, kind);
417
+ await writeRunMetricsFile(currentMetricsPath, metrics);
418
+ await writeRunMetricsFile(archivedMetricsPath, metrics);
419
+ return { currentPath, archivedPath, currentMetricsPath, archivedMetricsPath };
391
420
  }
@@ -16,3 +16,33 @@ export function toResidualReviewDebt(findings) {
16
16
  coderCommit: finding.coderCommit,
17
17
  }));
18
18
  }
19
+ // Plan-review debt is a canonical-keyed *projection* of the current findings,
20
+ // never an accumulator: for each canonicalId whose latest-round finding is a
21
+ // deferred verification-hardening finding, emit exactly one debt item (latest
22
+ // round wins). A canonical that later reopens (its latest finding goes back to
23
+ // open) or is fixed/rejected simply drops out of the projection, so the debt
24
+ // lifecycle is automatic — no stale or duplicate entries and no removal rule.
25
+ export function toPlanReviewDebt(findings) {
26
+ const latestByCanonical = new Map();
27
+ for (const finding of findings) {
28
+ const existing = latestByCanonical.get(finding.canonicalId);
29
+ if (!existing || finding.round >= existing.round) {
30
+ latestByCanonical.set(finding.canonicalId, finding);
31
+ }
32
+ }
33
+ return [...latestByCanonical.values()]
34
+ .filter((finding) => finding.status === 'deferred' && finding.findingClass === 'verification_hardening')
35
+ .map((finding) => ({
36
+ id: finding.id,
37
+ canonicalId: finding.canonicalId,
38
+ status: 'deferred',
39
+ files: [...finding.files],
40
+ claim: finding.claim,
41
+ evidence: finding.evidence ?? null,
42
+ requiredAction: finding.requiredAction,
43
+ coderDisposition: finding.coderDisposition,
44
+ coderCommit: finding.coderCommit,
45
+ findingClass: finding.findingClass,
46
+ originRound: finding.round,
47
+ }));
48
+ }
@@ -1,6 +1,6 @@
1
1
  import { getAgentTurnRetryLimit, getAgentTurnStartupTimeoutMs, getApiRetryLimit, getDefaultCoderModel, getDefaultCoderProvider, getDefaultReviewerModel, getDefaultReviewerProvider, getInactivityTimeoutMs, } from '../config.js';
2
2
  import { runCoderStructuredPrompt } from '../agents/structured-coder.js';
3
- import { createInlineSection, getReviewerDoctrineAccessMode, readOnlyReviewerNeedsInlinedDiff, renderInlineReviewerContext, reviewerNeedsInlineContext, } from '../context/inline-review-context.js';
3
+ import { getReviewerDoctrineAccessMode, readOnlyReviewerNeedsInlinedDiff, } from '../context/inline-review-context.js';
4
4
  import { runWithAgentTurnLiveness } from '../providers/liveness.js';
5
5
  import { assertProviderSupportsCoder, assertProviderSupportsStructuredAdvisor, getStructuredAdvisorAdapter, } from '../providers/registry.js';
6
6
  import { createProviderTelemetrySink } from '../providers/telemetry.js';
@@ -47,25 +47,21 @@ class AgentReviewFindingsProviderAdapter {
47
47
  async reviewDraft(args) {
48
48
  const reviewer = getStructuredAdvisorAdapter(this.args.agentConfig.reviewer);
49
49
  const schema = buildReviewFindingsReviewSchema();
50
- // The base adjudication prompt only previews the diff. A reviewer without
51
- // repository read access gets the full selected diff and the draft
52
- // findings artifact inlined so it can adjudicate entirely from the
53
- // prompt. A read-only reviewer (read tools, no shell) that exposes its own
54
- // commit-range diff tool gets the read-only range-inspection section
55
- // directing it to the git_diff tool with the exact resolved revisions; a
56
- // read-only reviewer without a commit-range diff tool (native Claude/Codex)
57
- // instead gets the full selected diff inlined and no git_diff-tool
58
- // instruction, because it can neither run git commands, call a git_diff
59
- // tool, nor rely on the truncated preview. Tool-access reviewers keep the
60
- // built prompt byte-identical.
50
+ // The base adjudication prompt only previews the diff. A read-only
51
+ // reviewer (read tools, no shell) that exposes its own commit-range diff
52
+ // tool gets the read-only range-inspection section directing it to the
53
+ // git_diff tool with the exact resolved revisions; a read-only reviewer
54
+ // without a commit-range diff tool (native Claude/Codex) instead gets the
55
+ // full selected diff inlined and no git_diff-tool instruction, because it
56
+ // can neither run git commands, call a git_diff tool, nor rely on the
57
+ // truncated preview. Tool-access reviewers keep the built prompt
58
+ // byte-identical.
61
59
  const accessMode = getReviewerDoctrineAccessMode(this.args.agentConfig.reviewer);
62
- const prompt = reviewerNeedsInlineContext(this.args.agentConfig.reviewer)
63
- ? `${args.prompt}\n\n${renderInlineReviewerContext(buildReviewFindingsReviewInlineContext(args))}`
64
- : accessMode === 'read-only'
65
- ? readOnlyReviewerNeedsInlinedDiff(this.args.agentConfig.reviewer)
66
- ? `${args.prompt}\n\n${buildReviewFindingsInlinedDiffSection(args.context)}`
67
- : `${args.prompt}\n\n${buildReviewFindingsReadOnlyInspectionSection(args.context)}`
68
- : args.prompt;
60
+ const prompt = accessMode === 'read-only'
61
+ ? readOnlyReviewerNeedsInlinedDiff(this.args.agentConfig.reviewer)
62
+ ? `${args.prompt}\n\n${buildReviewFindingsInlinedDiffSection(args.context)}`
63
+ : `${args.prompt}\n\n${buildReviewFindingsReadOnlyInspectionSection(args.context)}`
64
+ : args.prompt;
69
65
  const result = await runWithAgentTurnLiveness({
70
66
  provider: this.args.agentConfig.reviewer.provider,
71
67
  role: 'structured-advisor',
@@ -102,14 +98,6 @@ class AgentReviewFindingsProviderAdapter {
102
98
  return validateReviewFindingsReview(result.structured);
103
99
  }
104
100
  }
105
- function buildReviewFindingsReviewInlineContext(args) {
106
- return {
107
- sections: [
108
- createInlineSection(`Full selected diff for range ${args.context.externalBaseCommit}..${args.context.externalHeadCommit}`, args.context.diff || '(empty diff)'),
109
- createInlineSection('Draft findings artifact under adjudication', JSON.stringify(args.draft, null, 2)),
110
- ],
111
- };
112
- }
113
101
  function buildReviewFindingsProtocolSpec(args) {
114
102
  return {
115
103
  protocol: 'neal-json-block-v1',
@@ -160,11 +148,11 @@ function assertReviewAgentCapabilities(agentConfig) {
160
148
  role: 'reviewer',
161
149
  context: 'neal review',
162
150
  reason: 'neal review uses the configured reviewer provider to adjudicate and accept or revise the findings artifact',
163
- requireReadToolAccessOrInlineReviewerContext: true,
151
+ requireReadToolAccess: true,
164
152
  requireStructuredOutput: true,
165
153
  reasons: {
166
154
  structured_advisor_adapter: 'review findings adjudication runs through the configured structured-advisor adapter',
167
- read_tool_access_or_inline_reviewer_context: 'the reviewer must either inspect the draft, selected diff, and repository context directly with read tools, or support structured output so Neal can inline the draft findings artifact and the full selected diff into the adjudication prompt',
155
+ read_tool_access: 'the reviewer must inspect the draft, selected diff, and repository context directly with read tools',
168
156
  structured_output: 'the reviewer verdict must be schema-validated before Neal accepts the findings artifact',
169
157
  model_override: 'a non-null reviewer model override is configured for review adjudication',
170
158
  },
@@ -126,12 +126,28 @@ export function renderReviewMarkdown(state) {
126
126
  for (const round of rounds) {
127
127
  lines.push('', `## Round ${round} Findings`);
128
128
  for (const finding of state.findings.filter((item) => item.round === round)) {
129
- lines.push('', `### ${finding.id}`, `- Canonical ID: ${finding.canonicalId}`, `- Source: ${finding.source}`, `- Severity: ${finding.severity}`, `- Status: ${finding.status}`, `- Files: ${finding.files.join(', ') || 'n/a'}`, `- Claim: ${finding.claim}`, `- Evidence: ${finding.evidence?.trim() || 'n/a'}`, `- Required action: ${finding.requiredAction}`, `- Round summary: ${finding.roundSummary}`, `- Coder disposition: ${finding.coderDisposition ?? 'pending'}`, `- Coder commit: ${finding.coderCommit ?? 'pending'}`);
129
+ lines.push('', `### ${finding.id}`, `- Canonical ID: ${finding.canonicalId}`, `- Source: ${finding.source}`, `- Severity: ${finding.severity}`,
130
+ // 'n/a' keeps execute-review findings (which carry no class)
131
+ // semantically unchanged rather than mislabeling them plan_correctness.
132
+ `- Finding class: ${finding.findingClass ?? 'n/a'}`, `- Status: ${finding.status}`, `- Files: ${finding.files.join(', ') || 'n/a'}`, `- Claim: ${finding.claim}`, `- Evidence: ${finding.evidence?.trim() || 'n/a'}`, `- Required action: ${finding.requiredAction}`, `- Round summary: ${finding.roundSummary}`, `- Coder disposition: ${finding.coderDisposition ?? 'pending'}`, `- Coder commit: ${finding.coderCommit ?? 'pending'}`);
130
133
  }
131
134
  }
132
135
  }
136
+ lines.push('', '## Plan Review Debt');
137
+ appendPlanReviewDebtGroup(lines, 'Inherited', state.inheritedPlanReviewDebt);
138
+ appendPlanReviewDebtGroup(lines, 'Current', state.planReviewDebt);
133
139
  return `${lines.join('\n')}\n`;
134
140
  }
141
+ function appendPlanReviewDebtGroup(lines, label, items) {
142
+ lines.push('', `### ${label}`);
143
+ if (items.length === 0) {
144
+ lines.push('- none');
145
+ return;
146
+ }
147
+ for (const item of items) {
148
+ lines.push(`- ${item.canonicalId}: findingClass=${item.findingClass ?? 'n/a'}; originRound=${item.originRound ?? 'n/a'}; claim=${item.claim}; requiredAction=${item.requiredAction}; coderDisposition=${item.coderDisposition ?? 'none'}`);
149
+ }
150
+ }
135
151
  export async function writeReviewMarkdown(path, state) {
136
152
  await writeTextAtomic(path, renderReviewMarkdown(state));
137
153
  }
@@ -48,6 +48,21 @@ function addUsage(target, value) {
48
48
  function hasUsage(usage) {
49
49
  return Object.values(usage).some((value) => value > 0);
50
50
  }
51
+ // Accumulate cost from one counted event into the provider bucket. Presence is
52
+ // tracked separately from value: costUsd stays null until an event carries a
53
+ // numeric costUsd, then it holds the running sum. A single provider/role bucket
54
+ // has one source; if both appear, 'provider' wins.
55
+ function accumulateCost(bucket, data) {
56
+ const raw = data.costUsd;
57
+ if (typeof raw !== 'number' || !Number.isFinite(raw)) {
58
+ return;
59
+ }
60
+ bucket.costUsd = (bucket.costUsd ?? 0) + raw;
61
+ const source = data.costSource;
62
+ if (source === 'provider' || source === 'rate') {
63
+ bucket.costSource = bucket.costSource === 'provider' || source === 'provider' ? 'provider' : 'rate';
64
+ }
65
+ }
51
66
  function cloneEmptyUsage() {
52
67
  return { ...EMPTY_USAGE };
53
68
  }
@@ -210,10 +225,13 @@ export function summarizeRunMetrics(events) {
210
225
  label: identity.label,
211
226
  turns: 0,
212
227
  usage: cloneEmptyUsage(),
228
+ costUsd: null,
229
+ costSource: null,
213
230
  };
214
231
  existing.turns += 1;
215
232
  if (!hasDedicatedUsageEvents) {
216
233
  addUsage(existing.usage, data.usage);
234
+ accumulateCost(existing, data);
217
235
  }
218
236
  providers.set(identity.key, existing);
219
237
  }
@@ -226,8 +244,11 @@ export function summarizeRunMetrics(events) {
226
244
  label: identity.label,
227
245
  turns: 0,
228
246
  usage: cloneEmptyUsage(),
247
+ costUsd: null,
248
+ costSource: null,
229
249
  };
230
250
  addUsage(existing.usage, data.usage);
251
+ accumulateCost(existing, data);
231
252
  providers.set(identity.key, existing);
232
253
  }
233
254
  else if (event.type === 'provider.command_completed') {
@@ -276,6 +297,25 @@ export function summarizeRunMetrics(events) {
276
297
  phase.durationMs = start !== null && end !== null && end >= start ? end - start : null;
277
298
  }
278
299
  const publicPhases = phases.map(({ startIndex: _startIndex, endIndex: _endIndex, ...phase }) => phase);
300
+ const providerSummaries = [...providers.values()].sort((left, right) => {
301
+ const turns = right.turns - left.turns;
302
+ if (turns !== 0) {
303
+ return turns;
304
+ }
305
+ return usageSortValue(right) - usageSortValue(left);
306
+ });
307
+ // Coverage is computed only over usage-bearing buckets so a partial subtotal
308
+ // never masquerades as a complete run total.
309
+ const usageBearing = providerSummaries.filter((provider) => hasUsage(provider.usage));
310
+ const pricedUsageBearing = usageBearing.filter((provider) => provider.costUsd !== null);
311
+ const costCoverage = pricedUsageBearing.length === 0
312
+ ? 'none'
313
+ : pricedUsageBearing.length === usageBearing.length
314
+ ? 'complete'
315
+ : 'partial';
316
+ const totalCostUsd = costCoverage === 'none'
317
+ ? null
318
+ : providerSummaries.reduce((sum, provider) => (provider.costUsd !== null ? sum + provider.costUsd : sum), 0);
279
319
  return {
280
320
  observedStartedAt: firstTimestamp,
281
321
  observedCompletedAt: lastTimestamp,
@@ -288,13 +328,9 @@ export function summarizeRunMetrics(events) {
288
328
  toolEventCount,
289
329
  fileChangeEventCount,
290
330
  phases: publicPhases,
291
- providers: [...providers.values()].sort((left, right) => {
292
- const turns = right.turns - left.turns;
293
- if (turns !== 0) {
294
- return turns;
295
- }
296
- return usageSortValue(right) - usageSortValue(left);
297
- }),
331
+ providers: providerSummaries,
332
+ totalCostUsd,
333
+ costCoverage,
298
334
  };
299
335
  }
300
336
  function formatDuration(value) {
@@ -327,6 +363,31 @@ function formatProviderName(summary) {
327
363
  function usageCell(value) {
328
364
  return value > 0 ? formatNumber(value) : '-';
329
365
  }
366
+ function formatCostAmount(value) {
367
+ return `$${value.toFixed(4)}`;
368
+ }
369
+ // Cost cell for the Provider Usage table: '-' when the bucket reported no cost,
370
+ // otherwise the fixed-precision USD amount with a footnote marker on
371
+ // rate-computed cells.
372
+ function formatCost(value, source) {
373
+ if (value === null) {
374
+ return '-';
375
+ }
376
+ return source === 'rate' ? `${formatCostAmount(value)}*` : formatCostAmount(value);
377
+ }
378
+ // The top-summary cost line, labeled by coverage so a partial subtotal is never
379
+ // presented as a complete run total.
380
+ function formatEstimatedCostLine(metrics) {
381
+ if (metrics.costCoverage === 'none' || metrics.totalCostUsd === null) {
382
+ return '- Estimated cost: unknown';
383
+ }
384
+ if (metrics.costCoverage === 'partial') {
385
+ const usageBearing = metrics.providers.filter((provider) => hasUsage(provider.usage));
386
+ const priced = usageBearing.filter((provider) => provider.costUsd !== null);
387
+ return `- Estimated cost (partial — ${priced.length} of ${usageBearing.length} priced providers): ${formatCostAmount(metrics.totalCostUsd)}`;
388
+ }
389
+ return `- Estimated cost: ${formatCostAmount(metrics.totalCostUsd)}`;
390
+ }
330
391
  export function renderRunMetricsMarkdown(metrics) {
331
392
  const commandSummary = metrics.resolvedNonZeroCommandCount > 0
332
393
  ? `- Commands: ${metrics.commandCount} total, ${metrics.unresolvedNonZeroCommandCount} unresolved non-zero or failed, ${metrics.resolvedNonZeroCommandCount} resolved by later passing rerun`
@@ -337,16 +398,20 @@ export function renderRunMetricsMarkdown(metrics) {
337
398
  commandSummary,
338
399
  `- Tool events: ${metrics.toolEventCount}`,
339
400
  `- File change events: ${metrics.fileChangeEventCount}`,
401
+ formatEstimatedCostLine(metrics),
340
402
  ];
341
403
  if (metrics.phases.length > 0) {
342
404
  lines.push('', '### Phase Timing', '', '| Phase | Duration | Provider turns | Commands | Non-zero commands | Tool events | File changes |', '| --- | ---: | ---: | ---: | ---: | ---: | ---: |', ...metrics.phases.map((phase) => `| ${formatPhaseName(phase.phase)} | ${formatPhaseDuration(phase)} | ${phase.providerTurns} | ${phase.commandCount} | ${phase.nonZeroCommandCount} | ${phase.toolEventCount} | ${phase.fileChangeEventCount} |`));
343
405
  }
344
406
  const providersWithUsage = metrics.providers.filter((provider) => provider.turns > 0 || hasUsage(provider.usage));
345
407
  if (providersWithUsage.length > 0) {
346
- lines.push('', '### Provider Usage', '', '| Provider / role | Turns | Input | Cached input | Cache created | Cache read | Output | Reasoning output | Total |', '| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |', ...providersWithUsage.map((provider) => {
408
+ lines.push('', '### Provider Usage', '', '| Provider / role | Turns | Input | Cached input | Cache created | Cache read | Output | Reasoning output | Total | Cost |', '| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |', ...providersWithUsage.map((provider) => {
347
409
  const usage = provider.usage;
348
- return `| ${formatProviderName(provider)} | ${provider.turns} | ${usageCell(usage.inputTokens)} | ${usageCell(usage.cachedInputTokens)} | ${usageCell(usage.cacheCreationInputTokens)} | ${usageCell(usage.cacheReadInputTokens)} | ${usageCell(usage.outputTokens)} | ${usageCell(usage.reasoningOutputTokens)} | ${usageCell(usage.totalTokens)} |`;
410
+ return `| ${formatProviderName(provider)} | ${provider.turns} | ${usageCell(usage.inputTokens)} | ${usageCell(usage.cachedInputTokens)} | ${usageCell(usage.cacheCreationInputTokens)} | ${usageCell(usage.cacheReadInputTokens)} | ${usageCell(usage.outputTokens)} | ${usageCell(usage.reasoningOutputTokens)} | ${usageCell(usage.totalTokens)} | ${formatCost(provider.costUsd, provider.costSource)} |`;
349
411
  }));
412
+ if (providersWithUsage.some((provider) => provider.costUsd !== null && provider.costSource === 'rate')) {
413
+ lines.push('', '\\* Cost estimated from published or configured rates, not reported by the provider.');
414
+ }
350
415
  }
351
416
  else {
352
417
  lines.push('', '### Provider Usage', '', '- No provider usage events recorded.');
@@ -307,8 +307,10 @@ function assertValidPendingPlanReviewGuidance(state, context) {
307
307
  throwStateInvariant(context, 'pendingPlanReviewGuidance', `expected null for ${formatInvalidValue(state.topLevelMode)} mode`);
308
308
  }
309
309
  assertNonEmptyString(guidance.message, 'pendingPlanReviewGuidance.message', context);
310
- if (guidance.sourcePhase !== 'reviewer_plan') {
311
- throwStateInvariant(context, 'pendingPlanReviewGuidance.sourcePhase', `expected "reviewer_plan", received ${formatInvalidValue(guidance.sourcePhase)}`);
310
+ if (guidance.sourcePhase !== 'reviewer_plan' &&
311
+ guidance.sourcePhase !== 'coder_plan_response' &&
312
+ guidance.sourcePhase !== 'coder_plan_optional_response') {
313
+ throwStateInvariant(context, 'pendingPlanReviewGuidance.sourcePhase', `expected one of "reviewer_plan", "coder_plan_response", "coder_plan_optional_response", received ${formatInvalidValue(guidance.sourcePhase)}`);
312
314
  }
313
315
  if (typeof guidance.recordedAt !== 'string') {
314
316
  throwStateInvariant(context, 'pendingPlanReviewGuidance.recordedAt', `expected string, received ${formatInvalidValue(guidance.recordedAt)}`);
@@ -466,10 +468,16 @@ export function assertValidOrchestrationState(state, context) {
466
468
  if (activeStatusInTerminalPhase) {
467
469
  throwStateInvariant(context, 'status', `cannot be ${JSON.stringify(state.status)} while phase is terminal ${JSON.stringify(state.phase)}`);
468
470
  }
471
+ // The durable coder-authored plan-stage blocker reason must never outlive its
472
+ // block: any return to a non-blocked status (running/paused/done/failed) must
473
+ // have cleared it. This catches a missed clear at the next save/load.
474
+ if (state.status !== 'blocked' && state.blockerReason !== null) {
475
+ throwStateInvariant(context, 'blockerReason', `expected null when status is ${JSON.stringify(state.status)}, received ${formatInvalidValue(state.blockerReason)}`);
476
+ }
469
477
  assertSafeIntegerAtLeast(state.currentScopeNumber, 'currentScopeNumber', 0, context);
470
478
  assertSafeIntegerAtLeast(state.coderRetryCount, 'coderRetryCount', 0, context);
471
479
  assertSafeIntegerAtLeast(state.unattendedAutoResumeCount, 'unattendedAutoResumeCount', 0, context);
472
- assertSafeIntegerAtLeast(state.reviewStuckArbiterCount, 'reviewStuckArbiterCount', 0, context);
480
+ assertSafeIntegerAtLeast(state.consultantAttemptCount, 'consultantAttemptCount', 0, context);
473
481
  assertSafeIntegerAtLeast(state.finalCompletionContinueExecutionCount, 'finalCompletionContinueExecutionCount', 0, context);
474
482
  assertSafeIntegerAtLeast(state.splitPlanCountForCurrentScope, 'splitPlanCountForCurrentScope', 0, context);
475
483
  assertSafeIntegerAtLeast(state.derivedPlanDepth, 'derivedPlanDepth', 0, context);
@@ -117,13 +117,35 @@ export function getInteractiveRecoveryView(state) {
117
117
  pendingOperatorGuidance,
118
118
  };
119
119
  }
120
+ // The plan-stage blocked origin that a top-level plan run can answer via
121
+ // `neal resume --message`, or null when the run is not in such a wait. The
122
+ // reviewer-plan message-resume path is always eligible. A coder-authored
123
+ // *response* block (coder_plan_response / coder_plan_optional_response) is
124
+ // eligible only when it carries the durable `blockerReason`: Scope 6 sets that
125
+ // reason exclusively for coder_authored landings and leaves it null for a
126
+ // dirty-worktree safety block, which lands at the same phase but must stay a
127
+ // normal blocked state (no --message route, no waiting-for-guidance status). The
128
+ // initial `coder_plan` authoring block is deliberately excluded.
129
+ export function getPlanReviewGuidanceOriginPhase(state) {
130
+ if (state.topLevelMode !== 'plan' ||
131
+ state.status !== 'blocked' ||
132
+ state.phase !== 'blocked' ||
133
+ state.pendingPlanReviewGuidance !== null) {
134
+ return null;
135
+ }
136
+ if (state.blockedFromPhase === 'reviewer_plan') {
137
+ return 'reviewer_plan';
138
+ }
139
+ if ((state.blockedFromPhase === 'coder_plan_response' ||
140
+ state.blockedFromPhase === 'coder_plan_optional_response') &&
141
+ state.blockerReason !== null) {
142
+ return state.blockedFromPhase;
143
+ }
144
+ return null;
145
+ }
120
146
  export function getPlanReviewGuidanceView(state) {
121
147
  const pendingOperatorGuidance = state.topLevelMode === 'plan' && state.pendingPlanReviewGuidance !== null;
122
- const waitingForOperatorGuidance = state.topLevelMode === 'plan' &&
123
- state.status === 'blocked' &&
124
- state.phase === 'blocked' &&
125
- state.blockedFromPhase === 'reviewer_plan' &&
126
- state.pendingPlanReviewGuidance === null;
148
+ const waitingForOperatorGuidance = getPlanReviewGuidanceOriginPhase(state) !== null;
127
149
  return {
128
150
  kind: 'plan_review_guidance',
129
151
  waitingForOperatorGuidance,