@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
@@ -2,6 +2,8 @@ import { createRequire } from 'node:module';
2
2
  import { resolve } from 'node:path';
3
3
  import { query, } from '@anthropic-ai/claude-agent-sdk';
4
4
  import { buildStructuredJsonPrompt, runStructuredJsonProtocol, runStructuredJsonRepairLoop, validateStructuredJsonPayload, } from '../agents/structured-json.js';
5
+ import { agentSettingsIsolated } from './agent-settings-isolation.js';
6
+ import { agentSubprocessEnv } from './git-config-isolation.js';
5
7
  import { NealProviderError } from './types.js';
6
8
  const ANTHROPIC_CLAUDE_PROVIDER_ID = 'anthropic-claude';
7
9
  const requireFromProvider = createRequire(import.meta.url);
@@ -57,7 +59,7 @@ class ClaudeInactivityTimeoutError extends Error {
57
59
  }
58
60
  // In-round transient (`api_error`) retries back off exponentially: 500 ms
59
61
  // base, doubling per retry, capped at 5 s — the same schedule as the
60
- // generic-agentic adapter. `sleep` is injectable through the run functions so
62
+ // openai-compatible adapter. `sleep` is injectable through the run functions so
61
63
  // tests observe the delays without wall-clock waiting.
62
64
  const API_RETRY_BASE_DELAY_MS = 500;
63
65
  const API_RETRY_MAX_DELAY_MS = 5_000;
@@ -125,9 +127,44 @@ async function appendClaudeText(label, state, text, events, sessionHandle, role
125
127
  }
126
128
  return emittedTexts;
127
129
  }
