@navels/neal 0.2.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 (114) 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 +230 -38
  7. package/dist/neal/agents/prompts.js +6 -17
  8. package/dist/neal/agents/rounds.js +43 -73
  9. package/dist/neal/agents/schemas.js +47 -13
  10. package/dist/neal/blocked-guidance.js +16 -9
  11. package/dist/neal/cli.js +2 -2
  12. package/dist/neal/commands/check.js +2 -2
  13. package/dist/neal/commands/compat.js +100 -36
  14. package/dist/neal/commands/interactive-activity.js +2 -2
  15. package/dist/neal/commands/recovery-guidance.js +20 -8
  16. package/dist/neal/commands/runtime.js +4 -2
  17. package/dist/neal/config.js +16 -7
  18. package/dist/neal/context/inline-review-context.js +13 -28
  19. package/dist/neal/context/reviewer-context.js +19 -14
  20. package/dist/neal/eval/reviewer-eval-manifest.js +86 -0
  21. package/dist/neal/eval/reviewer-eval.js +155 -0
  22. package/dist/neal/interactive-controls.js +5 -0
  23. package/dist/neal/orchestrator/completion.js +49 -2
  24. package/dist/neal/orchestrator/failures.js +7 -0
  25. package/dist/neal/orchestrator/notifications.js +9 -9
  26. package/dist/neal/orchestrator/phases/planning.js +117 -80
  27. package/dist/neal/orchestrator/phases/recovery.js +151 -115
  28. package/dist/neal/orchestrator/phases/review.js +28 -1
  29. package/dist/neal/orchestrator/transitions.js +7 -1
  30. package/dist/neal/orchestrator.js +3 -0
  31. package/dist/neal/plan-queue.js +29 -1
  32. package/dist/neal/prompts/execute.js +7 -19
  33. package/dist/neal/prompts/planning.js +64 -55
  34. package/dist/neal/prompts/review-doctrine.js +13 -50
  35. package/dist/neal/prompts/specialized.js +7 -17
  36. package/dist/neal/prompts/specs.js +180 -32
  37. package/dist/neal/providers/agent-settings-isolation.js +35 -0
  38. package/dist/neal/providers/anthropic-claude.js +18 -1
  39. package/dist/neal/providers/detection.js +3 -7
  40. package/dist/neal/providers/git-config-isolation.js +83 -0
  41. package/dist/neal/providers/openai-codex.js +25 -3
  42. package/dist/neal/providers/{generic-agentic-tools.js → openai-compatible-tools.js} +11 -5
  43. package/dist/neal/providers/openai-compatible.js +1078 -430
  44. package/dist/neal/providers/openrouter-routing.js +68 -0
  45. package/dist/neal/providers/pricing.js +1 -1
  46. package/dist/neal/providers/rate-card.js +6 -32
  47. package/dist/neal/providers/registry.js +6 -17
  48. package/dist/neal/providers/types.js +12 -0
  49. package/dist/neal/resume-decision.js +7 -6
  50. package/dist/neal/resume-planner.js +9 -0
  51. package/dist/neal/retrospective.js +3 -3
  52. package/dist/neal/review-debt.js +30 -0
  53. package/dist/neal/review-findings/provider.js +17 -29
  54. package/dist/neal/review.js +17 -1
  55. package/dist/neal/state-invariants.js +11 -3
  56. package/dist/neal/state-views.js +27 -5
  57. package/dist/neal/state.js +59 -15
  58. package/dist/neal/status.js +28 -1
  59. package/dist/neal/support.js +3 -3
  60. package/docs/README.md +22 -18
  61. package/docs/{ADJUDICATOR_INVENTORY.md → adjudicator-inventory.md} +58 -57
  62. package/docs/architecture.md +79 -83
  63. package/docs/automation.md +11 -11
  64. package/docs/compat.md +91 -61
  65. package/docs/compatible-models.md +145 -121
  66. package/docs/demo.md +15 -5
  67. package/docs/maintenance.md +65 -34
  68. package/docs/plan-format.md +61 -33
  69. package/docs/prompt-evals.md +96 -0
  70. package/docs/{PROMPT_SPECS.md → prompt-specs.md} +52 -48
  71. package/docs/providers.md +279 -349
  72. package/docs/release.md +18 -19
  73. package/docs/review-convergence.md +246 -0
  74. package/docs/state-machine.md +61 -59
  75. package/docs/storage.md +40 -31
  76. package/docs/troubleshooting.md +32 -29
  77. package/examples/compat/add-edit-verify/PLAN.md +2 -0
  78. package/examples/compat/add-edit-verify/broken.diff +2 -2
  79. package/examples/compat/add-edit-verify/good.diff +2 -2
  80. package/examples/compat/add-edit-verify/src/add.js +1 -1
  81. package/examples/compat/manifest.json +25 -29
  82. package/examples/compat/sum-grep-edit/PLAN.md +33 -0
  83. package/examples/compat/sum-grep-edit/broken.diff +12 -0
  84. package/examples/compat/sum-grep-edit/good.diff +12 -0
  85. package/examples/compat/sum-grep-edit/package.json +5 -0
  86. package/examples/compat/sum-grep-edit/src/numbers.js +9 -0
  87. package/examples/compat/sum-grep-edit/test/numbers.test.js +15 -0
  88. package/examples/issue-triage-js/README.md +0 -15
  89. package/examples/reviewer-eval/clean-doc/base/src/clamp.ts +3 -0
  90. package/examples/reviewer-eval/clean-doc/change.diff +9 -0
  91. package/examples/reviewer-eval/clean-extract/base/src/greet.ts +3 -0
  92. package/examples/reviewer-eval/clean-extract/change.diff +13 -0
  93. package/examples/reviewer-eval/dropped-error/base/src/load.ts +4 -0
  94. package/examples/reviewer-eval/dropped-error/change.diff +15 -0
  95. package/examples/reviewer-eval/manifest.json +45 -0
  96. package/examples/reviewer-eval/off-by-one/base/src/paginate.ts +5 -0
  97. package/examples/reviewer-eval/off-by-one/change.diff +11 -0
  98. package/{config.yml → neal.yml} +1 -1
  99. package/package.json +15 -14
  100. package/dist/neal/providers/generic-agentic.js +0 -1256
  101. package/docs/comparison.md +0 -105
  102. package/docs/issue-pipeline.md +0 -124
  103. package/examples/compat/is-even-add-test/PLAN.md +0 -30
  104. package/examples/compat/is-even-add-test/broken.diff +0 -11
  105. package/examples/compat/is-even-add-test/good.diff +0 -11
  106. package/examples/compat/is-even-add-test/package.json +0 -5
  107. package/examples/compat/is-even-add-test/src/is-even.js +0 -3
  108. package/examples/compat/is-even-add-test/test/is-even.test.js +0 -9
  109. package/examples/compat/reverse-grep-edit/PLAN.md +0 -32
  110. package/examples/compat/reverse-grep-edit/broken.diff +0 -12
  111. package/examples/compat/reverse-grep-edit/good.diff +0 -12
  112. package/examples/compat/reverse-grep-edit/package.json +0 -5
  113. package/examples/compat/reverse-grep-edit/src/strings.js +0 -10
  114. package/examples/compat/reverse-grep-edit/test/strings.test.js +0 -15
