@navels/neal 0.1.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 (170) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +527 -0
  3. package/SECURITY.md +91 -0
  4. package/config.yml +104 -0
  5. package/dist/neal/activity-footer.js +177 -0
  6. package/dist/neal/activity-reporting.js +1 -0
  7. package/dist/neal/adjudicator/artifacts.js +58 -0
  8. package/dist/neal/adjudicator/blocked-adjudicator.js +223 -0
  9. package/dist/neal/adjudicator/contracts.js +139 -0
  10. package/dist/neal/adjudicator/execute.js +611 -0
  11. package/dist/neal/adjudicator/final-completion.js +104 -0
  12. package/dist/neal/adjudicator/planning.js +145 -0
  13. package/dist/neal/adjudicator/specs.js +453 -0
  14. package/dist/neal/agents/prompts.js +120 -0
  15. package/dist/neal/agents/rounds.js +706 -0
  16. package/dist/neal/agents/schemas.js +832 -0
  17. package/dist/neal/agents/structured-coder.js +82 -0
  18. package/dist/neal/agents/structured-json.js +528 -0
  19. package/dist/neal/agents.js +4 -0
  20. package/dist/neal/atomic-write.js +18 -0
  21. package/dist/neal/blocked-guidance.js +406 -0
  22. package/dist/neal/cli.js +471 -0
  23. package/dist/neal/commands/check.js +401 -0
  24. package/dist/neal/commands/compat.js +807 -0
  25. package/dist/neal/commands/interactive-activity.js +57 -0
  26. package/dist/neal/commands/new-run.js +79 -0
  27. package/dist/neal/commands/plan-and-execute.js +44 -0
  28. package/dist/neal/commands/recovery-guidance.js +217 -0
  29. package/dist/neal/commands/resume-run.js +395 -0
  30. package/dist/neal/commands/review.js +21 -0
  31. package/dist/neal/commands/runtime.js +557 -0
  32. package/dist/neal/commands/setup.js +596 -0
  33. package/dist/neal/commands/squash.js +113 -0
  34. package/dist/neal/commands/status.js +33 -0
  35. package/dist/neal/commands/writer-exit-codes.js +42 -0
  36. package/dist/neal/commit-message.js +17 -0
  37. package/dist/neal/config.js +432 -0
  38. package/dist/neal/context/artifacts.js +140 -0
  39. package/dist/neal/context/context.js +324 -0
  40. package/dist/neal/context/inline-review-context.js +131 -0
  41. package/dist/neal/context/reviewer-context.js +166 -0
  42. package/dist/neal/context/shared.js +117 -0
  43. package/dist/neal/context/types.js +1 -0
  44. package/dist/neal/diagnostic.js +208 -0
  45. package/dist/neal/execute-finalization.js +5 -0
  46. package/dist/neal/final-completion-review.js +188 -0
  47. package/dist/neal/final-completion.js +229 -0
  48. package/dist/neal/git.js +339 -0
  49. package/dist/neal/index.js +135 -0
  50. package/dist/neal/interactive-controls.js +85 -0
  51. package/dist/neal/logger.js +102 -0
  52. package/dist/neal/manual-gates.js +121 -0
  53. package/dist/neal/orchestrator/artifacts.js +70 -0
  54. package/dist/neal/orchestrator/completion.js +531 -0
  55. package/dist/neal/orchestrator/failures.js +31 -0
  56. package/dist/neal/orchestrator/notifications.js +175 -0
  57. package/dist/neal/orchestrator/phases/coder.js +516 -0
  58. package/dist/neal/orchestrator/phases/planning.js +540 -0
  59. package/dist/neal/orchestrator/phases/recovery.js +798 -0
  60. package/dist/neal/orchestrator/phases/review.js +136 -0
  61. package/dist/neal/orchestrator/phases/shared.js +279 -0
  62. package/dist/neal/orchestrator/run-loop.js +113 -0
  63. package/dist/neal/orchestrator/split-plan.js +235 -0
  64. package/dist/neal/orchestrator/transitions.js +309 -0
  65. package/dist/neal/orchestrator.js +215 -0
  66. package/dist/neal/phase-display.js +27 -0
  67. package/dist/neal/plan-doc.js +154 -0
  68. package/dist/neal/plan-queue.js +1092 -0
  69. package/dist/neal/plan-refinement.js +39 -0
  70. package/dist/neal/plan-validation.js +525 -0
  71. package/dist/neal/progress.js +237 -0
  72. package/dist/neal/prompts/assert-builder.js +13 -0
  73. package/dist/neal/prompts/execute.js +290 -0
  74. package/dist/neal/prompts/guidance.js +70 -0
  75. package/dist/neal/prompts/planning.js +313 -0
  76. package/dist/neal/prompts/review-doctrine.js +142 -0
  77. package/dist/neal/prompts/shared.js +101 -0
  78. package/dist/neal/prompts/specialized.js +212 -0
  79. package/dist/neal/prompts/specs.js +572 -0
  80. package/dist/neal/providers/anthropic-claude.js +1599 -0
  81. package/dist/neal/providers/detection.js +139 -0
  82. package/dist/neal/providers/generic-agentic-tools.js +586 -0
  83. package/dist/neal/providers/generic-agentic.js +1238 -0
  84. package/dist/neal/providers/liveness.js +151 -0
  85. package/dist/neal/providers/openai-codex.js +1014 -0
  86. package/dist/neal/providers/openai-compatible.js +654 -0
  87. package/dist/neal/providers/registry.js +389 -0
  88. package/dist/neal/providers/telemetry.js +208 -0
  89. package/dist/neal/providers/types.js +21 -0
  90. package/dist/neal/recovery-artifacts.js +50 -0
  91. package/dist/neal/resume-decision.js +220 -0
  92. package/dist/neal/resume-planner.js +265 -0
  93. package/dist/neal/retrospective.js +391 -0
  94. package/dist/neal/review-debt.js +18 -0
  95. package/dist/neal/review-findings/artifacts.js +173 -0
  96. package/dist/neal/review-findings/prompts.js +172 -0
  97. package/dist/neal/review-findings/provider.js +330 -0
  98. package/dist/neal/review-findings/run.js +373 -0
  99. package/dist/neal/review-findings/types.js +1 -0
  100. package/dist/neal/review-mode.js +67 -0
  101. package/dist/neal/review.js +137 -0
  102. package/dist/neal/run-lock.js +334 -0
  103. package/dist/neal/run-metrics.js +355 -0
  104. package/dist/neal/run-narrative-types.js +1 -0
  105. package/dist/neal/run-narrative.js +1374 -0
  106. package/dist/neal/run-registry.js +218 -0
  107. package/dist/neal/run-status.js +25 -0
  108. package/dist/neal/scopes.js +451 -0
  109. package/dist/neal/sensitive-text.js +8 -0
  110. package/dist/neal/squash-message.js +379 -0
  111. package/dist/neal/squash.js +591 -0
  112. package/dist/neal/state-invariants.js +496 -0
  113. package/dist/neal/state-views.js +344 -0
  114. package/dist/neal/state.js +887 -0
  115. package/dist/neal/status-footer.js +258 -0
  116. package/dist/neal/status.js +1260 -0
  117. package/dist/neal/storage-paths.js +57 -0
  118. package/dist/neal/support.js +58 -0
  119. package/dist/neal/terminal-narrator.js +435 -0
  120. package/dist/neal/types.js +1 -0
  121. package/dist/neal/verification-events.js +81 -0
  122. package/dist/neal/version.js +37 -0
  123. package/dist/neal/worktree-status.js +137 -0
  124. package/dist/notifier.js +44 -0
  125. package/docs/ADJUDICATOR_INVENTORY.md +310 -0
  126. package/docs/PROMPT_SPECS.md +266 -0
  127. package/docs/README.md +22 -0
  128. package/docs/architecture.md +113 -0
  129. package/docs/assets/neal-execution-flow.png +0 -0
  130. package/docs/automation.md +65 -0
  131. package/docs/comparison.md +105 -0
  132. package/docs/compat.md +269 -0
  133. package/docs/compatible-models.md +135 -0
  134. package/docs/demo.md +55 -0
  135. package/docs/maintenance.md +64 -0
  136. package/docs/plan-format.md +213 -0
  137. package/docs/providers.md +751 -0
  138. package/docs/release.md +147 -0
  139. package/docs/state-machine.md +266 -0
  140. package/docs/storage.md +207 -0
  141. package/docs/troubleshooting.md +152 -0
  142. package/examples/compat/add-edit-verify/PLAN.md +29 -0
  143. package/examples/compat/add-edit-verify/broken.diff +8 -0
  144. package/examples/compat/add-edit-verify/good.diff +8 -0
  145. package/examples/compat/add-edit-verify/package.json +5 -0
  146. package/examples/compat/add-edit-verify/src/add.js +2 -0
  147. package/examples/compat/add-edit-verify/test/add.test.js +9 -0
  148. package/examples/compat/is-even-add-test/PLAN.md +30 -0
  149. package/examples/compat/is-even-add-test/broken.diff +11 -0
  150. package/examples/compat/is-even-add-test/good.diff +11 -0
  151. package/examples/compat/is-even-add-test/package.json +5 -0
  152. package/examples/compat/is-even-add-test/src/is-even.js +3 -0
  153. package/examples/compat/is-even-add-test/test/is-even.test.js +9 -0
  154. package/examples/compat/manifest.json +60 -0
  155. package/examples/compat/plan-greeting/ISSUE.md +25 -0
  156. package/examples/compat/plan-greeting/package.json +5 -0
  157. package/examples/compat/plan-greeting/src/greet.js +2 -0
  158. package/examples/compat/plan-greeting/test/greet.test.js +8 -0
  159. package/examples/compat/reverse-grep-edit/PLAN.md +32 -0
  160. package/examples/compat/reverse-grep-edit/broken.diff +12 -0
  161. package/examples/compat/reverse-grep-edit/good.diff +12 -0
  162. package/examples/compat/reverse-grep-edit/package.json +5 -0
  163. package/examples/compat/reverse-grep-edit/src/strings.js +10 -0
  164. package/examples/compat/reverse-grep-edit/test/strings.test.js +15 -0
  165. package/examples/issue-triage-js/PLAN.md +83 -0
  166. package/examples/issue-triage-js/README.md +76 -0
  167. package/examples/issue-triage-js/package.json +9 -0
  168. package/examples/issue-triage-js/src/issue-triage.js +87 -0
  169. package/examples/issue-triage-js/test/issue-triage.test.js +107 -0
  170. package/package.json +70 -0