128
- async function logClaudeMessage(label, message, events, state, sessionHandle, role = roleForClaudeLabel(label)) {
130
+ function getClaudeBashCommand(toolInput) {
131
+ if (toolInput === null || typeof toolInput !== 'object') {
132
+ return null;
133
+ }
134
+ const command = toolInput.command;
135
+ return typeof command === 'string' && command.trim() ? command : null;
136
+ }
137
+ // Extracts the human-readable text of a tool_result block: string content is
138
+ // used as-is; block-array content contributes its text blocks. Non-text
139
+ // content (images, documents) yields null.
140
+ function getClaudeToolResultText(content) {
141
+ if (typeof content === 'string') {
142
+ return content;
143
+ }
144
+ if (!Array.isArray(content)) {
145
+ return null;
146
+ }
147
+ const texts = [];
148
+ for (const block of content) {
149
+ if (block !== null &&
150
+ typeof block === 'object' &&
151
+ block.type === 'text' &&
152
+ typeof block.text === 'string') {
153
+ texts.push(block.text);
154
+ }
155
+ }
156
+ return texts.length > 0 ? texts.join('\n') : null;
157
+ }
158
+ async function logClaudeMessage(label, message, events, state, sessionHandle, role = roleForClaudeLabel(label), cwd) {
129
159
  switch (message.type) {
130
160
  case 'assistant': {
161
+ if (state) {
162
+ for (const block of message.message.content) {
163
+ if (block.type === 'tool_use') {
164
+ state.pendingToolUses.set(block.id, { name: block.name, input: block.input });
165
+ }
166
+ }
167
+ }
131
168
  if (state?.sawTextDelta) {
132
169
  return [];
133
170
  }
@@ -150,6 +187,72 @@ async function logClaudeMessage(label, message, events, state, sessionHandle, ro
150
187
  }
151
188
  break;
152
189
  }
190
+ case 'user': {
191
+ // SDK tool executions surface as user messages carrying tool_result
192
+ // blocks. Matching them against the turn's pending tool_use blocks maps
193
+ // Bash runs to `command_completed` and successful write-tool calls to
194
+ // `file_changed`. Read-class tools (Read/Grep/Glob) intentionally emit
195
+ // nothing here — they are not verification evidence. The mapping is
196
+ // role-agnostic: advisor rounds run a read-only toolset, so they never
197
+ // produce Bash or write tool_uses.
198
+ if (!state) {
199
+ break;
200
+ }
201
+ const content = message.message.content;
202
+ if (!Array.isArray(content)) {
203
+ break;
204
+ }
205
+ for (const block of content) {
206
+ if (block.type !== 'tool_result') {
207
+ continue;
208
+ }
209
+ const pending = state.pendingToolUses.get(block.tool_use_id);
210
+ if (!pending) {
211
+ continue;
212
+ }
213
+ state.pendingToolUses.delete(block.tool_use_id);
214
+ if (pending.name === 'Bash') {
215
+ const command = getClaudeBashCommand(pending.input);
216
+ if (!command) {
217
+ continue;
218
+ }
219
+ // The SDK's tool_result exposes no exit code, so `exitCode` is
220
+ // omitted; `status` carries the is_error signal instead.
221
+ const output = getClaudeToolResultText(block.content);
222
+ await emitProviderEvent(events, {
223
+ type: 'command_completed',
224
+ provider: ANTHROPIC_CLAUDE_PROVIDER_ID,
225
+ role,
226
+ label,
227
+ sessionHandle,
228
+ itemId: block.tool_use_id,
229
+ command,
230
+ status: block.is_error === true ? 'failed' : 'completed',
231
+ ...(output !== null ? { output } : {}),
232
+ ...(cwd ? { cwd } : {}),
233
+ providerData: { sdkMessageType: message.type, toolName: pending.name, toolUseId: block.tool_use_id },
234
+ });
235
+ }
236
+ else if (CLAUDE_WRITE_TOOL_NAMES.has(pending.name) && block.is_error !== true) {
237
+ // An is_error result means the write did not happen (e.g. a
238
+ // write-path guard deny), so it must not claim a file change.
239
+ const filePath = getClaudeWriteToolPath(pending.input);
240
+ if (!filePath) {
241
+ continue;
242
+ }
243
+ await emitProviderEvent(events, {
244
+ type: 'file_changed',
245
+ provider: ANTHROPIC_CLAUDE_PROVIDER_ID,
246
+ role,
247
+ label,
248
+ sessionHandle,
249
+ files: [filePath],
250
+ providerData: { sdkMessageType: message.type, toolName: pending.name, toolUseId: block.tool_use_id },
251
+ });
252
+ }
253
+ }
254
+ break;
255
+ }
153
256
  case 'stream_event':
154
257
  if (message.event.type === 'content_block_delta') {
155
258
  if (message.event.delta.type === 'text_delta' && state) {
@@ -274,6 +377,9 @@ async function logClaudeMessage(label, message, events, state, sessionHandle, ro
274
377
  }
275
378
  break;
276
379
  case 'result':
380
+ // Turn boundary: a tool_use whose result never arrived must not match a
381
+ // tool_result from a later turn in the same stream.
382
+ state?.pendingToolUses.clear();
277
383
  await emitProviderEvent(events, {
278
384
  type: 'turn_completed',
279
385
  provider: ANTHROPIC_CLAUDE_PROVIDER_ID,
@@ -281,6 +387,10 @@ async function logClaudeMessage(label, message, events, state, sessionHandle, ro
281
387
  label,
282
388
  sessionHandle,
283
389
  usage: message.usage,
390
+ // One `result` message is one query invocation = one turn, so
391
+ // `total_cost_usd` is a per-turn figure that sums cleanly across turns.
392
+ costUsd: message.total_cost_usd,
393
+ costSource: 'provider',
284
394
  providerData: { sdkMessageType: message.type, subtype: message.subtype },
285
395
  });
286
396
  if (message.usage !== undefined) {
@@ -291,6 +401,8 @@ async function logClaudeMessage(label, message, events, state, sessionHandle, ro
291
401
  label,
292
402
  sessionHandle,
293
403
  usage: message.usage,
404
+ costUsd: message.total_cost_usd,
405
+ costSource: 'provider',
294
406
  providerData: { sdkMessageType: message.type, subtype: message.subtype },
295
407
  });
296
408
  }
@@ -401,7 +513,12 @@ async function collectClaudeResult(stream, cwd, label, inactivityTimeoutMs, even
401
513
  let sessionHandle = null;
402
514
  let lastResult = null;
403
515
  let firstStructuredResult = null;
404
- const logState = { textBuffer: '', sawTextDelta: false, lastThinkingEmitMs: 0 };
516
+ const logState = {
517
+ textBuffer: '',
518
+ sawTextDelta: false,
519
+ lastThinkingEmitMs: 0,
520
+ pendingToolUses: new Map(),
521
+ };
405
522
  const assistantTexts = [];
406
523
  const iterator = stream[Symbol.asyncIterator]();
407
524
  while (true) {
@@ -435,7 +552,7 @@ async function collectClaudeResult(stream, cwd, label, inactivityTimeoutMs, even
435
552
  providerData: { sdkMessageType: message.type },
436
553
  });
437
554
  }
438
- const emittedAssistantTexts = await logClaudeMessage(label, message, events, logState, sessionHandle, role);
555
+ const emittedAssistantTexts = await logClaudeMessage(label, message, events, logState, sessionHandle, role, cwd);
439
556
  if (emittedAssistantTexts.length > 0) {
440
557
  assistantTexts.push(emittedAssistantTexts.join('\n\n'));
441
558
  }
@@ -484,36 +601,72 @@ function createClaudeTurnAbortController(signal) {
484
601
  function deriveClaudeAbortController(signal) {
485
602
  return signal ? createClaudeTurnAbortController(signal) : undefined;
486
603
  }
487
- function buildClaudeQueryOptions(args, defaultModel, claudeExecutablePath = getClaudeCodeExecutablePath(), defaultEffort, abortController = deriveClaudeAbortController(args.signal)) {
604
+ function buildClaudeCoreQueryOptions(spec) {
488
605
  return {
489
- cwd: args.cwd,
490
- ...(abortController ? { abortController } : {}),
491
- ...((args.model ?? defaultModel) ? { model: args.model ?? defaultModel ?? undefined } : {}),
492
- ...(defaultEffort ? { effort: defaultEffort } : {}),
493
- tools: ['Read', 'Grep', 'Glob'],
606
+ cwd: spec.cwd,
607
+ // Full environment plus the git-config isolation overrides: Claude's Bash
608
+ // tool must not be able to write the operator's real global gitconfig.
609
+ // See git-config-isolation.ts.
610
+ env: agentSubprocessEnv(),
611
+ ...(spec.abortController ? { abortController: spec.abortController } : {}),
612
+ ...(spec.model ? { model: spec.model } : {}),
613
+ ...(spec.effort ? { effort: spec.effort } : {}),
614
+ tools: spec.tools,
615
+ ...(spec.hooks ? { hooks: spec.hooks } : {}),
616
+ // Under compat qualification only, run Claude in the SDK's isolation mode
617
+ // (load no filesystem settings). Left unset, the SDK loads ~/.claude and any
618
+ // repo .claude by default, pulling in the operator's interactive config —
619
+ // permissions, statusline, CLAUDE.md, and the Stop / Notification hooks that
620
+ // fire a desktop notifier on every turn. compat runs the whole role matrix,
621
+ // so those hooks would spam one notification per turn; isolating keeps the
622
+ // capability probe quiet and hermetic. Normal neal runs (pipeline, local
623
+ // `neal go`) honor the operator's config — it's their machine. neal's own
624
+ // hooks (the coder write-path guard passed programmatically above) are
625
+ // unaffected either way. See agent-settings-isolation.ts.
626
+ ...(agentSettingsIsolated() ? { settingSources: [] } : {}),
494
627
  permissionMode: 'bypassPermissions',
495
628
  allowDangerouslySkipPermissions: true,
496
- ...(args.resumeHandle ? { resume: args.resumeHandle } : {}),
497
- ...(claudeExecutablePath ? { pathToClaudeCodeExecutable: claudeExecutablePath } : {}),
629
+ ...(spec.resumeHandle ? { resume: spec.resumeHandle } : {}),
630
+ ...(spec.claudeExecutablePath ? { pathToClaudeCodeExecutable: spec.claudeExecutablePath } : {}),
631
+ ...(spec.outputSchema
632
+ ? {
633
+ outputFormat: {
634
+ type: 'json_schema',
635
+ schema: spec.outputSchema,
636
+ },
637
+ }
638
+ : {}),
498
639
  stderr: (data) => {
499
- void args.events?.({
640
+ void spec.events?.({
500
641
  type: 'tool_progress',
501
642
  provider: ANTHROPIC_CLAUDE_PROVIDER_ID,
502
- role: 'structured-advisor',
503
- label: args.label,
504
- sessionHandle: args.resumeHandle ?? null,
643
+ role: spec.stderrRole,
644
+ label: spec.stderrLabel,
645
+ sessionHandle: spec.stderrSessionHandle,
505
646
  toolName: 'stderr',
506
647
  message: data,
507
648
  isError: true,
508
649
  providerData: { stream: 'stderr' },
509
650
  });
510
651
  },
511
- outputFormat: {
512
- type: 'json_schema',
513
- schema: args.schema,
514
- },
515
652
  };
516
653
  }
654
+ function buildClaudeQueryOptions(args, defaultModel, claudeExecutablePath = getClaudeCodeExecutablePath(), defaultEffort, abortController = deriveClaudeAbortController(args.signal)) {
655
+ return buildClaudeCoreQueryOptions({
656
+ cwd: args.cwd,
657
+ abortController,
658
+ model: args.model ?? defaultModel,
659
+ effort: defaultEffort,
660
+ tools: ['Read', 'Grep', 'Glob'],
661
+ resumeHandle: args.resumeHandle,
662
+ claudeExecutablePath,
663
+ outputSchema: args.schema,
664
+ events: args.events,
665
+ stderrRole: 'structured-advisor',
666
+ stderrLabel: args.label,
667
+ stderrSessionHandle: args.resumeHandle ?? null,
668
+ });
669
+ }
517
670
  function buildClaudeQueryStream(args, defaultModel, defaultEffort, abortController) {
518
671
  return query({
519
672
  prompt: args.prompt,
@@ -527,30 +680,19 @@ function buildClaudeJsonBlockReviewPrompt(args) {
527
680
  return args.prompt;
528
681
  }
529
682
  function buildClaudeJsonBlockQueryOptions(args, defaultModel, claudeExecutablePath = getClaudeCodeExecutablePath(), defaultEffort, abortController = deriveClaudeAbortController(args.signal)) {
530
- return {
683
+ return buildClaudeCoreQueryOptions({
531
684
  cwd: args.cwd,
532
- ...(abortController ? { abortController } : {}),
533
- ...((args.model ?? defaultModel) ? { model: args.model ?? defaultModel ?? undefined } : {}),
534
- ...(defaultEffort ? { effort: defaultEffort } : {}),
685
+ abortController,
686
+ model: args.model ?? defaultModel,
687
+ effort: defaultEffort,
535
688
  tools: ['Read', 'Grep', 'Glob'],
536
- permissionMode: 'bypassPermissions',
537
- allowDangerouslySkipPermissions: true,
538
- ...(args.resumeHandle ? { resume: args.resumeHandle } : {}),
539
- ...(claudeExecutablePath ? { pathToClaudeCodeExecutable: claudeExecutablePath } : {}),
540
- stderr: (data) => {
541
- void args.events?.({
542
- type: 'tool_progress',
543
- provider: ANTHROPIC_CLAUDE_PROVIDER_ID,
544
- role: 'structured-advisor',
545
- label: args.label,
546
- sessionHandle: args.resumeHandle ?? null,
547
- toolName: 'stderr',
548
- message: data,
549
- isError: true,
550
- providerData: { stream: 'stderr' },
551
- });
552
- },
553
- };
689
+ resumeHandle: args.resumeHandle,
690
+ claudeExecutablePath,
691
+ events: args.events,
692
+ stderrRole: 'structured-advisor',
693
+ stderrLabel: args.label,
694
+ stderrSessionHandle: args.resumeHandle ?? null,
695
+ });
554
696
  }
555
697
  function buildClaudeJsonBlockQueryStream(args, defaultModel, defaultEffort, abortController) {
556
698
  return query({
@@ -559,29 +701,18 @@ function buildClaudeJsonBlockQueryStream(args, defaultModel, defaultEffort, abor
559
701
  });
560
702
  }
561
703
  function buildClaudeJsonBlockRepairQueryOptions(args, defaultModel, claudeExecutablePath = getClaudeCodeExecutablePath(), defaultEffort, abortController) {
562
- return {
704
+ return buildClaudeCoreQueryOptions({
563
705
  cwd: args.cwd,
564
- ...(abortController ? { abortController } : {}),
565
- ...((args.model ?? defaultModel) ? { model: args.model ?? defaultModel ?? undefined } : {}),
566
- ...(defaultEffort ? { effort: defaultEffort } : {}),
706
+ abortController,
707
+ model: args.model ?? defaultModel,
708
+ effort: defaultEffort,
567
709
  tools: [],
568
- permissionMode: 'bypassPermissions',
569
- allowDangerouslySkipPermissions: true,
570
- ...(claudeExecutablePath ? { pathToClaudeCodeExecutable: claudeExecutablePath } : {}),
571
- stderr: (data) => {
572
- void args.events?.({
573
- type: 'tool_progress',
574
- provider: ANTHROPIC_CLAUDE_PROVIDER_ID,
575
- role: 'structured-advisor',
576
- label: `${args.label}:structured-json-repair`,
577
- sessionHandle: null,
578
- toolName: 'stderr',
579
- message: data,
580
- isError: true,
581
- providerData: { stream: 'stderr' },
582
- });
583
- },
584
- };
710
+ claudeExecutablePath,
711
+ events: args.events,
712
+ stderrRole: 'structured-advisor',
713
+ stderrLabel: `${args.label}:structured-json-repair`,
714
+ stderrSessionHandle: null,
715
+ });
585
716
  }
586
717
  function buildClaudeJsonBlockRepairQueryStream(args, repairPrompt, defaultModel, defaultEffort, abortController) {
587
718
  return query({
@@ -959,7 +1090,33 @@ async function validateOrRepairClaudeJsonBlockResponse(args) {
959
1090
  throw providerError;
960
1091
  }
961
1092
  }
962
- async function runClaudeJsonBlockStructuredAdvisorRound(args, defaultModel, createStream = buildClaudeJsonBlockQueryStream, createRepairStream = buildClaudeJsonBlockRepairQueryStream, defaultEffort, sleep = defaultSleep) {
1093
+ // Both advisor protocols give up on an unsuccessful Claude result with the
1094
+ // same normalized provider error; only the loop position at which each
1095
+ // protocol reaches this differs (see runClaudeStructuredAdvisorRoundLoop).
1096
+ async function createClaudeAdvisorUnsuccessfulResultError(details) {
1097
+ const providerError = createClaudeProviderError({
1098
+ message: details.resultErrorMessage
1099
+ ? `Claude ${details.label} did not return a successful result${details.subtype ? ` (${details.subtype})` : ''}: ${details.resultErrorMessage}`
1100
+ : `Claude ${details.label} did not return a successful result${details.subtype ? ` (${details.subtype})` : ''}`,
1101
+ role: 'structured-advisor',
1102
+ sessionHandle: details.sessionHandle,
1103
+ kind: getClaudeStructuredFailureKind(details.subtype, details.resultErrorMessage),
1104
+ subtype: details.subtype,
1105
+ });
1106
+ await emitClaudeProviderError(details.events, providerError, details.label);
1107
+ return providerError;
1108
+ }
1109
+ // One advisor round loop serves both structured-output protocols. The shared
1110
+ // skeleton — per-turn abort controller, assistant-text mirroring, transient
1111
+ // api_error retries with exponential backoff, transient error-result
1112
+ // retries, and the final unsuccessful-result error — is identical for both;
1113
+ // the `run.protocol` discriminator selects the json-block behavior (local
1114
+ // extraction/validation with the repair sub-loop, plus the mirrored-prose
1115
+ // recovery when the stream fails after emitting assistant text) or the
1116
+ // native behavior (SDK `json_schema` structured output with stream-error and
1117
+ // retry-exhaustion telemetry).
1118
+ async function runClaudeStructuredAdvisorRoundLoop(run, defaultModel, defaultEffort, sleep = defaultSleep) {
1119
+ const args = run.args;
963
1120
  let sessionHandle = args.resumeHandle ?? null;
964
1121
  let apiRetryCount = 0;
965
1122
  const apiRetryLimit = args.apiRetryLimit;
@@ -974,56 +1131,9 @@ async function runClaudeJsonBlockStructuredAdvisorRound(args, defaultModel, crea
974
1131
  await args.events?.(event);
975
1132
  }
976
1133
  : undefined;
977
- let result;
978
- try {
979
- const turnAbortController = createClaudeTurnAbortController(args.signal);
980
- result = await collectClaudeResult(createStream(args, defaultModel, defaultEffort, turnAbortController), args.cwd, args.label, args.inactivityTimeoutMs, primaryEvents, 'structured-advisor', turnAbortController);
981
- }
982
- catch (error) {
983
- const providerError = normalizeClaudeProviderError(error, {
984
- role: 'structured-advisor',
985
- sessionHandle,
986
- });
987
- if (providerError.kind === 'api_error' && apiRetryCount < apiRetryLimit) {
988
- apiRetryCount += 1;
989
- await emitProviderEvent(args.events, {
990
- type: 'tool_progress',
991
- provider: ANTHROPIC_CLAUDE_PROVIDER_ID,
992
- role: 'structured-advisor',
993
- label: args.label,
994
- sessionHandle,
995
- toolName: 'api_retry',
996
- message: `transient API failure; retrying review (${apiRetryCount}/${apiRetryLimit})`,
997
- isError: true,
998
- providerData: {
999
- retryCount: apiRetryCount,
1000
- retryLimit: apiRetryLimit,
1001
- message: providerError.message,
1002
- },
1003
- });
1004
- await sleep(getApiRetryDelayMs(apiRetryCount));
1005
- continue;
1006
- }
1007
- const mirroredAssistantText = mirroredAssistantTexts.join('\n\n').trim();
1008
- if (mirroredAssistantText &&
1009
- (providerError.kind === 'structured_output_invalid' || providerError.kind === 'structured_output_missing')) {
1010
- return await validateOrRepairClaudeJsonBlockResponse({
1011
- roundArgs: args,
1012
- assistantText: mirroredAssistantText,
1013
- defaultModel,
1014
- defaultEffort,
1015
- sessionHandle: providerError.sessionHandle ?? sessionHandle,
1016
- createRepairStream,
1017
- });
1018
- }
1019
- await emitClaudeProviderError(args.events, providerError, args.label);
1020
- throw providerError;
1021
- }
1022
- sessionHandle = result.sessionHandle ?? sessionHandle;
1023
- const subtype = result.lastResult?.subtype ?? null;
1024
- const resultErrorMessage = getClaudeResultErrorMessage(result.lastResult);
1025
- const isErrorResult = result.lastResult?.is_error === true;
1026
- if (isErrorResult && isTransientClaudeFailure(subtype, resultErrorMessage) && apiRetryCount < apiRetryLimit) {
1134
+ // Reads `sessionHandle` at call time, so post-collect callers observe the
1135
+ // handle updated from the just-collected result.
1136
+ const retryAfterTransientErrorResult = async (subtype, message) => {
1027
1137
  apiRetryCount += 1;
1028
1138
  await emitProviderEvent(args.events, {
1029
1139
  type: 'tool_progress',
@@ -1038,60 +1148,15 @@ async function runClaudeJsonBlockStructuredAdvisorRound(args, defaultModel, crea
1038
1148
  retryCount: apiRetryCount,
1039
1149
  retryLimit: apiRetryLimit,
1040
1150
  subtype,
1041
- message: resultErrorMessage,
1151
+ message,
1042
1152
  },
1043
1153
  });
1044
1154
  await sleep(getApiRetryDelayMs(apiRetryCount));
1045
- continue;
1046
- }
1047
- const structuredRetryExhausted = isStructuredOutputRetryExhaustion(subtype, resultErrorMessage);
1048
- if (subtype && subtype !== 'success' && !structuredRetryExhausted) {
1049
- const providerError = createClaudeProviderError({
1050
- message: resultErrorMessage
1051
- ? `Claude ${args.label} did not return a successful result${subtype ? ` (${subtype})` : ''}: ${resultErrorMessage}`
1052
- : `Claude ${args.label} did not return a successful result${subtype ? ` (${subtype})` : ''}`,
1053
- role: 'structured-advisor',
1054
- sessionHandle,
1055
- kind: getClaudeStructuredFailureKind(subtype, resultErrorMessage),
1056
- subtype,
1057
- });
1058
- await emitClaudeProviderError(args.events, providerError, args.label);
1059
- throw providerError;
1060
- }
1061
- const assistantText = getClaudeAssistantTextForLocalJson(result, mirroredAssistantTexts.join('\n\n').trim());
1062
- return await validateOrRepairClaudeJsonBlockResponse({
1063
- roundArgs: args,
1064
- assistantText,
1065
- defaultModel,
1066
- defaultEffort,
1067
- sessionHandle,
1068
- createRepairStream,
1069
- });
1070
- }
1071
- }
1072
- async function runClaudeStructuredAdvisorRound(args, defaultModel, createStream = buildClaudeQueryStream, defaultEffort, sleep = defaultSleep) {
1073
- if (isClaudeJsonBlockStructuredAdvisorRound(args)) {
1074
- return await runClaudeJsonBlockStructuredAdvisorRound(args, defaultModel, undefined, undefined, defaultEffort, sleep);
1075
- }
1076
- let sessionHandle = args.resumeHandle ?? null;
1077
- let apiRetryCount = 0;
1078
- const apiRetryLimit = args.apiRetryLimit;
1079
- while (true) {
1155
+ };
1080
1156
  let result;
1081
- const mirroredAssistantTexts = [];
1082
- const primaryEvents = args.events
1083
- ? async (event) => {
1084
- const text = getMirroredAssistantText(event, 'structured-advisor', args.label);
1085
- if (text) {
1086
- mirroredAssistantTexts.push(text);
1087
- }
1088
- await args.events?.(event);
1089
- }
1090
- : undefined;
1091
1157
  try {
1092
1158
  const turnAbortController = createClaudeTurnAbortController(args.signal);
1093
- const stream = createStream(args, defaultModel, defaultEffort, turnAbortController);
1094
- result = await collectClaudeResult(stream, args.cwd, args.label, args.inactivityTimeoutMs, primaryEvents, 'structured-advisor', turnAbortController);
1159
+ result = await collectClaudeResult(run.createStream(run.args, defaultModel, defaultEffort, turnAbortController), args.cwd, args.label, args.inactivityTimeoutMs, primaryEvents, 'structured-advisor', turnAbortController);
1095
1160
  }
1096
1161
  catch (error) {
1097
1162
  const providerError = normalizeClaudeProviderError(error, {
@@ -1099,7 +1164,7 @@ async function runClaudeStructuredAdvisorRound(args, defaultModel, createStream
1099
1164
  sessionHandle,
1100
1165
  });
1101
1166
  const mirroredAssistantText = mirroredAssistantTexts.join('\n\n').trim();
1102
- if (providerError.kind === 'structured_output_invalid') {
1167
+ if (run.protocol === 'native' && providerError.kind === 'structured_output_invalid') {
1103
1168
  await emitProviderEvent(args.events, {
1104
1169
  type: 'tool_progress',
1105
1170
  provider: ANTHROPIC_CLAUDE_PROVIDER_ID,
@@ -1137,11 +1202,51 @@ async function runClaudeStructuredAdvisorRound(args, defaultModel, createStream
1137
1202
  await sleep(getApiRetryDelayMs(apiRetryCount));
1138
1203
  continue;
1139
1204
  }
1205
+ if (run.protocol === 'json-block' &&
1206
+ mirroredAssistantText &&
1207
+ (providerError.kind === 'structured_output_invalid' || providerError.kind === 'structured_output_missing')) {
1208
+ return await validateOrRepairClaudeJsonBlockResponse({
1209
+ roundArgs: run.args,
1210
+ assistantText: mirroredAssistantText,
1211
+ defaultModel,
1212
+ defaultEffort,
1213
+ sessionHandle: providerError.sessionHandle ?? sessionHandle,
1214
+ createRepairStream: run.createRepairStream,
1215
+ });
1216
+ }
1140
1217
  await emitClaudeProviderError(args.events, providerError, args.label);
1141
1218
  throw providerError;
1142
1219
  }
1143
1220
  sessionHandle = result.sessionHandle ?? sessionHandle;
1144
1221
  const lastResult = result.lastResult;
1222
+ const subtype = lastResult?.subtype ?? null;
1223
+ const resultErrorMessage = getClaudeResultErrorMessage(lastResult);
1224
+ const structuredRetryExhausted = isStructuredOutputRetryExhaustion(subtype, resultErrorMessage);
1225
+ const isTransientErrorResult = lastResult?.is_error === true && isTransientClaudeFailure(subtype, resultErrorMessage);
1226
+ if (run.protocol === 'json-block') {
1227
+ if (isTransientErrorResult && apiRetryCount < apiRetryLimit) {
1228
+ await retryAfterTransientErrorResult(subtype, resultErrorMessage);
1229
+ continue;
1230
+ }
1231
+ if (subtype && subtype !== 'success' && !structuredRetryExhausted) {
1232
+ throw await createClaudeAdvisorUnsuccessfulResultError({
1233
+ events: args.events,
1234
+ label: args.label,
1235
+ subtype,
1236
+ resultErrorMessage,
1237
+ sessionHandle,
1238
+ });
1239
+ }
1240
+ const assistantText = getClaudeAssistantTextForLocalJson(result, mirroredAssistantTexts.join('\n\n').trim());
1241
+ return await validateOrRepairClaudeJsonBlockResponse({
1242
+ roundArgs: run.args,
1243
+ assistantText,
1244
+ defaultModel,
1245
+ defaultEffort,
1246
+ sessionHandle,
1247
+ createRepairStream: run.createRepairStream,
1248
+ });
1249
+ }
1145
1250
  const structured = getPreferredStructuredOutput(result);
1146
1251
  if (structured !== undefined) {
1147
1252
  await emitProviderEvent(args.events, {
@@ -1156,9 +1261,6 @@ async function runClaudeStructuredAdvisorRound(args, defaultModel, createStream
1156
1261
  structured,
1157
1262
  };
1158
1263
  }
1159
- const subtype = lastResult?.subtype ?? null;
1160
- const resultErrorMessage = getClaudeResultErrorMessage(lastResult);
1161
- const structuredRetryExhausted = isStructuredOutputRetryExhaustion(subtype, resultErrorMessage);
1162
1264
  if (structuredRetryExhausted) {
1163
1265
  await emitProviderEvent(args.events, {
1164
1266
  type: 'tool_progress',
@@ -1177,40 +1279,28 @@ async function runClaudeStructuredAdvisorRound(args, defaultModel, createStream
1177
1279
  },
1178
1280
  });
1179
1281
  }
1180
- if (lastResult?.is_error === true && isTransientClaudeFailure(subtype, resultErrorMessage) && apiRetryCount < apiRetryLimit) {
1181
- apiRetryCount += 1;
1182
- await emitProviderEvent(args.events, {
1183
- type: 'tool_progress',
1184
- provider: ANTHROPIC_CLAUDE_PROVIDER_ID,
1185
- role: 'structured-advisor',
1186
- label: args.label,
1187
- sessionHandle,
1188
- toolName: 'api_retry',
1189
- message: `transient Claude error result; retrying review (${apiRetryCount}/${apiRetryLimit})`,
1190
- isError: true,
1191
- providerData: {
1192
- retryCount: apiRetryCount,
1193
- retryLimit: apiRetryLimit,
1194
- subtype,
1195
- message: resultErrorMessage,
1196
- },
1197
- });
1198
- await sleep(getApiRetryDelayMs(apiRetryCount));
1282
+ if (isTransientErrorResult && apiRetryCount < apiRetryLimit) {
1283
+ await retryAfterTransientErrorResult(subtype, resultErrorMessage);
1199
1284
  continue;
1200
1285
  }
1201
- const providerError = createClaudeProviderError({
1202
- message: resultErrorMessage
1203
- ? `Claude ${args.label} did not return a successful result${subtype ? ` (${subtype})` : ''}: ${resultErrorMessage}`
1204
- : `Claude ${args.label} did not return a successful result${subtype ? ` (${subtype})` : ''}`,
1205
- role: 'structured-advisor',
1206
- sessionHandle,
1207
- kind: getClaudeStructuredFailureKind(subtype, resultErrorMessage),
1286
+ throw await createClaudeAdvisorUnsuccessfulResultError({
1287
+ events: args.events,
1288
+ label: args.label,
1208
1289
  subtype,
1290
+ resultErrorMessage,
1291
+ sessionHandle,
1209
1292
  });
1210
- await emitClaudeProviderError(args.events, providerError, args.label);
1211
- throw providerError;
1212
1293
  }
1213
1294
  }
1295
+ async function runClaudeJsonBlockStructuredAdvisorRound(args, defaultModel, createStream = buildClaudeJsonBlockQueryStream, createRepairStream = buildClaudeJsonBlockRepairQueryStream, defaultEffort, sleep = defaultSleep) {
1296
+ return runClaudeStructuredAdvisorRoundLoop({ protocol: 'json-block', args, createStream, createRepairStream }, defaultModel, defaultEffort, sleep);
1297
+ }
1298
+ async function runClaudeStructuredAdvisorRound(args, defaultModel, createStream = buildClaudeQueryStream, defaultEffort, sleep = defaultSleep) {
1299
+ if (isClaudeJsonBlockStructuredAdvisorRound(args)) {
1300
+ return await runClaudeJsonBlockStructuredAdvisorRound(args, defaultModel, undefined, undefined, defaultEffort, sleep);
1301
+ }
1302
+ return runClaudeStructuredAdvisorRoundLoop({ protocol: 'native', args, createStream }, defaultModel, defaultEffort, sleep);
1303
+ }
1214
1304
  class AnthropicClaudeStructuredAdvisorAdapter {
1215
1305
  options;
1216
1306
  constructor(options = {}) {
@@ -1264,45 +1354,27 @@ function buildClaudeWritePathGuardHooks(cwd, allowedWritePaths) {
1264
1354
  return { PreToolUse: [{ hooks: [guard] }] };
1265
1355
  }
1266
1356
  function buildClaudeCoderQueryOptions(args, defaultModel, claudeExecutablePath = getClaudeCodeExecutablePath(), defaultEffort, abortController = deriveClaudeAbortController(args.signal)) {
1267
- return {
1357
+ return buildClaudeCoreQueryOptions({
1268
1358
  cwd: args.cwd,
1269
- ...(abortController ? { abortController } : {}),
1270
- ...(defaultModel ? { model: defaultModel } : {}),
1271
- ...(defaultEffort ? { effort: defaultEffort } : {}),
1359
+ abortController,
1360
+ model: defaultModel,
1361
+ effort: defaultEffort,
1272
1362
  // `toolPolicy.allowRun: false` (plan-authoring turns) removes Bash so the
1273
1363
  // "no shell" jail is enforced by tool absence, not prompt text.
1274
1364
  tools: args.toolPolicy?.allowRun === false
1275
1365
  ? ['Read', 'Grep', 'Glob', 'Edit', 'Write']
1276
1366
  : ['Read', 'Grep', 'Glob', 'Bash', 'Edit', 'Write'],
1277
- ...(args.toolPolicy?.allowedWritePaths
1278
- ? { hooks: buildClaudeWritePathGuardHooks(args.cwd, args.toolPolicy.allowedWritePaths) }
1279
- : {}),
1280
- permissionMode: 'bypassPermissions',
1281
- allowDangerouslySkipPermissions: true,
1282
- ...(args.resumeHandle ? { resume: args.resumeHandle } : {}),
1283
- ...(claudeExecutablePath ? { pathToClaudeCodeExecutable: claudeExecutablePath } : {}),
1284
- ...(args.outputSchema
1285
- ? {
1286
- outputFormat: {
1287
- type: 'json_schema',
1288
- schema: args.outputSchema,
1289
- },
1290
- }
1291
- : {}),
1292
- stderr: (data) => {
1293
- void args.events?.({
1294
- type: 'tool_progress',
1295
- provider: ANTHROPIC_CLAUDE_PROVIDER_ID,
1296
- role: 'coder',
1297
- label: 'coder',
1298
- sessionHandle: args.resumeHandle ?? null,
1299
- toolName: 'stderr',
1300
- message: data,
1301
- isError: true,
1302
- providerData: { stream: 'stderr' },
1303
- });
1304
- },
1305
- };
1367
+ hooks: args.toolPolicy?.allowedWritePaths
1368
+ ? buildClaudeWritePathGuardHooks(args.cwd, args.toolPolicy.allowedWritePaths)
1369
+ : undefined,
1370
+ resumeHandle: args.resumeHandle,
1371
+ claudeExecutablePath,
1372
+ outputSchema: args.outputSchema,
1373
+ events: args.events,
1374
+ stderrRole: 'coder',
1375
+ stderrLabel: 'coder',
1376
+ stderrSessionHandle: args.resumeHandle ?? null,
1377
+ });
1306
1378
  }
1307
1379
  function buildClaudeCoderQueryStream(args, defaultModel, defaultEffort, abortController) {
1308
1380
  return query({
@@ -1311,29 +1383,18 @@ function buildClaudeCoderQueryStream(args, defaultModel, defaultEffort, abortCon
1311
1383
  });
1312
1384
  }
1313
1385
  function buildClaudeCoderRepairQueryOptions(args, defaultModel, claudeExecutablePath = getClaudeCodeExecutablePath(), defaultEffort, abortController) {
1314
- return {
1386
+ return buildClaudeCoreQueryOptions({
1315
1387
  cwd: args.cwd,
1316
- ...(abortController ? { abortController } : {}),
1317
- ...(defaultModel ? { model: defaultModel } : {}),
1318
- ...(defaultEffort ? { effort: defaultEffort } : {}),
1388
+ abortController,
1389
+ model: defaultModel,
1390
+ effort: defaultEffort,
1319
1391
  tools: [],
1320
- permissionMode: 'bypassPermissions',
1321
- allowDangerouslySkipPermissions: true,
1322
- ...(claudeExecutablePath ? { pathToClaudeCodeExecutable: claudeExecutablePath } : {}),
1323
- stderr: (data) => {
1324
- void args.events?.({
1325
- type: 'tool_progress',
1326
- provider: ANTHROPIC_CLAUDE_PROVIDER_ID,
1327
- role: 'coder',
1328
- label: `${args.label}:structured-json-repair`,
1329
- sessionHandle: null,
1330
- toolName: 'stderr',
1331
- message: data,
1332
- isError: true,
1333
- providerData: { stream: 'stderr' },
1334
- });
1335
- },
1336
- };
1392
+ claudeExecutablePath,
1393
+ events: args.events,
1394
+ stderrRole: 'coder',
1395
+ stderrLabel: `${args.label}:structured-json-repair`,
1396
+ stderrSessionHandle: null,
1397
+ });
1337
1398
  }
1338
1399
  function buildClaudeCoderRepairQueryStream(args, repairPrompt, defaultModel, defaultEffort, abortController) {
1339
1400
  return query({