@@ -1,3 +1,4 @@
1
+ import { createHash } from 'node:crypto';
1
2
  function field(key, source, required, description) {
2
3
  return { key, source, required, description };
3
4
  }
@@ -36,8 +37,7 @@ const PLAN_REVIEWER_CONTEXT = context('PlanReviewerPromptContext', [
36
37
  field('parentPlanDoc', 'prompt_argument', false, 'Path to the parent plan when reviewing a derived plan.'),
37
38
  field('derivedFromScopeNumber', 'orchestrator_state', false, 'Parent scope number when reviewing a derived plan.'),
38
39
  field('repositoryState', 'repository_state', true, 'Current repository context and directly referenced companion docs.'),
39
- field('inlineContext', 'repository_state', false, "Neal-inlined plan document, parent plan, and review-history sections for reviewers without repository read access; only valid with the 'no-read' access mode."),
40
- field('accessMode', 'orchestrator_state', false, "Three-way reviewer doctrine access mode derived from the reviewer provider's structured-advisor tool access: 'tool-access' (inspect and execute), 'read-only' (read tools only; no command execution, test runs, or scratch work), or 'no-read' (judge entirely from Neal-inlined context). When absent the builder derives 'no-read' from inline-context presence, else 'tool-access'."),
40
+ field('accessMode', 'orchestrator_state', false, "Two-way reviewer doctrine access mode derived from the reviewer provider's structured-advisor tool access: 'tool-access' (inspect and execute) or 'read-only' (read tools only; no command execution, test runs, or scratch work). Defaults to 'tool-access' when absent."),
41
41
  ]);
