@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
@@ -92,10 +92,9 @@ export function detectBuiltInProviders(options = {}) {
92
92
  cwd: options.cwd ?? process.cwd(),
93
93
  resolveOpenAICompatibleSettings: options.resolveOpenAICompatibleSettings ?? ((cwd) => getOpenAICompatibleSettings(cwd, env)),
94
94
  };
95
- // generic-agentic reuses the openai-compatible settings surface
96
- // (providers.openai_compatible + env fallbacks), so both providers share
97
- // one resolved-settings check. Memoized so the settings resolver still
98
- // runs at most once per detection pass.
95
+ // openai-compatible resolves from the shared settings surface
96
+ // (providers.openai_compatible + env fallbacks). Memoized so the settings
97
+ // resolver still runs at most once per detection pass.
99
98
  let sharedOpenAICompatibleDetection;
100
99
  const detectFromSharedOpenAICompatibleSettings = () => {
101
100
  sharedOpenAICompatibleDetection ??= detectOpenAICompatible(resolvedOptions);
@@ -119,9 +118,6 @@ export function detectBuiltInProviders(options = {}) {
119
118
  case 'openai-compatible':
120
119
  detection = detectFromSharedOpenAICompatibleSettings();
121
120
  break;
122
- case 'generic-agentic':
123
- detection = detectFromSharedOpenAICompatibleSettings();
124
- break;
125
121
  default:
126
122
  detection = {
127
123
  runtimeAvailable: false,
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Git-config isolation for agent subprocesses.
3
+ *
4
+ * Agents (the coder especially) run shell commands with the operator's real
5
+ * environment, so `git config --global ...` writes the operator's actual
6
+ * `~/.gitconfig`. That has happened twice: a coder model hit git identity
7
+ * friction inside a throwaway worktree and "fixed" it globally, clobbering the
8
+ * operator's `[user]` block and breaking commit signing on their machine
9
+ * (issue #54).
10
+ *
11
+ * The defense is environmental, not behavioral: every agent subprocess gets
12
+ * `GIT_CONFIG_GLOBAL` pointed at a neal-owned scratch copy of the operator's
13
+ * global config, and `GIT_CONFIG_SYSTEM` pointed at the null device. Reads
14
+ * behave identically (the copy has the operator's settings, and `includeIf`
15
+ * paths inside it still resolve), agent commits keep the operator's identity
16
+ * and signing config, and a `--global` write succeeds from the agent's point
17
+ * of view — it just lands in the scratch copy instead of the operator's file.
18
+ *
19
+ * One scratch copy per process, created lazily: adapters are constructed per
20
+ * round, and the copy must be stable across rounds so an agent's own global
21
+ * writes persist for the life of the run.
22
+ */
23
+ import { copyFileSync, mkdtempSync, writeFileSync } from 'node:fs';
24
+ import { devNull, homedir, tmpdir } from 'node:os';
25
+ import { join } from 'node:path';
26
+ import process from 'node:process';
27
+ let cached = null;
28
+ /** Candidate sources for the operator's global config, in git's own precedence. */
29
+ function globalConfigSourcePaths() {
30
+ const fromEnv = process.env.GIT_CONFIG_GLOBAL;
31
+ if (fromEnv !== undefined && fromEnv !== '') {
32
+ return [fromEnv];
33
+ }
34
+ const xdgBase = process.env.XDG_CONFIG_HOME !== undefined && process.env.XDG_CONFIG_HOME !== ''
35
+ ? process.env.XDG_CONFIG_HOME
36
+ : join(homedir(), '.config');
37
+ // ~/.gitconfig wins over the XDG file when both exist, matching git.
38
+ return [join(homedir(), '.gitconfig'), join(xdgBase, 'git', 'config')];
39
+ }
40
+ export function gitConfigIsolationEnv() {
41
+ if (cached !== null) {
42
+ return cached;
43
+ }
44
+ const dir = mkdtempSync(join(tmpdir(), 'neal-git-config-'));
45
+ const scratch = join(dir, 'gitconfig');
46
+ let copied = false;
47
+ for (const source of globalConfigSourcePaths()) {
48
+ try {
49
+ copyFileSync(source, scratch);
50
+ copied = true;
51
+ break;
52
+ }
53
+ catch {
54
+ // Missing source — try the next candidate.
55
+ }
56
+ }
57
+ if (!copied) {
58
+ // No global config on this machine; give agents an empty one to write to.
59
+ writeFileSync(scratch, '');
60
+ }
61
+ cached = { GIT_CONFIG_GLOBAL: scratch, GIT_CONFIG_SYSTEM: devNull };
62
+ return cached;
63
+ }
64
+ /**
65
+ * The full environment for an agent subprocess whose SDK replaces (rather
66
+ * than merges with) the parent environment when `env` is provided.
67
+ * `undefined` entries are dropped because those SDKs type env as
68
+ * `Record<string, string>`.
69
+ */
70
+ export function agentSubprocessEnv() {
71
+ const env = {};
72
+ for (const [key, value] of Object.entries(process.env)) {
73
+ if (value !== undefined) {
74
+ env[key] = value;
75
+ }
76
+ }
77
+ return { ...env, ...gitConfigIsolationEnv() };
78
+ }
79
+ // Test-only: drop the process-level cache so a test can point the source at a
80
+ // fixture file. Mirrors disableAgentSettingsIsolation's teardown role.
81
+ export function resetGitConfigIsolationForTests() {
82
+ cached = null;
83
+ }
@@ -1,6 +1,9 @@
1
1
  import { Codex } from '@openai/codex-sdk';
2
2
  import { runStructuredJsonProtocol } from '../agents/structured-json.js';
3
- import { NealProviderError } from './types.js';
3
+ import { agentSettingsIsolated } from './agent-settings-isolation.js';
4
+ import { agentSubprocessEnv } from './git-config-isolation.js';
5
+ import { resolveRateCost } from './pricing.js';
6
+ import { isContentSafetyRefusalMessage, NealProviderError } from './types.js';
4
7
  const OPENAI_CODEX_PROVIDER_ID = 'openai-codex';
5
8
  class CodexInactivityTimeoutError extends Error {
6
9
  constructor(timeoutMs) {
@@ -10,7 +13,7 @@ class CodexInactivityTimeoutError extends Error {
10
13
  }
11
14
  // In-round transient (`api_error`) retries back off exponentially: 500 ms
12
15
  // base, doubling per retry, capped at 5 s — the same schedule as the
13
- // generic-agentic adapter. `sleep` is injectable through the adapter options
16
+ // openai-compatible adapter. `sleep` is injectable through the adapter options
14
17
  // so tests observe the delays without wall-clock waiting.
15
18
  const API_RETRY_BASE_DELAY_MS = 500;
16
19
  const API_RETRY_MAX_DELAY_MS = 5_000;
@@ -67,6 +70,12 @@ function inferCodexErrorKind(error, message, fallback) {
67
70
  if (error instanceof CodexInactivityTimeoutError || /\btimed out after\b/i.test(message)) {
68
71
  return 'timeout';
69
72
  }
73
+ // Placed before the permission check so a content-safety refusal is never
74
+ // misread as permission_denied when OpenAI phrases it with the word
75
+ // "authorized".
76
+ if (isContentSafetyRefusalMessage(message)) {
77
+ return 'content_refused';
78
+ }
70
79
  if (text.includes('permission') || text.includes('denied') || text.includes('forbidden') || text.includes('not authorized')) {
71
80
  return 'permission_denied';
72
81
  }
@@ -228,7 +237,21 @@ function buildCodexThreadOptions(args) {
228
237
  };
229
238
  }
230
239
  function createCodexThread(args) {
231
- const codex = new Codex();
240
+ // Under compat qualification only, isolate from the user's interactive
241
+ // `notify` hook in ~/.codex/config.toml: compat runs the whole role matrix
242
+ // through Codex, and left alone the hook fires a desktop notification per
243
+ // planner/coder/reviewer turn. Override `notify` to empty (a `--config
244
+ // notify=[]` pass to the CLI) for that case. Normal neal runs (pipeline,
245
+ // local `neal go`) honor the user's config. See agent-settings-isolation.ts.
246
+ //
247
+ // `env` is always passed (the SDK stops inheriting process.env once env is
248
+ // provided, so it is the full environment plus the git-config isolation
249
+ // overrides): the Codex CLI and every shell it runs must not be able to
250
+ // write the operator's real global gitconfig. See git-config-isolation.ts.
251
+ const env = agentSubprocessEnv();
252
+ const codex = agentSettingsIsolated()
253
+ ? new Codex({ config: { notify: [] }, env })
254
+ : new Codex({ env });
232
255
  const threadOptions = buildCodexThreadOptions({
233
256
  cwd: args.cwd,
234
257
  model: args.model,
@@ -269,7 +292,17 @@ async function emitCodexStreamError(args) {
269
292
  });
270
293
  return true;
271
294
  }
272
- async function consumeCodexTurn(turn, cwd, inactivityTimeoutMs, events, onSessionStarted, abortController) {
295
+ // Shared turn consumer behind `consumeCodexTurn` (coder) and
296
+ // `consumeCodexAdvisorTurn` (structured advisor). The two paths differ only
297
+ // in the role stamped on emitted events and normalized errors, the
298
+ // advisor-only `label` stamp, and the coder-only `onSessionStarted`
299
+ // callback; everything else is identical.
300
+ async function consumeCodexTurnWithOptions(turn, cwd, inactivityTimeoutMs, options) {
301
+ const { role, label, events, onSessionStarted, abortController, model } = options;
302
+ // The advisor path stamps `label` on every emitted event while the coder
303
+ // path emits events without a `label` property at all — the conditional
304
+ // spread preserves that own-property distinction exactly.
305
+ const labelFields = label === undefined ? {} : { label };
273
306
  let finalResponse = '';
274
307
  let observedFailure = null;
275
308
  let sessionHandle = null;
@@ -282,13 +315,13 @@ async function consumeCodexTurn(turn, cwd, inactivityTimeoutMs, events, onSessio
282
315
  catch (error) {
283
316
  if (observedFailure) {
284
317
  throw createCodexProviderErrorFromObservedFailure(observedFailure, {
285
- role: 'coder',
318
+ role,
286
319
  sessionHandle,
287
320
  cause: error,
288
321
  });
289
322
  }
290
323
  throw normalizeCodexProviderError(error, {
291
- role: 'coder',
324
+ role,
292
325
  sessionHandle,
293
326
  });
294
327
  }
@@ -302,7 +335,8 @@ async function consumeCodexTurn(turn, cwd, inactivityTimeoutMs, events, onSessio
302
335
  await emitProviderEvent(events, {
303
336
  type: 'session_started',
304
337
  provider: OPENAI_CODEX_PROVIDER_ID,
305
- role: 'coder',
338
+ role,
339
+ ...labelFields,
306
340
  sessionHandle: event.thread_id,
307
341
  providerData: { sdkEventType: event.type },
308
342
  });
@@ -312,7 +346,8 @@ async function consumeCodexTurn(turn, cwd, inactivityTimeoutMs, events, onSessio
312
346
  await emitProviderEvent(events, {
313
347
  type: 'turn_started',
314
348
  provider: OPENAI_CODEX_PROVIDER_ID,
315
- role: 'coder',
349
+ role,
350
+ ...labelFields,
316
351
  sessionHandle,
317
352
  providerData: { sdkEventType: event.type },
318
353
  });
@@ -321,7 +356,8 @@ async function consumeCodexTurn(turn, cwd, inactivityTimeoutMs, events, onSessio
321
356
  await emitProviderEvent(events, {
322
357
  type: 'tool_started',
323
358
  provider: OPENAI_CODEX_PROVIDER_ID,
324
- role: 'coder',
359
+ role,
360
+ ...labelFields,
325
361
  sessionHandle,
326
362
  itemId: summarizeItem(event.item).itemId,
327
363
  toolName: event.item.type,
@@ -332,7 +368,8 @@ async function consumeCodexTurn(turn, cwd, inactivityTimeoutMs, events, onSessio
332
368
  await emitProviderEvent(events, {
333
369
  type: 'tool_progress',
334
370
  provider: OPENAI_CODEX_PROVIDER_ID,
335
- role: 'coder',
371
+ role,
372
+ ...labelFields,
336
373
  sessionHandle,
337
374
  itemId: summarizeItem(event.item).itemId,
338
375
  toolName: event.item.type,
@@ -344,7 +381,8 @@ async function consumeCodexTurn(turn, cwd, inactivityTimeoutMs, events, onSessio
344
381
  await emitProviderEvent(events, {
345
382
  type: 'command_completed',
346
383
  provider: OPENAI_CODEX_PROVIDER_ID,
347
- role: 'coder',
384
+ role,
385
+ ...labelFields,
348
386
  sessionHandle,
349
387
  itemId: event.item.id,
350
388
  command: event.item.command,
@@ -361,7 +399,8 @@ async function consumeCodexTurn(turn, cwd, inactivityTimeoutMs, events, onSessio
361
399
  await emitProviderEvent(events, {
362
400
  type: 'file_changed',
363
401
  provider: OPENAI_CODEX_PROVIDER_ID,
364
- role: 'coder',
402
+ role,
403
+ ...labelFields,
365
404
  sessionHandle,
366
405
  files,
367
406
  providerData: { sdkEventType: event.type, item: summarizeItem(event.item) },
@@ -371,206 +410,41 @@ async function consumeCodexTurn(turn, cwd, inactivityTimeoutMs, events, onSessio
371
410
  finalResponse = event.item.text;
372
411
  }
373
412
  break;
374
- case 'turn.completed':
413
+ case 'turn.completed': {
414
+ // Card-derived cost only when the turn actually carried usage. Codex
415
+ // advertises opportunistic usage reporting, and `computeRateCostUsd`
416
+ // returns 0 for absent usage, so gating on `event.usage !== undefined`
417
+ // (matching the usage_reported emit) keeps a usage-less turn's
418
+ // tokens-only shape byte-stable rather than attaching costUsd: 0. Codex
419
+ // has no config pricing, so cost comes from the card via the configured
420
+ // model only.
421
+ const cost = event.usage !== undefined
422
+ ? (resolveRateCost({ usage: event.usage, model: model ?? null, configPricing: null }) ?? {})
423
+ : {};
375
424
  await emitProviderEvent(events, {
376
425
  type: 'turn_completed',
377
426
  provider: OPENAI_CODEX_PROVIDER_ID,
378
- role: 'coder',
427
+ role,
428
+ ...labelFields,
379
429
  sessionHandle,
380
430
  usage: event.usage,
431
+ ...cost,
381
432
  providerData: { sdkEventType: event.type },
382
433
  });
383
434
  if (event.usage !== undefined) {
384
435
  await emitProviderEvent(events, {
385
436
  type: 'usage_reported',
386
437
  provider: OPENAI_CODEX_PROVIDER_ID,
387
- role: 'coder',
438
+ role,
439
+ ...labelFields,
388
440
  sessionHandle,
389
441
  usage: event.usage,
442
+ ...cost,
390
443
  providerData: { sdkEventType: event.type },
391
444
  });
392
445
  }
393
446
  break;
394
- case 'turn.failed':
395
- {
396
- const nextFailure = observeCodexFailure({
397
- message: event.error.message,
398
- source: event.type,
399
- cause: event.error,
400
- });
401
- if (shouldReplaceCodexFailure(observedFailure, nextFailure)) {
402
- observedFailure = nextFailure;
403
- await emitProviderEvent(events, {
404
- type: 'provider_error',
405
- provider: OPENAI_CODEX_PROVIDER_ID,
406
- role: 'coder',
407
- sessionHandle,
408
- message: event.error.message,
409
- providerData: { sdkEventType: event.type },
410
- });
411
- }
412
- }
413
- break;
414
- case 'error':
415
- {
416
- const nextFailure = observeCodexFailure({
417
- message: event.message,
418
- source: event.type,
419
- cause: event,
420
- });
421
- if (shouldReplaceCodexFailure(observedFailure, nextFailure)) {
422
- if (await emitCodexStreamError({
423
- events,
424
- role: 'coder',
425
- sessionHandle,
426
- message: event.message,
427
- })) {
428
- observedFailure = nextFailure;
429
- }
430
- }
431
- }
432
- break;
433
- default:
434
- break;
435
- }
436
- }
437
- if (observedFailure) {
438
- throw createCodexProviderErrorFromObservedFailure(observedFailure, {
439
- role: 'coder',
440
- sessionHandle,
441
- });
442
- }
443
- return { finalResponse, sessionHandle };
444
- }
445
- async function consumeCodexAdvisorTurn(turn, cwd, label, inactivityTimeoutMs, events, abortController) {
446
- let finalResponse = '';
447
- let observedFailure = null;
448
- let sessionHandle = null;
449
- const iterator = turn.events[Symbol.asyncIterator]();
450
- while (true) {
451
- let next;
452
- try {
453
- next = await nextWithTimeout(iterator.next(), inactivityTimeoutMs, () => abortController?.abort());
454
- }
455
- catch (error) {
456
- if (observedFailure) {
457
- throw createCodexProviderErrorFromObservedFailure(observedFailure, {
458
- role: 'structured-advisor',
459
- sessionHandle,
460
- cause: error,
461
- });
462
447
  }
463
- throw normalizeCodexProviderError(error, {
464
- role: 'structured-advisor',
465
- sessionHandle,
466
- });
467
- }
468
- if (next.done) {
469
- break;
470
- }
471
- const event = next.value;
472
- switch (event.type) {
473
- case 'thread.started':
474
- sessionHandle = event.thread_id;
475
- await emitProviderEvent(events, {
476
- type: 'session_started',
477
- provider: OPENAI_CODEX_PROVIDER_ID,
478
- role: 'structured-advisor',
479
- label,
480
- sessionHandle: event.thread_id,
481
- providerData: { sdkEventType: event.type },
482
- });
483
- break;
484
- case 'turn.started':
485
- await emitProviderEvent(events, {
486
- type: 'turn_started',
487
- provider: OPENAI_CODEX_PROVIDER_ID,
488
- role: 'structured-advisor',
489
- label,
490
- sessionHandle,
491
- providerData: { sdkEventType: event.type },
492
- });
493
- break;
494
- case 'item.started':
495
- await emitProviderEvent(events, {
496
- type: 'tool_started',
497
- provider: OPENAI_CODEX_PROVIDER_ID,
498
- role: 'structured-advisor',
499
- label,
500
- sessionHandle,
501
- itemId: summarizeItem(event.item).itemId,
502
- toolName: event.item.type,
503
- providerData: { sdkEventType: event.type, item: summarizeItem(event.item) },
504
- });
505
- break;
506
- case 'item.updated':
507
- await emitProviderEvent(events, {
508
- type: 'tool_progress',
509
- provider: OPENAI_CODEX_PROVIDER_ID,
510
- role: 'structured-advisor',
511
- label,
512
- sessionHandle,
513
- itemId: summarizeItem(event.item).itemId,
514
- toolName: event.item.type,
515
- providerData: { sdkEventType: event.type, item: summarizeItem(event.item) },
516
- });
517
- break;
518
- case 'item.completed':
519
- if (event.item.type === 'command_execution') {
520
- await emitProviderEvent(events, {
521
- type: 'command_completed',
522
- provider: OPENAI_CODEX_PROVIDER_ID,
523
- role: 'structured-advisor',
524
- label,
525
- sessionHandle,
526
- itemId: event.item.id,
527
- command: event.item.command,
528
- status: event.item.status,
529
- exitCode: event.item.exit_code ?? null,
530
- output: event.item.aggregated_output,
531
- outputLength: event.item.aggregated_output.length,
532
- cwd,
533
- providerData: { sdkEventType: event.type, item: summarizeItem(event.item) },
534
- });
535
- }
536
- else if (event.item.type === 'file_change' && event.item.changes.length > 0) {
537
- const files = event.item.changes.map((change) => change.path);
538
- await emitProviderEvent(events, {
539
- type: 'file_changed',
540
- provider: OPENAI_CODEX_PROVIDER_ID,
541
- role: 'structured-advisor',
542
- label,
543
- sessionHandle,
544
- files,
545
- providerData: { sdkEventType: event.type, item: summarizeItem(event.item) },
546
- });
547
- }
548
- else if (event.item.type === 'agent_message') {
549
- finalResponse = event.item.text;
550
- }
551
- break;
552
- case 'turn.completed':
553
- await emitProviderEvent(events, {
554
- type: 'turn_completed',
555
- provider: OPENAI_CODEX_PROVIDER_ID,
556
- role: 'structured-advisor',
557
- label,
558
- sessionHandle,
559
- usage: event.usage,
560
- providerData: { sdkEventType: event.type },
561
- });
562
- if (event.usage !== undefined) {
563
- await emitProviderEvent(events, {
564
- type: 'usage_reported',
565
- provider: OPENAI_CODEX_PROVIDER_ID,
566
- role: 'structured-advisor',
567
- label,
568
- sessionHandle,
569
- usage: event.usage,
570
- providerData: { sdkEventType: event.type },
571
- });
572
- }
573
- break;
574
448
  case 'turn.failed':
575
449
  {
576
450
  const nextFailure = observeCodexFailure({
@@ -583,8 +457,8 @@ async function consumeCodexAdvisorTurn(turn, cwd, label, inactivityTimeoutMs, ev
583
457
  await emitProviderEvent(events, {
584
458
  type: 'provider_error',
585
459
  provider: OPENAI_CODEX_PROVIDER_ID,
586
- role: 'structured-advisor',
587
- label,
460
+ role,
461
+ ...labelFields,
588
462
  sessionHandle,
589
463
  message: event.error.message,
590
464
  providerData: { sdkEventType: event.type },
@@ -602,7 +476,7 @@ async function consumeCodexAdvisorTurn(turn, cwd, label, inactivityTimeoutMs, ev
602
476
  if (shouldReplaceCodexFailure(observedFailure, nextFailure)) {
603
477
  if (await emitCodexStreamError({
604
478
  events,
605
- role: 'structured-advisor',
479
+ role,
606
480
  label,
607
481
  sessionHandle,
608
482
  message: event.message,
@@ -618,12 +492,36 @@ async function consumeCodexAdvisorTurn(turn, cwd, label, inactivityTimeoutMs, ev
618
492
  }
619
493
  if (observedFailure) {
620
494
  throw createCodexProviderErrorFromObservedFailure(observedFailure, {
621
- role: 'structured-advisor',
495
+ role,
622
496
  sessionHandle,
623
497
  });
624
498
  }
625
499
  return { finalResponse, sessionHandle };
626
500
  }
501
+ async function consumeCodexTurn(turn, cwd, inactivityTimeoutMs, events, onSessionStarted, abortController,
502
+ // Appended last so existing positional callers (including test hooks) stay
503
+ // valid; forwards the configured slug for card-derived cost.
504
+ model) {
505
+ return consumeCodexTurnWithOptions(turn, cwd, inactivityTimeoutMs, {
506
+ role: 'coder',
507
+ events,
508
+ onSessionStarted,
509
+ abortController,
510
+ model,
511
+ });
512
+ }
513
+ async function consumeCodexAdvisorTurn(turn, cwd, label, inactivityTimeoutMs, events, abortController,
514
+ // Appended last so existing positional callers (including test hooks) stay
515
+ // valid; forwards the configured slug for card-derived cost.
516
+ model) {
517
+ return consumeCodexTurnWithOptions(turn, cwd, inactivityTimeoutMs, {
518
+ role: 'structured-advisor',
519
+ label,
520
+ events,
521
+ abortController,
522
+ model,
523
+ });
524
+ }
627
525
  function parseCodexStructuredOutput(finalResponse, label, sessionHandle) {
628
526
  try {
629
527
  return JSON.parse(finalResponse);
@@ -685,7 +583,7 @@ class OpenAICodexCoderAdapter {
685
583
  ...(args.outputSchema ? { outputSchema: args.outputSchema } : {}),
686
584
  signal: abortController.signal,
687
585
  });
688
- const result = await consumeCodexTurn(streamedTurn, args.cwd, args.inactivityTimeoutMs, args.events, args.onSessionStarted, abortController);
586
+ const result = await consumeCodexTurn(streamedTurn, args.cwd, args.inactivityTimeoutMs, args.events, args.onSessionStarted, abortController, this.options.model ?? null);
689
587
  if (args.outputSchema) {
690
588
  await emitProviderEvent(args.events, {
691
589
  type: 'structured_output_received',
@@ -745,7 +643,7 @@ class OpenAICodexCoderAdapter {
745
643
  const streamedTurn = await thread.runStreamed(prompt, {
746
644
  signal: abortController.signal,
747
645
  });
748
- const result = await consumeCodexTurn(streamedTurn, args.cwd, args.inactivityTimeoutMs, args.events, args.onSessionStarted, abortController);
646
+ const result = await consumeCodexTurn(streamedTurn, args.cwd, args.inactivityTimeoutMs, args.events, args.onSessionStarted, abortController, this.options.model ?? null);
749
647
  return {
750
648
  assistantText: result.finalResponse,
751
649
  sessionHandle: thread.id,
@@ -767,7 +665,7 @@ class OpenAICodexCoderAdapter {
767
665
  const streamedTurn = await repairThread.runStreamed(prompt, {
768
666
  signal: repairAbortController.signal,
769
667
  });
770
- const result = await consumeCodexTurn(streamedTurn, args.cwd, args.inactivityTimeoutMs, args.events, undefined, repairAbortController);
668
+ const result = await consumeCodexTurn(streamedTurn, args.cwd, args.inactivityTimeoutMs, args.events, undefined, repairAbortController, this.options.model ?? null);
771
669
  return {
772
670
  assistantText: result.finalResponse,
773
671
  sessionHandle: repairThread.id,
@@ -865,7 +763,7 @@ class OpenAICodexStructuredAdvisorAdapter {
865
763
  const streamedTurn = await thread.runStreamed(prompt, {
866
764
  signal: abortController.signal,
867
765
  });
868
- const result = await consumeCodexAdvisorTurn(streamedTurn, args.cwd, args.label, args.inactivityTimeoutMs, args.events, abortController);
766
+ const result = await consumeCodexAdvisorTurn(streamedTurn, args.cwd, args.label, args.inactivityTimeoutMs, args.events, abortController, this.options.model ?? null);
869
767
  return {
870
768
  assistantText: result.finalResponse,
871
769
  sessionHandle: thread.id ?? result.sessionHandle ?? null,
@@ -883,7 +781,7 @@ class OpenAICodexStructuredAdvisorAdapter {
883
781
  const streamedTurn = await repairThread.runStreamed(prompt, {
884
782
  signal: repairAbortController.signal,
885
783
  });
886
- const result = await consumeCodexAdvisorTurn(streamedTurn, args.cwd, args.label, args.inactivityTimeoutMs, args.events, repairAbortController);
784
+ const result = await consumeCodexAdvisorTurn(streamedTurn, args.cwd, args.label, args.inactivityTimeoutMs, args.events, repairAbortController, this.options.model ?? null);
887
785
  return {
888
786
  assistantText: result.finalResponse,
889
787
  sessionHandle: repairThread.id ?? result.sessionHandle ?? null,
@@ -900,7 +798,7 @@ class OpenAICodexStructuredAdvisorAdapter {
900
798
  });
901
799
  }
902
800
  const streamedTurn = await thread.runStreamed(args.prompt, buildCodexStructuredAdvisorRunOptions(args, abortController.signal));
903
- const result = await consumeCodexAdvisorTurn(streamedTurn, args.cwd, args.label, args.inactivityTimeoutMs, args.events, abortController);
801
+ const result = await consumeCodexAdvisorTurn(streamedTurn, args.cwd, args.label, args.inactivityTimeoutMs, args.events, abortController, this.options.model ?? null);
904
802
  const structured = parseCodexStructuredOutput(result.finalResponse, args.label, thread.id);
905
803
  await emitProviderEvent(args.events, {
906
804
  type: 'structured_output_received',
@@ -1,8 +1,6 @@
1
1
  /**
2
- * Generic-agentic tool layer: the six neal-defined AI SDK tools, jailed to a
3
- * root directory. Ported from `spike/generic-agentic/src/tools.ts` (copied,
4
- * never imported — the spike stays a frozen historical reference) with the
5
- * Phase 1 hardening from the integration plan:
2
+ * openai-compatible provider tool layer: the six neal-defined AI SDK tools,
3
+ * jailed to a root directory. Key mechanics:
6
4
  *
7
5
  * - an event-emission hook so tool execution surfaces the existing provider
8
6
  * event grammar (`tool_started`, `tool_progress`, `command_completed`,
@@ -42,6 +40,7 @@ import { promises as fs } from 'node:fs';
42
40
  import path from 'node:path';
43
41
  import { tool } from 'ai';
44
42
  import { z } from 'zod';
43
+ import { gitConfigIsolationEnv } from './git-config-isolation.js';
45
44
  export const RESULT_BYTE_LIMIT = 16 * 1024;
46
45
  export const READ_FILE_BYTE_LIMIT = 256 * 1024;
47
46
  export const GREP_MAX_MATCHING_LINES = 200;
@@ -386,7 +385,14 @@ function gitDiffBody(rootDir, base, head, relativePath, stat) {
386
385
  }
387
386
  function runBody(rootDir, command, timeoutMs) {
388
387
  return new Promise((resolve) => {
389
- exec(command, { cwd: path.resolve(rootDir), timeout: timeoutMs, maxBuffer: 10 * 1024 * 1024 }, (error, stdout, stderr) => {
388
+ exec(command, {
389
+ cwd: path.resolve(rootDir),
390
+ // Agent-issued shell commands must not be able to write the operator's
391
+ // real global gitconfig. See git-config-isolation.ts.
392
+ env: { ...process.env, ...gitConfigIsolationEnv() },
393
+ timeout: timeoutMs,
394
+ maxBuffer: 10 * 1024 * 1024,
395
+ }, (error, stdout, stderr) => {
390
396
  const timedOut = Boolean(error && error.killed);
391
397
  let exitCode;
392
398
  if (error) {