@@ -0,0 +1,807 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { cpSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
3
+ import { readFile } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { join, relative, resolve } from 'node:path';
6
+ import process from 'node:process';
7
+ import { Writable } from 'node:stream';
8
+ import { fileURLToPath } from 'node:url';
9
+ import { DEFAULT_REVIEW_INSTRUCTION } from '../cli.js';
10
+ import { assertWriterProvidersConfigured } from '../config.js';
11
+ import { createRunId } from '../logger.js';
12
+ import { loadOrInitialize } from '../orchestrator.js';
13
+ import { validatePlanDocument } from '../plan-validation.js';
14
+ import { getReviewFindingsArtifactPaths } from '../review-findings/artifacts.js';
15
+ import { createAgentReviewFindingsProviderAdapter } from '../review-findings/provider.js';
16
+ import { runNealReviewCli } from '../review-findings/run.js';
17
+ import { isNealProviderError } from '../providers/types.js';
18
+ import { getRunDir } from '../storage-paths.js';
19
+ import { verifyConfiguredProviders } from './check.js';
20
+ import { executeRun } from './runtime.js';
21
+ // Native adapters drive their providers' own CLIs/SDKs directly; every other
22
+ // provider id is "generic-agentic" and should be qualified via `neal compat`.
23
+ export const NATIVE_PROVIDER_IDS = new Set([
24
+ 'openai-codex',
25
+ 'anthropic-claude',
26
+ ]);
27
+ export function isGenericAgenticProvider(provider) {
28
+ return !NATIVE_PROVIDER_IDS.has(provider);
29
+ }
30
+ // Default model per native reference provider. `neal compat` routes the
31
+ // non-candidate roles onto a native adapter; these are the known-good models
32
+ // each adapter drives (effort omitted → native provider default).
33
+ export const REFERENCE_DEFAULT_MODELS = {
34
+ 'openai-codex': 'gpt-5.5',
35
+ 'anthropic-claude': 'claude-opus-4-8',
36
+ };
37
+ // A compat reference is either a native provider id (run at its built-in default model)
38
+ // or `generic-agentic:<openrouter-slug>` to use a *validated* OpenRouter model as the
39
+ // known-good partner. The latter is the follow-up the routing plan anticipated once a
40
+ // rock-solid OpenRouter reference was identified: it removes the codex reference and so
41
+ // uncaps parallelism (no shared native-provider capacity pool). Use ONLY a model that
42
+ // reliably passes all roles — a flaky partner makes a candidate FAIL unattributable.
43
+ export function resolveReference(reference) {
44
+ const id = reference ?? 'openai-codex';
45
+ const colon = id.indexOf(':');
46
+ if (colon === -1) {
47
+ return {
48
+ provider: id,
49
+ model: REFERENCE_DEFAULT_MODELS[id] ?? null,
50
+ };
51
+ }
52
+ return { provider: id.slice(0, colon), model: id.slice(colon + 1) };
53
+ }
54
+ // ---------------------------------------------------------------------------
55
+ // Manifest loading
56
+ // ---------------------------------------------------------------------------
57
+ export function getCompatExamplesDir() {
58
+ // src/neal/commands/compat.ts -> ../../../examples/compat (repo/package root).
59
+ return fileURLToPath(new URL('../../../examples/compat/', import.meta.url));
60
+ }
61
+ function asStringArray(value, context) {
62
+ if (!Array.isArray(value) || value.some((entry) => typeof entry !== 'string')) {
63
+ throw new Error(`compat manifest: ${context} must be an array of strings`);
64
+ }
65
+ return value;
66
+ }
67
+ function validateFixture(raw, index) {
68
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
69
+ throw new Error(`compat manifest: fixtures[${index}] must be an object`);
70
+ }
71
+ const value = raw;
72
+ if (typeof value.id !== 'string' || value.id.trim() === '') {
73
+ throw new Error(`compat manifest: fixtures[${index}].id must be a non-empty string`);
74
+ }
75
+ const roles = asStringArray(value.roles, `fixtures[${index}].roles`);
76
+ for (const role of roles) {
77
+ if (role !== 'coder' && role !== 'reviewer' && role !== 'planner') {
78
+ throw new Error(`compat manifest: ${value.id} has unsupported role ${JSON.stringify(role)}`);
79
+ }
80
+ }
81
+ if (typeof value.projectDir !== 'string' || value.projectDir.trim() === '') {
82
+ throw new Error(`compat manifest: ${value.id} must define a projectDir`);
83
+ }
84
+ const referenceFix = value.referenceFix;
85
+ if (!referenceFix ||
86
+ typeof referenceFix.file !== 'string' ||
87
+ typeof referenceFix.from !== 'string' ||
88
+ typeof referenceFix.to !== 'string') {
89
+ throw new Error(`compat manifest: ${value.id} must define a referenceFix { file, from, to }`);
90
+ }
91
+ const fixture = {
92
+ id: value.id,
93
+ roles: roles,
94
+ projectDir: value.projectDir,
95
+ referenceFix: { file: referenceFix.file, from: referenceFix.from, to: referenceFix.to },
96
+ };
97
+ if (value.planDoc !== undefined) {
98
+ if (typeof value.planDoc !== 'string') {
99
+ throw new Error(`compat manifest: ${value.id}.planDoc must be a string`);
100
+ }
101
+ fixture.planDoc = value.planDoc;
102
+ }
103
+ if (value.issuePrompt !== undefined) {
104
+ if (typeof value.issuePrompt !== 'string') {
105
+ throw new Error(`compat manifest: ${value.id}.issuePrompt must be a string`);
106
+ }
107
+ fixture.issuePrompt = value.issuePrompt;
108
+ }
109
+ if (value.verifyCommand !== undefined) {
110
+ if (typeof value.verifyCommand !== 'string') {
111
+ throw new Error(`compat manifest: ${value.id}.verifyCommand must be a string`);
112
+ }
113
+ fixture.verifyCommand = value.verifyCommand;
114
+ }
115
+ if (value.reviewer !== undefined) {
116
+ const reviewer = value.reviewer;
117
+ if (typeof reviewer.goodDiff !== 'string' || typeof reviewer.brokenDiff !== 'string') {
118
+ throw new Error(`compat manifest: ${value.id}.reviewer must define goodDiff and brokenDiff`);
119
+ }
120
+ fixture.reviewer = { goodDiff: reviewer.goodDiff, brokenDiff: reviewer.brokenDiff };
121
+ }
122
+ // Contract checks mirrored from the bundled-fixture structural test
123
+ // (test/compat-fixtures.test.ts).
124
+ if (fixture.roles.includes('coder') && !fixture.planDoc) {
125
+ throw new Error(`compat manifest: ${value.id} carries the coder role but defines no planDoc`);
126
+ }
127
+ if (fixture.roles.includes('planner') && !fixture.issuePrompt) {
128
+ throw new Error(`compat manifest: ${value.id} carries the planner role but defines no issuePrompt`);
129
+ }
130
+ if (fixture.roles.includes('reviewer')) {
131
+ if (!fixture.reviewer) {
132
+ throw new Error(`compat manifest: ${value.id} carries the reviewer role but defines no diffs`);
133
+ }
134
+ if (!fixture.verifyCommand) {
135
+ throw new Error(`compat manifest: ${value.id} carries the reviewer role but defines no verifyCommand`);
136
+ }
137
+ }
138
+ if ((fixture.roles.includes('coder') || fixture.roles.includes('reviewer')) && !fixture.verifyCommand) {
139
+ throw new Error(`compat manifest: ${value.id} must define a verifyCommand`);
140
+ }
141
+ return fixture;
142
+ }
143
+ export function loadCompatManifest(compatDir = getCompatExamplesDir()) {
144
+ const manifestPath = join(compatDir, 'manifest.json');
145
+ const parsed = JSON.parse(readFileSync(manifestPath, 'utf8'));
146
+ if (parsed === null || typeof parsed !== 'object' || !Array.isArray(parsed.fixtures)) {
147
+ throw new Error('compat manifest: top-level object must define a fixtures array');
148
+ }
149
+ const rawFixtures = parsed.fixtures;
150
+ if (rawFixtures.length === 0) {
151
+ throw new Error('compat manifest: must define at least one fixture');
152
+ }
153
+ return { fixtures: rawFixtures.map((fixture, index) => validateFixture(fixture, index)) };
154
+ }
155
+ // ---------------------------------------------------------------------------
156
+ // Argument parsing
157
+ // ---------------------------------------------------------------------------
158
+ export function parseCompatArgs(args) {
159
+ if (args[0] !== 'compat') {
160
+ throw new Error(`Unknown argument: ${args[0] ?? ''}`);
161
+ }
162
+ let model = null;
163
+ let role = 'all';
164
+ let reference = null;
165
+ let json = false;
166
+ let index = 1;
167
+ function requireValue(flag) {
168
+ const value = args[index + 1];
169
+ if (value === undefined || value.startsWith('--') || value.trim() === '') {
170
+ throw new Error(`neal compat ${flag} requires a non-empty value`);
171
+ }
172
+ return value.trim();
173
+ }
174
+ while (index < args.length) {
175
+ const flag = args[index];
176
+ switch (flag) {
177
+ case '--model':
178
+ model = requireValue('--model');
179
+ index += 2;
180
+ break;
181
+ case '--reference': {
182
+ const value = requireValue('--reference');
183
+ const colon = value.indexOf(':');
184
+ if (colon === -1) {
185
+ if (!NATIVE_PROVIDER_IDS.has(value)) {
186
+ throw new Error('neal compat --reference must be a native provider id (openai-codex or anthropic-claude) or generic-agentic:<openrouter-model>');
187
+ }
188
+ }
189
+ else if (value.slice(0, colon) !== 'generic-agentic' || value.slice(colon + 1).trim() === '') {
190
+ throw new Error('neal compat --reference provider:model form must be generic-agentic:<openrouter-model>');
191
+ }
192
+ reference = value;
193
+ index += 2;
194
+ break;
195
+ }
196
+ case '--role': {
197
+ const value = requireValue('--role');
198
+ if (value !== 'coder' && value !== 'reviewer' && value !== 'planner' && value !== 'all') {
199
+ throw new Error('neal compat --role must be one of: coder, reviewer, planner, all');
200
+ }
201
+ role = value;
202
+ index += 2;
203
+ break;
204
+ }
205
+ case '--json':
206
+ json = true;
207
+ index += 1;
208
+ break;
209
+ default:
210
+ throw new Error(`Unknown argument: ${flag}`);
211
+ }
212
+ }
213
+ return { model, role, reference, json };
214
+ }
215
+ // ---------------------------------------------------------------------------
216
+ // Candidate config derivation
217
+ // ---------------------------------------------------------------------------
218
+ export function deriveCandidateConfig(base, options) {
219
+ const next = {
220
+ planner: { ...base.planner },
221
+ coder: { ...base.coder },
222
+ reviewer: { ...base.reviewer },
223
+ };
224
+ const routingActive = options.model !== null || options.reference !== null;
225
+ const reference = resolveReference(options.reference);
226
+ // Tested role: when a candidate slug is given, force the generic-agentic
227
+ // provider and drop any configured effort so the slug drives a clean
228
+ // OpenRouter call. Otherwise leave the configured provider/model untouched.
229
+ if (options.model !== null) {
230
+ next[options.testedRole] = { provider: 'generic-agentic', model: options.model };
231
+ }
232
+ // Non-tested roles: when either flag is given, route them onto the reference
233
+ // (a native adapter at its default model, or generic-agentic:<slug> for a validated
234
+ // OpenRouter partner). When neither flag is given, leave every role as configured
235
+ // (pure pass-through).
236
+ if (routingActive) {
237
+ for (const role of ['planner', 'coder', 'reviewer']) {
238
+ if (role !== options.testedRole) {
239
+ next[role] = { provider: reference.provider, model: reference.model };
240
+ }
241
+ }
242
+ }
243
+ return next;
244
+ }
245
+ export function rolesForSelection(role) {
246
+ if (role === 'all') {
247
+ return ['coder', 'reviewer', 'planner'];
248
+ }
249
+ return [role];
250
+ }
251
+ // ---------------------------------------------------------------------------
252
+ // Throwaway worktree helpers
253
+ // ---------------------------------------------------------------------------
254
+ function gitInThrowaway(cwd, args) {
255
+ const result = spawnSync('git', args, { cwd, encoding: 'utf8' });
256
+ if (result.error) {
257
+ throw result.error;
258
+ }
259
+ return { status: result.status ?? 1, stderr: result.stderr ?? '' };
260
+ }
261
+ function initThrowawayRepo(cwd) {
262
+ const steps = [
263
+ ['init', '-q'],
264
+ ['config', 'user.name', 'Neal Compat'],
265
+ ['config', 'user.email', 'compat@neal.local'],
266
+ ['config', 'commit.gpgsign', 'false'],
267
+ ['add', '-A'],
268
+ ['commit', '-q', '-m', 'base'],
269
+ ];
270
+ for (const step of steps) {
271
+ const { status, stderr } = gitInThrowaway(cwd, step);
272
+ if (status !== 0) {
273
+ throw new Error(`compat: throwaway git ${step[0]} failed: ${stderr.trim()}`);
274
+ }
275
+ }
276
+ }
277
+ function applyAndCommitDiff(cwd, diffPath, message) {
278
+ const apply = gitInThrowaway(cwd, ['apply', diffPath]);
279
+ if (apply.status !== 0) {
280
+ throw new Error(`compat: diff ${diffPath} did not apply cleanly: ${apply.stderr.trim()}`);
281
+ }
282
+ const add = gitInThrowaway(cwd, ['add', '-A']);
283
+ if (add.status !== 0) {
284
+ throw new Error(`compat: git add failed after applying ${diffPath}`);
285
+ }
286
+ const commit = gitInThrowaway(cwd, ['commit', '-q', '-m', message]);
287
+ if (commit.status !== 0) {
288
+ throw new Error(`compat: git commit failed after applying ${diffPath}`);
289
+ }
290
+ }
291
+ function copyFixtureProject(compatDir, fixture) {
292
+ const dir = mkdtempSync(join(tmpdir(), `neal-compat-${fixture.id}-`));
293
+ cpSync(join(compatDir, fixture.projectDir), dir, { recursive: true });
294
+ return dir;
295
+ }
296
+ function cleanProcessEnv() {
297
+ // Strip NODE_TEST_* so a verifyCommand spawning `node --test` reports its own
298
+ // real exit status instead of inheriting this process's test context.
299
+ const env = { ...process.env };
300
+ for (const key of Object.keys(env)) {
301
+ if (key.startsWith('NODE_TEST')) {
302
+ delete env[key];
303
+ }
304
+ }
305
+ return env;
306
+ }
307
+ function runVerifyCommand(verifyCommand, cwd) {
308
+ const parts = verifyCommand.split(/\s+/u).filter(Boolean);
309
+ const [command, ...commandArgs] = parts;
310
+ if (!command) {
311
+ throw new Error('compat: empty verifyCommand');
312
+ }
313
+ const result = spawnSync(command, commandArgs, { cwd, encoding: 'utf8', env: cleanProcessEnv() });
314
+ if (result.error) {
315
+ throw result.error;
316
+ }
317
+ return result.status ?? 1;
318
+ }
319
+ async function readRunEvents(runDir) {
320
+ let content;
321
+ try {
322
+ content = await readFile(join(runDir, 'events.ndjson'), 'utf8');
323
+ }
324
+ catch {
325
+ return [];
326
+ }
327
+ return content
328
+ .split('\n')
329
+ .filter(Boolean)
330
+ .map((line) => JSON.parse(line));
331
+ }
332
+ function hasEvent(events, type) {
333
+ return events.some((event) => event.type === type);
334
+ }
335
+ export async function runWriterFixture(options) {
336
+ const throwawayCwd = copyFixtureProject(options.compatDir, options.fixture);
337
+ const runDir = getRunDir(throwawayCwd, createRunId());
338
+ const planDoc = resolve(throwawayCwd, options.documentRelativePath);
339
+ let finalStatus = null;
340
+ let unattended = null;
341
+ let threwDuringRun = false;
342
+ let errorMessage = null;
343
+ let verifyExitCode = null;
344
+ let finalDocument = null;
345
+ try {
346
+ initThrowawayRepo(throwawayCwd);
347
+ options.onPrepared?.({ throwawayCwd, runDir, planDoc });
348
+ const loaded = await loadOrInitialize(planDoc, throwawayCwd, options.candidateConfig, undefined, options.mode, {
349
+ runDir,
350
+ unattended: true,
351
+ allowedDirtyPaths: [planDoc],
352
+ });
353
+ unattended = loaded.state.unattended;
354
+ // executeRun renders its final run summary to process.stdout (runtime.ts).
355
+ // Capture and discard that here so it never leaks into the compat
356
+ // command's own stdout (the PASS/FAIL table / `--json` matrix). Footer and
357
+ // diagnostics already go to stderr, so only stdout needs redirecting.
358
+ const realStdoutWrite = process.stdout.write.bind(process.stdout);
359
+ process.stdout.write = (() => true);
360
+ try {
361
+ const result = await executeRun(loaded.state, loaded.statePath, loaded.logger, { unattended: true });
362
+ finalStatus = result.finalState.status;
363
+ unattended = result.finalState.unattended;
364
+ }
365
+ catch (error) {
366
+ threwDuringRun = true;
367
+ errorMessage = error instanceof Error ? error.message : String(error);
368
+ }
369
+ finally {
370
+ process.stdout.write = realStdoutWrite;
371
+ }
372
+ const events = await readRunEvents(runDir);
373
+ if (options.verifyCommand && finalStatus === 'done') {
374
+ verifyExitCode = runVerifyCommand(options.verifyCommand, throwawayCwd);
375
+ }
376
+ if (options.captureDocument) {
377
+ try {
378
+ finalDocument = await readFile(planDoc, 'utf8');
379
+ }
380
+ catch {
381
+ finalDocument = null;
382
+ }
383
+ }
384
+ return {
385
+ finalStatus,
386
+ unattended,
387
+ runDir,
388
+ throwawayCwd,
389
+ events,
390
+ threwDuringRun,
391
+ errorMessage,
392
+ verifyExitCode,
393
+ finalDocument,
394
+ };
395
+ }
396
+ finally {
397
+ rmSync(throwawayCwd, { recursive: true, force: true });
398
+ }
399
+ }
400
+ // ---------------------------------------------------------------------------
401
+ // Failure-mode classification for writer runs
402
+ // ---------------------------------------------------------------------------
403
+ export function classifyWriterFailure(args) {
404
+ const { events } = args;
405
+ // Precedence follows the plan taxonomy (most specific first).
406
+ const providerError = events.find((event) => event.type === 'provider.provider_error');
407
+ if (providerError) {
408
+ // A provider error names its own cause via `data.errorKind`. Structured-output
409
+ // kinds are genuine schema failures (the model could not produce/honor
410
+ // schema-enforced JSON) and route to the `structured_output` mode, matching
411
+ // `classifyReviewerThrownFailure`. Every other kind (and an absent kind) stays
412
+ // `provider_failed`.
413
+ const errorKind = providerError.data?.errorKind;
414
+ if (errorKind === 'structured_output_missing' || errorKind === 'structured_output_invalid') {
415
+ return 'structured_output';
416
+ }
417
+ return 'provider_failed';
418
+ }
419
+ if (hasEvent(events, 'unattended.block_unresolved')) {
420
+ return 'block_unresolved';
421
+ }
422
+ if (args.finalStatus === 'blocked') {
423
+ return 'block_unresolved';
424
+ }
425
+ if (hasEvent(events, 'phase.error')) {
426
+ return 'structured_output';
427
+ }
428
+ if (args.threwDuringRun || args.finalStatus === null) {
429
+ return 'finalization_error';
430
+ }
431
+ // status 'failed' with no conclusive structural signal.
432
+ return 'provider_failed';
433
+ }
434
+ function makeCell(role, fixtureId, pass, failureMode, detail, diffKind = null) {
435
+ return { role, fixtureId, diffKind, pass, failureMode, detail };
436
+ }
437
+ // ---------------------------------------------------------------------------
438
+ // Coder fixture evaluation
439
+ // ---------------------------------------------------------------------------
440
+ export async function evaluateCoderFixture(args) {
441
+ const { fixture } = args;
442
+ if (!fixture.planDoc || !fixture.verifyCommand) {
443
+ throw new Error(`compat: coder fixture ${fixture.id} is missing planDoc/verifyCommand`);
444
+ }
445
+ const documentRelativePath = relative(fixture.projectDir, fixture.planDoc);
446
+ const run = await runWriterFixture({
447
+ compatDir: args.compatDir,
448
+ fixture,
449
+ candidateConfig: args.candidateConfig,
450
+ mode: 'execute',
451
+ documentRelativePath,
452
+ verifyCommand: fixture.verifyCommand,
453
+ onPrepared: args.onPrepared,
454
+ });
455
+ if (run.finalStatus === 'done') {
456
+ if (run.verifyExitCode === 0) {
457
+ return { cell: makeCell('coder', fixture.id, true, null, null), run };
458
+ }
459
+ return {
460
+ cell: makeCell('coder', fixture.id, false, 'wrong_or_empty_output', `run completed but verifyCommand exited ${run.verifyExitCode}`),
461
+ run,
462
+ };
463
+ }
464
+ const failureMode = classifyWriterFailure({
465
+ finalStatus: run.finalStatus,
466
+ events: run.events,
467
+ threwDuringRun: run.threwDuringRun,
468
+ });
469
+ return { cell: makeCell('coder', fixture.id, false, failureMode, run.errorMessage), run };
470
+ }
471
+ // ---------------------------------------------------------------------------
472
+ // Planner fixture evaluation (secondary)
473
+ // ---------------------------------------------------------------------------
474
+ export async function evaluatePlannerFixture(args) {
475
+ const { fixture } = args;
476
+ if (!fixture.issuePrompt) {
477
+ throw new Error(`compat: planner fixture ${fixture.id} is missing issuePrompt`);
478
+ }
479
+ const documentRelativePath = relative(fixture.projectDir, fixture.issuePrompt);
480
+ const run = await runWriterFixture({
481
+ compatDir: args.compatDir,
482
+ fixture,
483
+ candidateConfig: args.candidateConfig,
484
+ mode: 'plan',
485
+ documentRelativePath,
486
+ captureDocument: true,
487
+ onPrepared: args.onPrepared,
488
+ });
489
+ if (run.finalStatus !== 'done') {
490
+ const failureMode = classifyWriterFailure({
491
+ finalStatus: run.finalStatus,
492
+ events: run.events,
493
+ threwDuringRun: run.threwDuringRun,
494
+ });
495
+ return { cell: makeCell('planner', fixture.id, false, failureMode, run.errorMessage), run };
496
+ }
497
+ const document = run.finalDocument;
498
+ if (document === null) {
499
+ return {
500
+ cell: makeCell('planner', fixture.id, false, 'finalization_error', 'no plan document was emitted'),
501
+ run,
502
+ };
503
+ }
504
+ const validation = validatePlanDocument(document);
505
+ if (validation.ok && validation.executionShape === 'one_shot') {
506
+ return { cell: makeCell('planner', fixture.id, true, null, null), run };
507
+ }
508
+ const detail = validation.ok
509
+ ? `emitted plan is ${validation.executionShape ?? 'unshaped'}, expected one_shot`
510
+ : `emitted plan failed validation: ${validation.errors.join('; ')}`;
511
+ return { cell: makeCell('planner', fixture.id, false, 'wrong_or_empty_output', detail), run };
512
+ }
513
+ // ---------------------------------------------------------------------------
514
+ // Reviewer fixture evaluation
515
+ // ---------------------------------------------------------------------------
516
+ export function hasBlockingFinding(result) {
517
+ return result.draft.findings.some((finding) => finding.severity === 'blocking');
518
+ }
519
+ export function classifyNonAcceptedReviewOutcome(outcome) {
520
+ switch (outcome) {
521
+ case 'blocked':
522
+ return 'block_unresolved';
523
+ case 'cap_reached':
524
+ return 'max_step_loop';
525
+ case 'failed':
526
+ return 'structured_output';
527
+ }
528
+ }
529
+ // Unwrap the underlying NealProviderError from a reviewer-loop throw. The
530
+ // reviewer fixture path surfaces provider failures in three shapes: a raw
531
+ // NealProviderError (the reviewDraft / structured-advisor path), or wrapped in
532
+ // a CoderRoundError (the draftFindings path via runCoderStructuredPrompt) or a
533
+ // ReviewerRoundError. Both wrappers expose the cause as a `providerError`
534
+ // field, so duck-typing that field covers every wrapper without importing the
535
+ // round-error classes.
536
+ function extractReviewProviderError(error) {
537
+ if (isNealProviderError(error)) {
538
+ return error;
539
+ }
540
+ if (error !== null && typeof error === 'object' && 'providerError' in error) {
541
+ const wrapped = error.providerError;
542
+ if (isNealProviderError(wrapped)) {
543
+ return wrapped;
544
+ }
545
+ }
546
+ return null;
547
+ }
548
+ // Classify a thrown error from the reviewer loop. A provider error names its own
549
+ // cause via `kind`: only the structured-output kinds are genuine schema
550
+ // failures; every other kind (timeout, api_error, permission, session,
551
+ // provider_failed, unknown) is a provider failure. Non-provider errors fall
552
+ // back to the persisted run outcome (`blocked`/`cap_reached`/`failed`), where
553
+ // `failed` denotes a schema/validation failure raised inside the review loop.
554
+ export function classifyReviewerThrownFailure(error, persistedOutcome) {
555
+ const providerError = extractReviewProviderError(error);
556
+ if (providerError) {
557
+ return providerError.kind === 'structured_output_missing' || providerError.kind === 'structured_output_invalid'
558
+ ? 'structured_output'
559
+ : 'provider_failed';
560
+ }
561
+ if (persistedOutcome && persistedOutcome !== 'accepted') {
562
+ return classifyNonAcceptedReviewOutcome(persistedOutcome);
563
+ }
564
+ return 'provider_failed';
565
+ }
566
+ function buildReviewParsedArgs() {
567
+ return {
568
+ instruction: DEFAULT_REVIEW_INSTRUCTION,
569
+ instructionSource: 'default',
570
+ selector: { kind: 'last', count: 1 },
571
+ };
572
+ }
573
+ async function evaluateReviewerDiff(args) {
574
+ const { fixture, diffKind } = args;
575
+ if (!fixture.reviewer || !fixture.verifyCommand) {
576
+ throw new Error(`compat: reviewer fixture ${fixture.id} is missing diffs/verifyCommand`);
577
+ }
578
+ const diffRelative = diffKind === 'good' ? fixture.reviewer.goodDiff : fixture.reviewer.brokenDiff;
579
+ const diffPath = join(args.compatDir, diffRelative);
580
+ const throwawayCwd = copyFixtureProject(args.compatDir, fixture);
581
+ const reviewId = createRunId();
582
+ try {
583
+ initThrowawayRepo(throwawayCwd);
584
+ applyAndCommitDiff(throwawayCwd, diffPath, `apply ${diffKind} diff`);
585
+ const provider = args.createReviewProvider({ cwd: throwawayCwd, agentConfig: args.candidateConfig });
586
+ const sink = new Writable({ write(_chunk, _encoding, callback) { callback(); } });
587
+ let result;
588
+ try {
589
+ result = await runNealReviewCli({
590
+ cwd: throwawayCwd,
591
+ parsed: buildReviewParsedArgs(),
592
+ stdout: sink,
593
+ stderr: sink,
594
+ provider,
595
+ reviewId,
596
+ });
597
+ }
598
+ catch (error) {
599
+ // runNealReviewCli collapses every non-accepted run to a persisted
600
+ // `failed` outcome, so a provider/API/timeout failure and a genuine
601
+ // schema-validation failure both surface as `failed`. Classify the thrown
602
+ // error structurally FIRST (a NealProviderError carries the real cause via
603
+ // its `kind`) so provider failures stay `provider_failed` and only true
604
+ // schema failures collapse to `structured_output`.
605
+ const outcome = await readReviewOutcome(throwawayCwd, reviewId);
606
+ const failureMode = classifyReviewerThrownFailure(error, outcome);
607
+ return makeCell('reviewer', fixture.id, false, failureMode, error instanceof Error ? error.message : String(error), diffKind);
608
+ }
609
+ const blocking = hasBlockingFinding(result);
610
+ if (diffKind === 'good') {
611
+ if (!blocking) {
612
+ return makeCell('reviewer', fixture.id, true, null, null, 'good');
613
+ }
614
+ return makeCell('reviewer', fixture.id, false, 'wrong_or_empty_output', 'reviewer flagged the known-good diff with a blocking finding', 'good');
615
+ }
616
+ // broken diff: PASS requires ≥1 blocking finding.
617
+ if (blocking) {
618
+ return makeCell('reviewer', fixture.id, true, null, null, 'broken');
619
+ }
620
+ return makeCell('reviewer', fixture.id, false, 'wrong_or_empty_output', 'reviewer accepted the deliberately broken diff without a blocking finding', 'broken');
621
+ }
622
+ finally {
623
+ rmSync(throwawayCwd, { recursive: true, force: true });
624
+ }
625
+ }
626
+ async function readReviewOutcome(cwd, reviewId) {
627
+ const paths = getReviewFindingsArtifactPaths(cwd, reviewId);
628
+ try {
629
+ const meta = JSON.parse(await readFile(paths.meta, 'utf8'));
630
+ const outcome = meta.outcome;
631
+ if (outcome === 'accepted' ||
632
+ outcome === 'blocked' ||
633
+ outcome === 'failed' ||
634
+ outcome === 'cap_reached') {
635
+ return outcome;
636
+ }
637
+ return null;
638
+ }
639
+ catch {
640
+ return null;
641
+ }
642
+ }
643
+ export async function evaluateReviewerFixture(args) {
644
+ const good = await evaluateReviewerDiff({ ...args, diffKind: 'good' });
645
+ const broken = await evaluateReviewerDiff({ ...args, diffKind: 'broken' });
646
+ return { good, broken };
647
+ }
648
+ // ---------------------------------------------------------------------------
649
+ // Output formatting
650
+ // ---------------------------------------------------------------------------
651
+ export function buildRoleRollups(cells, roles) {
652
+ return roles.map((role) => {
653
+ const roleCells = cells.filter((cell) => cell.role === role);
654
+ const passCount = roleCells.filter((cell) => cell.pass).length;
655
+ return {
656
+ role,
657
+ cellCount: roleCells.length,
658
+ passCount,
659
+ pass: roleCells.length > 0 && passCount === roleCells.length,
660
+ };
661
+ });
662
+ }
663
+ export function formatCompatJson(report) {
664
+ return JSON.stringify(report, null, 2);
665
+ }
666
+ // Describe the native provider the non-candidate roles run on for the human
667
+ // table. `--reference` is a native provider id (not a model); when omitted but
668
+ // `--model` is set, routing is active and the reference defaults to
669
+ // `openai-codex`. With neither flag the non-candidate roles pass through to the
670
+ // configured providers.
671
+ export function describeReferenceProvider(report) {
672
+ if (report.reference !== null) {
673
+ return report.reference;
674
+ }
675
+ if (report.model !== null) {
676
+ return 'openai-codex (default)';
677
+ }
678
+ return '(configured / native pass-through)';
679
+ }
680
+ export function formatCompatTable(report) {
681
+ const lines = [];
682
+ lines.push('neal compat - compatibility smoke test (PASS/FAIL, not a skill score)');
683
+ lines.push('');
684
+ lines.push(`Candidate model: ${report.model ?? '(configured model)'}`);
685
+ lines.push(`Reference provider: ${describeReferenceProvider(report)}`);
686
+ lines.push('');
687
+ lines.push('Role Fixture Diff Result Failure mode');
688
+ lines.push('-------- --------------------- ------- ------ -----------------');
689
+ for (const cell of report.cells) {
690
+ const role = cell.role.padEnd(8);
691
+ const fixture = cell.fixtureId.slice(0, 21).padEnd(21);
692
+ const diff = (cell.diffKind ?? '-').padEnd(7);
693
+ const result = (cell.pass ? 'PASS' : 'FAIL').padEnd(6);
694
+ const mode = cell.pass ? '' : cell.failureMode ?? 'unknown';
695
+ lines.push(`${role} ${fixture} ${diff} ${result} ${mode}`);
696
+ }
697
+ lines.push('');
698
+ for (const rollup of report.roles) {
699
+ lines.push(`${rollup.role}: ${rollup.pass ? 'PASS' : 'FAIL'} (${rollup.passCount}/${rollup.cellCount})`);
700
+ }
701
+ lines.push('');
702
+ lines.push(`Overall: ${report.overallPass ? 'PASS' : 'FAIL'}`);
703
+ return lines.join('\n');
704
+ }
705
+ export async function runCompat(args) {
706
+ const { cwd, parsed } = args;
707
+ const deps = args.deps ?? {};
708
+ const compatDir = deps.compatDir ?? getCompatExamplesDir();
709
+ const manifest = deps.manifest ?? loadCompatManifest(compatDir);
710
+ const verifyProviders = deps.verifyProviders ?? verifyConfiguredProviders;
711
+ const createReviewProvider = deps.createReviewProvider ?? ((adapterArgs) => createAgentReviewFindingsProviderAdapter(adapterArgs));
712
+ const baseConfig = assertWriterProvidersConfigured(cwd, { context: 'neal compat' });
713
+ const targetedRoles = rolesForSelection(parsed.role);
714
+ const cells = [];
715
+ // Pre-filter: verify the candidate config for each tested role's provider can
716
+ // emit the structured check payload. Per the plan, a candidate that fails the
717
+ // pre-filter for ANY targeted role fails the whole candidate: every targeted
718
+ // role records FAIL(protocol) and all fixtures are skipped. This keeps the
719
+ // matrix complete (no role left without a cell/failure mode).
720
+ const protocolErrors = new Map();
721
+ for (const role of targetedRoles) {
722
+ const candidateConfig = deriveCandidateConfig(baseConfig, {
723
+ testedRole: role,
724
+ model: parsed.model,
725
+ reference: parsed.reference,
726
+ });
727
+ const sink = new Writable({ write(_chunk, _encoding, callback) { callback(); } });
728
+ try {
729
+ // eslint-disable-next-line no-await-in-loop
730
+ await verifyProviders({ cwd, agentConfig: candidateConfig, stdout: sink });
731
+ }
732
+ catch (error) {
733
+ protocolErrors.set(role, error instanceof Error ? error.message : String(error));
734
+ }
735
+ }
736
+ if (protocolErrors.size > 0) {
737
+ for (const role of targetedRoles) {
738
+ const detail = protocolErrors.get(role) ??
739
+ 'candidate skipped: provider pre-filter failed for another targeted role';
740
+ cells.push(makeCell(role, `provider:${role}`, false, 'protocol', detail));
741
+ }
742
+ }
743
+ else {
744
+ for (const role of targetedRoles) {
745
+ const candidateConfig = deriveCandidateConfig(baseConfig, {
746
+ testedRole: role,
747
+ model: parsed.model,
748
+ reference: parsed.reference,
749
+ });
750
+ if (role === 'coder') {
751
+ for (const fixture of manifest.fixtures.filter((f) => f.roles.includes('coder'))) {
752
+ // eslint-disable-next-line no-await-in-loop
753
+ const { cell } = await evaluateCoderFixture({ compatDir, fixture, candidateConfig });
754
+ cells.push(cell);
755
+ }
756
+ }
757
+ else if (role === 'planner') {
758
+ for (const fixture of manifest.fixtures.filter((f) => f.roles.includes('planner'))) {
759
+ // eslint-disable-next-line no-await-in-loop
760
+ const { cell } = await evaluatePlannerFixture({ compatDir, fixture, candidateConfig });
761
+ cells.push(cell);
762
+ }
763
+ }
764
+ else {
765
+ for (const fixture of manifest.fixtures.filter((f) => f.roles.includes('reviewer'))) {
766
+ // eslint-disable-next-line no-await-in-loop
767
+ const { good, broken } = await evaluateReviewerFixture({
768
+ compatDir,
769
+ fixture,
770
+ candidateConfig,
771
+ createReviewProvider,
772
+ });
773
+ cells.push(good, broken);
774
+ }
775
+ }
776
+ }
777
+ }
778
+ const roles = buildRoleRollups(cells, targetedRoles);
779
+ const overallPass = roles.length > 0 && roles.every((rollup) => rollup.pass);
780
+ return {
781
+ schemaVersion: 1,
782
+ model: parsed.model,
783
+ reference: parsed.reference,
784
+ role: parsed.role,
785
+ candidateProviders: {
786
+ coder: parsed.model !== null ? 'generic-agentic' : baseConfig.coder.provider,
787
+ reviewer: parsed.model !== null ? 'generic-agentic' : baseConfig.reviewer.provider,
788
+ planner: parsed.model !== null ? 'generic-agentic' : baseConfig.planner.provider,
789
+ },
790
+ cells,
791
+ roles,
792
+ overallPass,
793
+ };
794
+ }
795
+ export async function runCompatCli(args) {
796
+ const stdout = args.stdout ?? process.stdout;
797
+ const report = await runCompat({ cwd: args.cwd, parsed: args.parsed, deps: args.deps });
798
+ stdout.write(`${args.parsed.json ? formatCompatJson(report) : formatCompatTable(report)}\n`);
799
+ if (!report.overallPass) {
800
+ process.exitCode = 1;
801
+ }
802
+ return report;
803
+ }
804
+ export async function runCompatCommand(args) {
805
+ const parsed = parseCompatArgs(args);
806
+ await runCompatCli({ cwd: process.cwd(), parsed });
807
+ }