42
42
  const SCOPE_CODER_CONTEXT = context('ScopeCoderPromptContext', [
43
43
  field('planDoc', 'prompt_argument', true, 'Path to the active execute-mode plan.'),
@@ -65,8 +65,7 @@ const SCOPE_REVIEWER_CONTEXT = context('ScopeReviewerPromptContext', [
65
65
  field('recentHistorySummary', 'review_history', true, 'Accepted-scope history for the active parent objective.'),
66
66
  field('reviewMarkdownPath', 'run_artifact', true, 'Review artifact that carries prior findings and coder responses.'),
67
67
  field('scratchDir', 'run_artifact', true, 'Run-local reviewer scratch directory for temporary verification artifacts.'),
68
- field('inlineContext', 'repository_state', false, "Neal-inlined full diff, plan document, and review-history sections for reviewers without repository read access; only valid with the 'no-read' access mode."),
69
- field('accessMode', 'orchestrator_state', false, "Three-way reviewer doctrine access mode derived from the reviewer provider's structured-advisor tool access: 'tool-access' (inspect and execute), 'read-only' (read tools only; no command execution, test runs, or scratch work), or 'no-read' (judge entirely from Neal-inlined context). When absent the builder derives 'no-read' from inline-context presence, else 'tool-access'."),
68
+ field('accessMode', 'orchestrator_state', false, "Two-way reviewer doctrine access mode derived from the reviewer provider's structured-advisor tool access: 'tool-access' (inspect and execute) or 'read-only' (read tools only; no command execution, test runs, or scratch work). Defaults to 'tool-access' when absent."),
70
69
  ]);
71
70
  const COMPLETION_CODER_CONTEXT = context('CompletionCoderPromptContext', [
72
71
  field('planDoc', 'prompt_argument', true, 'Path to the execute-mode plan being evaluated for final completion.'),
@@ -79,18 +78,32 @@ const COMPLETION_REVIEWER_CONTEXT = context('CompletionReviewerPromptContext', [
79
78
  field('summary', 'review_history', true, 'Coder-authored whole-plan completion summary under review.'),
80
79
  field('scratchDir', 'run_artifact', true, 'Run-local final-completion reviewer scratch directory for temporary verification artifacts.'),
81
80
  field('repositoryState', 'repository_state', true, 'Current repository state used to judge whole-plan completion.'),
82
- field('inlineContext', 'repository_state', false, "Neal-inlined aggregate diff (or evidence-gap statement) and plan-document sections for reviewers without repository read access; only valid with the 'no-read' access mode."),
83
- field('accessMode', 'orchestrator_state', false, "Three-way reviewer doctrine access mode derived from the reviewer provider's structured-advisor tool access: 'tool-access' (inspect and execute), 'read-only' (read tools only; no command execution, test runs, or scratch work), or 'no-read' (judge entirely from Neal-inlined context). When absent the builder derives 'no-read' from inline-context presence, else 'tool-access'."),
81
+ field('accessMode', 'orchestrator_state', false, "Two-way reviewer doctrine access mode derived from the reviewer provider's structured-advisor tool access: 'tool-access' (inspect and execute) or 'read-only' (read tools only; no command execution, test runs, or scratch work). Defaults to 'tool-access' when absent."),
84
82
  ]);
85
- const BLOCKED_ADJUDICATOR_CONTEXT = context('BlockedAdjudicatorPromptContext', [
83
+ const CONSULTANT_CONTEXT = context('ConsultantPromptContext', [
86
84
  field('blockedReason', 'prompt_argument', true, 'Blocked reason reported by the stalled coder or reviewer turn.'),
87
- field('inlineContext', 'repository_state', true, 'Neal-inlined adjudication context (plan content, open blocking findings, reviewer-round snapshots, or coder blocker plus changed files) the adjudicator judges entirely from.'),
85
+ field('inlineContext', 'repository_state', true, 'Neal-inlined adjudication context (plan content, open blocking findings, reviewer-round snapshots, or coder blocker plus changed files) the consultant judges entirely from.'),
88
86
  ]);
89
87
  export const PROMPT_SPECS = [
90
88
  {
91
89
  id: 'plan_author',
90
+ version: 3,
91
+ changelog: [
92
+ {
93
+ version: 1,
94
+ renderSha: '2e5600ef64885419b574cc0a04b684dc93f3efcc9ede65e4cacc8951a220d5cd',
95
+ },
96
+ {
97
+ version: 2,
98
+ renderSha: 'a9ebcafa81fa0f60812adcb7c7b68f999a6c45c1c7fa7b7a20911031d6b0fd9e',
99
+ },
100
+ {
101
+ version: 3,
102
+ renderSha: '51943007b2a334ee0c92d8314616fb882e9cea7b410b05c5aeda668c7c9e04dc',
103
+ },
104
+ ],
92
105
  role: 'coder',
93
- purpose: 'Author or revise Neal-executable plans without leaking planning-task scaffolding into final artifacts.',
106
+ purpose: 'Author or revise concise, human-reviewable Neal-executable plans at moderate-to-high-level implementation detail.',
94
107
  requiredContext: PLAN_AUTHOR_CONTEXT,
95
108
  schemaTarget: {
96
109
  kind: 'structured_json',
@@ -108,6 +121,8 @@ export const PROMPT_SPECS = [
108
121
  evaluationNotes: [
109
122
  'Render tests should assert the canonical Neal-executable execution-shape contract stays present.',
110
123
  'Fixture cases should cover single-scope vs multi-scope decisions and cleanup of planning-only scaffolding.',
124
+ 'Prompt tests should preserve repository grounding while rejecting exhaustive implementation inventories.',
125
+ 'Planner and response prompts should narrow self-imposed global verification guarantees unless they are necessary for the requested change to be correct.',
111
126
  ],
112
127
  firstMigrationPriority: 1,
113
128
  currentHome: 'src/neal/prompts',
@@ -160,8 +175,23 @@ export const PROMPT_SPECS = [
160
175
  },
161
176
  {
162
177
  id: 'plan_reviewer',
178
+ version: 3,
179
+ changelog: [
180
+ {
181
+ version: 1,
182
+ renderSha: 'c84e630d1463590eda463726a0529dab6277f1980a2c9c586a3fe3673e44d2ee',
183
+ },
184
+ {
185
+ version: 2,
186
+ renderSha: '43a6f77d767bb5a010203f37b2c1ac980f0d7c6e1eb104f038c33afcea97c658',
187
+ },
188
+ {
189
+ version: 3,
190
+ renderSha: 'fa0309ed0e394c690dfc3135da3c98a74dd5413ccb754fefa100171bf35b72b6',
191
+ },
192
+ ],
163
193
  role: 'reviewer',
164
- purpose: 'Review Neal-executable plans for execution-shape correctness, verification concreteness, and resume safety.',
194
+ purpose: 'Review human-reviewable Neal-executable plans for material approach, scope, verification, and resume-safety defects.',
165
195
  requiredContext: PLAN_REVIEWER_CONTEXT,
166
196
  schemaTarget: {
167
197
  kind: 'structured_json',
@@ -183,7 +213,8 @@ export const PROMPT_SPECS = [
183
213
  providerVariants: SHARED_PROVIDER_VARIANTS,
184
214
  evaluationNotes: [
185
215
  'Render tests should assert the reviewer prompt requires executionShape confirmation.',
186
- 'Render tests should assert shared adversarial plan-readiness posture and verification skepticism without adding implementation diff-review instructions.',
216
+ 'Render tests should assert independent material plan review without adding implementation diff-review instructions or exhaustive detail requirements.',
217
+ 'Reviewer tests should distinguish requested-change correctness from incomplete enforcement of broader guarantees introduced by the plan.',
187
218
  'Fixture cases should cover ordinary plans and derived plans.',
188
219
  ],
189
220
  firstMigrationPriority: 1,
@@ -206,8 +237,7 @@ export const PROMPT_SPECS = [
206
237
  field('planDoc', 'prompt_argument', true, 'Path to the plan artifact under review.'),
207
238
  field('round', 'orchestrator_state', true, 'Plan-review round number.'),
208
239
  field('reviewMarkdownPath', 'run_artifact', true, 'Review history artifact path.'),
209
- field('inlineContext', 'repository_state', false, "Optional Neal-inlined plan/review-history context; only valid with the 'no-read' access mode, whose prompt contains no repository-access instructions."),
210
- field('accessMode', 'orchestrator_state', false, "Optional explicit doctrine access mode ('tool-access', 'read-only', or 'no-read'); when absent the builder derives 'no-read' from inline-context presence, else 'tool-access'."),
240
+ field('accessMode', 'orchestrator_state', false, "Optional explicit doctrine access mode ('tool-access' or 'read-only'); defaults to 'tool-access' when absent."),
211
241
  ]),
212
242
  },
213
243
  schemaTarget: {
@@ -230,8 +260,7 @@ export const PROMPT_SPECS = [
230
260
  field('planDoc', 'prompt_argument', true, 'Path to the derived plan artifact under review.'),
231
261
  field('parentPlanDoc', 'prompt_argument', false, 'Path to the parent plan artifact.'),
232
262
  field('derivedFromScopeNumber', 'orchestrator_state', false, 'Parent scope number that the derived plan replaces.'),
233
- field('inlineContext', 'repository_state', false, "Optional Neal-inlined derived-plan/parent-plan/review-history context; only valid with the 'no-read' access mode, whose prompt contains no repository-access instructions."),
234
- field('accessMode', 'orchestrator_state', false, "Optional explicit doctrine access mode ('tool-access', 'read-only', or 'no-read'); when absent the builder derives 'no-read' from inline-context presence, else 'tool-access'."),
263
+ field('accessMode', 'orchestrator_state', false, "Optional explicit doctrine access mode ('tool-access' or 'read-only'); defaults to 'tool-access' when absent."),
235
264
  ]),
236
265
  },
237
266
  schemaTarget: {
@@ -245,6 +274,13 @@ export const PROMPT_SPECS = [
245
274
  },
246
275
  {
247
276
  id: 'scope_coder',
277
+ version: 1,
278
+ changelog: [
279
+ {
280
+ version: 1,
281
+ renderSha: 'c579d65db51be90fa81dbe3b7dd260083bc4960cc4cba32ccf2cd802d19703bc',
282
+ },
283
+ ],
248
284
  role: 'coder',
249
285
  purpose: 'Execute exactly one bounded implementation scope and respond to in-scope review feedback without starting new scopes.',
250
286
  requiredContext: SCOPE_CODER_CONTEXT,
@@ -347,6 +383,17 @@ export const PROMPT_SPECS = [
347
383
  },
348
384
  {
349
385
  id: 'scope_reviewer',
386
+ version: 2,
387
+ changelog: [
388
+ {
389
+ version: 1,
390
+ renderSha: '38a19da2b70dc10f6f4d8c4b955ece084bbe6e1291151de9c557dca194411386',
391
+ },
392
+ {
393
+ version: 2,
394
+ renderSha: '431a75ad341a531535606af607187239a31d12cf921575eb17774b317ad639a0',
395
+ },
396
+ ],
350
397
  role: 'reviewer',
351
398
  purpose: 'Review execute-scope results for correctness, verification coverage, and meaningful progress toward the active parent objective.',
352
399
  requiredContext: SCOPE_REVIEWER_CONTEXT,
@@ -400,8 +447,7 @@ export const PROMPT_SPECS = [
400
447
  field('reviewMarkdownPath', 'run_artifact', true, 'Review history artifact path.'),
401
448
  field('parentScopeLabel', 'orchestrator_state', true, 'Active parent objective label.'),
402
449
  field('scratchDir', 'run_artifact', true, 'Run-local scratch directory for reviewer verification artifacts.'),
403
- field('inlineContext', 'repository_state', false, "Optional Neal-inlined diff/plan/review-history context; only valid with the 'no-read' access mode, whose prompt contains no repository-access instructions."),
404
- field('accessMode', 'orchestrator_state', false, "Optional explicit doctrine access mode ('tool-access', 'read-only', or 'no-read'); when absent the builder derives 'no-read' from inline-context presence, else 'tool-access'."),
450
+ field('accessMode', 'orchestrator_state', false, "Optional explicit doctrine access mode ('tool-access' or 'read-only'); defaults to 'tool-access' when absent."),
405
451
  ]),
406
452
  },
407
453
  schemaTarget: {
@@ -436,6 +482,13 @@ export const PROMPT_SPECS = [
436
482
  },
437
483
  {
438
484
  id: 'completion_coder',
485
+ version: 1,
486
+ changelog: [
487
+ {
488
+ version: 1,
489
+ renderSha: 'f88b43d206de28212ad7f6f3ae84576718934b0a5e36953e35afee89c4d7ce5e',
490
+ },
491
+ ],
439
492
  role: 'coder',
440
493
  purpose: 'Summarize whole-plan completion state in compact structured JSON.',
441
494
  requiredContext: COMPLETION_CODER_CONTEXT,
@@ -489,6 +542,17 @@ export const PROMPT_SPECS = [
489
542
  },
490
543
  {
491
544
  id: 'completion_reviewer',
545
+ version: 2,
546
+ changelog: [
547
+ {
548
+ version: 1,
549
+ renderSha: 'e5d104d46899f01cf69db9d2e6d109aa32d0faf645d6d908aae443558900a131',
550
+ },
551
+ {
552
+ version: 2,
553
+ renderSha: 'cc06cea4a8de6366f07a6943718fc1ae219e4c9092a5cf52542fa9de0caa02da',
554
+ },
555
+ ],
492
556
  role: 'reviewer',
493
557
  purpose: 'Judge whole-plan completion and decide whether Neal should accept completion, continue execution, or block for operator input.',
494
558
  requiredContext: COMPLETION_REVIEWER_CONTEXT,
@@ -533,8 +597,7 @@ export const PROMPT_SPECS = [
533
597
  field('packet', 'orchestrator_state', true, 'Whole-plan completion packet.'),
534
598
  field('summary', 'review_history', true, 'Coder-authored completion summary.'),
535
599
  field('scratchDir', 'run_artifact', true, 'Run-local scratch directory for final-completion reviewer artifacts.'),
536
- field('inlineContext', 'repository_state', false, "Optional Neal-inlined aggregate-diff/plan context; only valid with the 'no-read' access mode, whose prompt contains no repository-access instructions."),
537
- field('accessMode', 'orchestrator_state', false, "Optional explicit doctrine access mode ('tool-access', 'read-only', or 'no-read'); when absent the builder derives 'no-read' from inline-context presence, else 'tool-access'."),
600
+ field('accessMode', 'orchestrator_state', false, "Optional explicit doctrine access mode ('tool-access' or 'read-only'); defaults to 'tool-access' when absent."),
538
601
  ]),
539
602
  },
540
603
  schemaTarget: {
@@ -547,60 +610,144 @@ export const PROMPT_SPECS = [
547
610
  ],
548
611
  },
549
612
  {
550
- id: 'blocked_adjudicator',
613
+ id: 'consultant',
614
+ version: 1,
615
+ changelog: [
616
+ {
617
+ version: 1,
618
+ renderSha: '42e41552be1e8cceb0596a26f09892e13cc31adf07e58479d939d289c8b469e3',
619
+ },
620
+ ],
551
621
  role: 'reviewer',
552
622
  purpose: 'Triage a blocked Neal run entirely from Neal-inlined context and decide whether the block is an in-scope recoverable misunderstanding or a genuine wall that must escalate to a human.',
553
- requiredContext: BLOCKED_ADJUDICATOR_CONTEXT,
623
+ requiredContext: CONSULTANT_CONTEXT,
554
624
  schemaTarget: {
555
625
  kind: 'structured_json',
556
- schemaBuilder: 'buildBlockedAdjudicatorSchema',
557
- parser: 'validateBlockedAdjudicatorVerdictPayload',
626
+ schemaBuilder: 'buildConsultantSchema',
627
+ parser: 'validateConsultantVerdictPayload',
558
628
  providerSurface: 'neal_json_block_protocol',
559
629
  },
560
630
  baseInstructions: {
561
631
  kind: 'builder',
562
632
  modulePath: 'src/neal/agents/prompts.ts',
563
- exportName: 'buildBlockedAdjudicatorPrompt',
564
- inputShape: context('BuildBlockedAdjudicatorPromptArgs', [
633
+ exportName: 'buildConsultantPrompt',
634
+ inputShape: context('BuildConsultantPromptArgs', [
565
635
  field('blockedReason', 'prompt_argument', true, 'Blocked reason string.'),
566
636
  field('inlineContext', 'repository_state', true, 'Neal-inlined adjudication context.'),
567
637
  ]),
568
638
  },
569
639
  providerVariants: SHARED_PROVIDER_VARIANTS,
570
640
  evaluationNotes: [
571
- 'Render tests should assert the adjudicator judges entirely from inlined context and its static instructions carry no repository-access phrasing.',
641
+ 'Render tests should assert the consultant judges entirely from inlined context and its static instructions carry no repository-access phrasing.',
572
642
  'A golden render test pins the exact prompt bytes, including the ALL-CAPS emphasis lines.',
573
643
  ],
574
644
  firstMigrationPriority: 3,
575
645
  currentHome: 'mixed',
576
646
  ownershipNotes: [
577
- 'Prompt spec owns the adjudicator instructions and required context only; anti-thrash guarding, recovery routing, and verdict persistence stay in src/neal/adjudicator/ outside the prompt-spec library.',
647
+ 'Prompt spec owns the consultant instructions and required context only; anti-thrash guarding, recovery routing, and verdict persistence stay in src/neal/adjudicator/ outside the prompt-spec library.',
578
648
  ],
579
649
  variants: [
580
650
  {
581
651
  kind: 'primary',
582
652
  status: 'adjacent',
583
653
  description: 'Read-only blocked-run adjudication round.',
584
- currentRoundEntrypoints: ['runBlockedAdjudicatorRound'],
654
+ currentRoundEntrypoints: ['runConsultantRound'],
585
655
  baseInstructions: {
586
656
  kind: 'builder',
587
657
  modulePath: 'src/neal/agents/prompts.ts',
588
- exportName: 'buildBlockedAdjudicatorPrompt',
589
- inputShape: context('BuildBlockedAdjudicatorPromptArgs', [
658
+ exportName: 'buildConsultantPrompt',
659
+ inputShape: context('BuildConsultantPromptArgs', [
590
660
  field('blockedReason', 'prompt_argument', true, 'Blocked reason string.'),
591
661
  field('inlineContext', 'repository_state', true, 'Neal-inlined adjudication context.'),
592
662
  ]),
593
663
  },
594
664
  schemaTarget: {
595
665
  kind: 'structured_json',
596
- schemaBuilder: 'buildBlockedAdjudicatorSchema',
597
- parser: 'validateBlockedAdjudicatorVerdictPayload',
666
+ schemaBuilder: 'buildConsultantSchema',
667
+ parser: 'validateConsultantVerdictPayload',
598
668
  providerSurface: 'neal_json_block_protocol',
599
669
  },
600
670
  },
601
671
  ],
602
672
  },
603
673
  ];
674
+ // SHA-256 of `text` as lowercase hex. Used to pin the render matrix (via the
675
+ // changelog `renderSha`) and, in the render-integrity test, the builder-module
676
+ // source tripwire.
677
+ export function sha256Hex(text) {
678
+ return createHash('sha256').update(text, 'utf8').digest('hex');
679
+ }
680
+ // Canonical, order-independent serialization of a render matrix. Each cell is a
681
+ // `{ key, render }` pair; cells are sorted by `key` and joined as
682
+ // `=== <key> ===\n<render>\n` blocks so the output is deterministic regardless
683
+ // of generation order and stays human-diffable in the versioned goldens.
684
+ export function serializeRenderMatrix(cells) {
685
+ return [...cells]
686
+ .sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0))
687
+ .map((cell) => `=== ${cell.key} ===\n${cell.render}\n`)
688
+ .join('');
689
+ }
690
+ // Structural validation of a spec's integer version and sha-anchored changelog.
691
+ // This is format/shape enforcement only; it does not compare the recorded
692
+ // `renderSha` against any live render (that is verifyRenderVersionContract's
693
+ // job) so it can run cheaply at module load for every spec.
694
+ export function validatePromptSpecVersioning(spec) {
695
+ const { id, version, changelog } = spec;
696
+ if (!Number.isSafeInteger(version) || version < 1) {
697
+ throw new Error(`Prompt spec ${id} version must be a safe integer >= 1, received ${String(version)}`);
698
+ }
699
+ if (changelog.length === 0) {
700
+ throw new Error(`Prompt spec ${id} changelog must not be empty`);
701
+ }
702
+ let previousVersion = 0;
703
+ for (const entry of changelog) {
704
+ if (!Number.isSafeInteger(entry.version) || entry.version < 1) {
705
+ throw new Error(`Prompt spec ${id} changelog entry version must be a safe integer >= 1, received ${String(entry.version)}`);
706
+ }
707
+ if (entry.version <= previousVersion) {
708
+ throw new Error(`Prompt spec ${id} changelog entry versions must be strictly increasing, received ${entry.version} after ${previousVersion}`);
709
+ }
710
+ previousVersion = entry.version;
711
+ if (!/^[0-9a-f]{64}$/.test(entry.renderSha)) {
712
+ throw new Error(`Prompt spec ${id} changelog entry renderSha must be 64-char lowercase hex, received ${entry.renderSha}`);
713
+ }
714
+ }
715
+ const last = changelog[changelog.length - 1];
716
+ if (last.version !== version) {
717
+ throw new Error(`Prompt spec ${id} last changelog entry version ${last.version} must equal spec version ${version}`);
718
+ }
719
+ }
720
+ // Internal-consistency and change-visibility contract over a spec's live render
721
+ // matrix and its versioned goldens. Throws when the last changelog entry does
722
+ // not match the spec version, when the live matrix sha does not match the last
723
+ // recorded `renderSha`, or when any changelog entry's golden is missing or does
724
+ // not hash to its recorded `renderSha`. This is not tamper evidence: an author
725
+ // who edits the live render, the golden, and the recorded sha together produces
726
+ // an internally consistent state that passes here; catching that coordinated
727
+ // same-version repin is the documented review/CI append-only rule, not this
728
+ // unit contract.
729
+ export function verifyRenderVersionContract(args) {
730
+ const { specId, version, changelog, liveMatrix, readGolden } = args;
731
+ const last = changelog[changelog.length - 1];
732
+ if (!last || last.version !== version) {
733
+ throw new Error(`Prompt spec ${specId} last changelog entry version ${String(last?.version)} must equal spec version ${version}`);
734
+ }
735
+ const liveSha = sha256Hex(liveMatrix);
736
+ if (liveSha !== last.renderSha) {
737
+ throw new Error(`Prompt spec ${specId} live render matrix sha ${liveSha} does not match recorded renderSha ${last.renderSha} for version ${version}. ` +
738
+ 'The render changed: re-audit the builder axes, bump the version, add a new versioned golden, and re-pin the recorded renderSha.');
739
+ }
740
+ for (const entry of changelog) {
741
+ const golden = readGolden(entry.version);
742
+ if (golden === undefined) {
743
+ throw new Error(`Prompt spec ${specId} is missing the versioned golden for version ${entry.version}`);
744
+ }
745
+ const goldenSha = sha256Hex(golden);
746
+ if (goldenSha !== entry.renderSha) {
747
+ throw new Error(`Prompt spec ${specId} golden for version ${entry.version} sha ${goldenSha} does not match recorded renderSha ${entry.renderSha}`);
748
+ }
749
+ }
750
+ }
604
751
  function getContractFieldKeys(contract) {
605
752
  return new Set(contract.fields.map((field) => field.key));
606
753
  }
@@ -613,6 +760,7 @@ function validateBuilderInputShape(spec, builder, label) {
613
760
  }
614
761
  function validatePromptSpecContracts(specs) {
615
762
  for (const spec of specs) {
763
+ validatePromptSpecVersioning(spec);
616
764
  validateBuilderInputShape(spec, spec.baseInstructions, 'baseInstructions');
617
765
  for (const variant of spec.variants) {
618
766
  validateBuilderInputShape(spec, variant.baseInstructions, `variant ${variant.kind}/${variant.baseInstructions.exportName}`);
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Whether neal-driven agent runs should be isolated from the operator's
3
+ * interactive agent config.
4
+ *
5
+ * The native adapters drive the Codex and Claude SDKs, which by default load
6
+ * the operator's own config — the Codex `notify` hook, the Claude
7
+ * `Stop`/`Notification` hooks, `CLAUDE.md`, permissions. In normal use
8
+ * (pipeline, local `neal go`) that is correct: it is the operator's machine
9
+ * and their configured agent behavior, so neal honors it.
10
+ *
11
+ * The one exception is the compat qualification harness (`neal compat`, which
12
+ * `scripts/qualify-sdk.sh` drives). It is a hermetic, repeatable capability
13
+ * probe that runs the whole planner/coder/reviewer matrix — many turns — and
14
+ * must not fire the operator's per-turn notifier hooks or let ambient config
15
+ * skew the verdict. So `runCompat` turns isolation ON for its process, and the
16
+ * native adapters read it here.
17
+ *
18
+ * A process-level env flag (matching NEAL_STOP_AFTER_CURRENT_SCOPE_FILE /
19
+ * NEAL_GUIDANCE_DIR) rather than a threaded option, because compat drives the
20
+ * adapters through the full orchestrator, and the flag would otherwise have to
21
+ * cross every layer between the command and adapter construction.
22
+ */
23
+ const ISOLATION_ENV_FLAG = 'NEAL_ISOLATE_AGENT_SETTINGS';
24
+ export function enableAgentSettingsIsolation() {
25
+ process.env[ISOLATION_ENV_FLAG] = '1';
26
+ }
27
+ // Symmetric off switch. Production only ever enables (compat, once), but tests
28
+ // that exercise the isolated path need a clean teardown so the process-level
29
+ // flag does not leak into sibling tests.
30
+ export function disableAgentSettingsIsolation() {
31
+ delete process.env[ISOLATION_ENV_FLAG];
32
+ }
33
+ export function agentSettingsIsolated() {
34
+ return process.env[ISOLATION_ENV_FLAG] === '1';
35
+ }
@@ -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;
@@ -602,11 +604,26 @@ function deriveClaudeAbortController(signal) {
602
604
  function buildClaudeCoreQueryOptions(spec) {
603
605
  return {
604
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(),
605
611
  ...(spec.abortController ? { abortController: spec.abortController } : {}),
606
612
  ...(spec.model ? { model: spec.model } : {}),
607
613
  ...(spec.effort ? { effort: spec.effort } : {}),
608
614
  tools: spec.tools,
609
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: [] } : {}),
610
627
  permissionMode: 'bypassPermissions',
611
628
  allowDangerouslySkipPermissions: true,
612
629
  ...(spec.resumeHandle ? { resume: spec.resumeHandle } : {}),
@@ -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,7 +1,9 @@
1
1
  import { Codex } from '@openai/codex-sdk';
2
2
  import { runStructuredJsonProtocol } from '../agents/structured-json.js';
3
+ import { agentSettingsIsolated } from './agent-settings-isolation.js';
4
+ import { agentSubprocessEnv } from './git-config-isolation.js';
3
5
  import { resolveRateCost } from './pricing.js';
4
- import { NealProviderError } from './types.js';
6
+ import { isContentSafetyRefusalMessage, NealProviderError } from './types.js';
5
7
  const OPENAI_CODEX_PROVIDER_ID = 'openai-codex';
6
8
  class CodexInactivityTimeoutError extends Error {
7
9
  constructor(timeoutMs) {
@@ -11,7 +13,7 @@ class CodexInactivityTimeoutError extends Error {
11
13
  }
12
14
  // In-round transient (`api_error`) retries back off exponentially: 500 ms
13
15
  // base, doubling per retry, capped at 5 s — the same schedule as the
14
- // generic-agentic adapter. `sleep` is injectable through the adapter options
16
+ // openai-compatible adapter. `sleep` is injectable through the adapter options
15
17
  // so tests observe the delays without wall-clock waiting.
16
18
  const API_RETRY_BASE_DELAY_MS = 500;
17
19
  const API_RETRY_MAX_DELAY_MS = 5_000;
@@ -68,6 +70,12 @@ function inferCodexErrorKind(error, message, fallback) {
68
70
  if (error instanceof CodexInactivityTimeoutError || /\btimed out after\b/i.test(message)) {
69
71
  return 'timeout';
70
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
+ }
71
79
  if (text.includes('permission') || text.includes('denied') || text.includes('forbidden') || text.includes('not authorized')) {
72
80
  return 'permission_denied';
73
81
  }
@@ -229,7 +237,21 @@ function buildCodexThreadOptions(args) {
229
237
  };
230
238
  }
231
239
  function createCodexThread(args) {
232
- 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 });
233
255
  const threadOptions = buildCodexThreadOptions({
234
256
  cwd: args.cwd,
235
257
  model: args.model,