@juzser/blacksmith 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 (172) hide show
  1. package/.claude/agents/auditor.md +146 -0
  2. package/.claude/agents/coder.md +164 -0
  3. package/.claude/agents/grader.md +110 -0
  4. package/.claude/agents/merger.md +107 -0
  5. package/.claude/agents/planner.md +92 -0
  6. package/.claude/agents/researcher.md +105 -0
  7. package/.claude/agents/reviewer.md +171 -0
  8. package/.claude/agents/scribe.md +72 -0
  9. package/.claude/agents/security-reviewer.md +130 -0
  10. package/.claude/agents/spec-reviewer.md +215 -0
  11. package/.claude/agents/tester.md +121 -0
  12. package/.claude/agents/uiux.md +126 -0
  13. package/.claude/agents/verifier.md +103 -0
  14. package/.claude/agents/wave-runner.md +74 -0
  15. package/LICENSE +21 -0
  16. package/README.md +389 -0
  17. package/factory/orchestrator/dist/actors.js +46 -0
  18. package/factory/orchestrator/dist/agents-registry.js +308 -0
  19. package/factory/orchestrator/dist/args.js +84 -0
  20. package/factory/orchestrator/dist/artifacts.js +111 -0
  21. package/factory/orchestrator/dist/attribution.js +160 -0
  22. package/factory/orchestrator/dist/audit.js +812 -0
  23. package/factory/orchestrator/dist/autonomy.js +151 -0
  24. package/factory/orchestrator/dist/budgetAlarm.js +366 -0
  25. package/factory/orchestrator/dist/budgets.js +196 -0
  26. package/factory/orchestrator/dist/claims.js +633 -0
  27. package/factory/orchestrator/dist/cli.js +3185 -0
  28. package/factory/orchestrator/dist/commit.js +152 -0
  29. package/factory/orchestrator/dist/coverage.js +188 -0
  30. package/factory/orchestrator/dist/crossFinding.js +479 -0
  31. package/factory/orchestrator/dist/crosscheck.js +396 -0
  32. package/factory/orchestrator/dist/daemon.js +707 -0
  33. package/factory/orchestrator/dist/db/projector.js +1317 -0
  34. package/factory/orchestrator/dist/db/queries.js +1714 -0
  35. package/factory/orchestrator/dist/db/schema.js +305 -0
  36. package/factory/orchestrator/dist/delegation.js +400 -0
  37. package/factory/orchestrator/dist/diffstat.js +164 -0
  38. package/factory/orchestrator/dist/dispatchAudit.js +275 -0
  39. package/factory/orchestrator/dist/dotenv.js +72 -0
  40. package/factory/orchestrator/dist/effort.js +202 -0
  41. package/factory/orchestrator/dist/epic.js +1018 -0
  42. package/factory/orchestrator/dist/epicWidth.js +238 -0
  43. package/factory/orchestrator/dist/errorIssues.js +249 -0
  44. package/factory/orchestrator/dist/errors.js +11 -0
  45. package/factory/orchestrator/dist/escalation.js +500 -0
  46. package/factory/orchestrator/dist/events.js +1015 -0
  47. package/factory/orchestrator/dist/findingAge.js +91 -0
  48. package/factory/orchestrator/dist/findingContext.js +103 -0
  49. package/factory/orchestrator/dist/findings.js +1156 -0
  50. package/factory/orchestrator/dist/gate.js +911 -0
  51. package/factory/orchestrator/dist/gh.js +146 -0
  52. package/factory/orchestrator/dist/git.js +87 -0
  53. package/factory/orchestrator/dist/goalCheck.js +380 -0
  54. package/factory/orchestrator/dist/graph.js +61 -0
  55. package/factory/orchestrator/dist/hookDecision.js +106 -0
  56. package/factory/orchestrator/dist/immutability.js +171 -0
  57. package/factory/orchestrator/dist/impact.js +280 -0
  58. package/factory/orchestrator/dist/integration.js +141 -0
  59. package/factory/orchestrator/dist/judgePreflight.js +155 -0
  60. package/factory/orchestrator/dist/judges.js +310 -0
  61. package/factory/orchestrator/dist/lessonAudit.js +399 -0
  62. package/factory/orchestrator/dist/lessons.js +1329 -0
  63. package/factory/orchestrator/dist/mcp.js +555 -0
  64. package/factory/orchestrator/dist/paths.js +82 -0
  65. package/factory/orchestrator/dist/plan.js +502 -0
  66. package/factory/orchestrator/dist/planQuorum.js +421 -0
  67. package/factory/orchestrator/dist/policy.js +1308 -0
  68. package/factory/orchestrator/dist/policyHook.js +28 -0
  69. package/factory/orchestrator/dist/preconditions.js +47 -0
  70. package/factory/orchestrator/dist/projects.js +134 -0
  71. package/factory/orchestrator/dist/prompts.js +57 -0
  72. package/factory/orchestrator/dist/provenance.js +201 -0
  73. package/factory/orchestrator/dist/providers/api-transport.js +117 -0
  74. package/factory/orchestrator/dist/providers/cli-transport.js +208 -0
  75. package/factory/orchestrator/dist/providers/index.js +54 -0
  76. package/factory/orchestrator/dist/providers/schema-validate.js +152 -0
  77. package/factory/orchestrator/dist/providers/types.js +49 -0
  78. package/factory/orchestrator/dist/queue.js +263 -0
  79. package/factory/orchestrator/dist/quorum.js +347 -0
  80. package/factory/orchestrator/dist/quorumEscalations.js +132 -0
  81. package/factory/orchestrator/dist/results.js +65 -0
  82. package/factory/orchestrator/dist/roadmap.js +437 -0
  83. package/factory/orchestrator/dist/runtime.js +86 -0
  84. package/factory/orchestrator/dist/sameMistakeKpi.js +243 -0
  85. package/factory/orchestrator/dist/sandbox.js +165 -0
  86. package/factory/orchestrator/dist/scaffold.js +373 -0
  87. package/factory/orchestrator/dist/scheduler.js +408 -0
  88. package/factory/orchestrator/dist/schemas.js +195 -0
  89. package/factory/orchestrator/dist/security.js +222 -0
  90. package/factory/orchestrator/dist/severity.js +293 -0
  91. package/factory/orchestrator/dist/spec.js +376 -0
  92. package/factory/orchestrator/dist/specChange.js +497 -0
  93. package/factory/orchestrator/dist/stack.js +324 -0
  94. package/factory/orchestrator/dist/symbols.js +966 -0
  95. package/factory/orchestrator/dist/taskEvents.js +399 -0
  96. package/factory/orchestrator/dist/taskId.js +126 -0
  97. package/factory/orchestrator/dist/taxonomy.js +113 -0
  98. package/factory/orchestrator/dist/testSelect.js +129 -0
  99. package/factory/orchestrator/dist/testerAudit.js +264 -0
  100. package/factory/orchestrator/dist/testgate.js +171 -0
  101. package/factory/orchestrator/dist/usage.js +874 -0
  102. package/factory/orchestrator/dist/waivers.js +228 -0
  103. package/factory/orchestrator/dist/waveBudget.js +223 -0
  104. package/factory/orchestrator/dist/waveConcurrency.js +250 -0
  105. package/factory/orchestrator/dist/waveNext.js +210 -0
  106. package/factory/orchestrator/dist/waveSchedule.js +146 -0
  107. package/factory/orchestrator/dist/worktree.js +207 -0
  108. package/factory/orchestrator/drizzle/0000_broken_rockslide.sql +183 -0
  109. package/factory/orchestrator/drizzle/0001_luxuriant_zaladane.sql +1 -0
  110. package/factory/orchestrator/drizzle/0002_yellow_wild_pack.sql +8 -0
  111. package/factory/orchestrator/drizzle/0003_damp_riptide.sql +12 -0
  112. package/factory/orchestrator/drizzle/0004_broad_lady_vermin.sql +1 -0
  113. package/factory/orchestrator/drizzle/0005_romantic_jocasta.sql +2 -0
  114. package/factory/orchestrator/drizzle/0006_bent_madelyne_pryor.sql +16 -0
  115. package/factory/orchestrator/drizzle/0007_overjoyed_goliath.sql +1 -0
  116. package/factory/orchestrator/drizzle/0008_smart_iron_patriot.sql +2 -0
  117. package/factory/orchestrator/drizzle/0009_agents_epic_id.sql +2 -0
  118. package/factory/orchestrator/drizzle/0010_milestone_kind.sql +1 -0
  119. package/factory/orchestrator/drizzle/meta/_journal.json +83 -0
  120. package/factory/policies/budgets.yml +262 -0
  121. package/factory/policies/crosscheck.yml +308 -0
  122. package/factory/policies/delegation.yml +44 -0
  123. package/factory/policies/effort.yml +142 -0
  124. package/factory/policies/guardrails.yml +306 -0
  125. package/factory/policies/lessons.md +239 -0
  126. package/factory/policies/scheduler.yml +90 -0
  127. package/factory/policies/sensitive-paths.yml +148 -0
  128. package/factory/policies/severity.yml +83 -0
  129. package/factory/policies/stack.yml +60 -0
  130. package/factory/policies/taxonomy.yml +340 -0
  131. package/factory/policies/worktree.yml +95 -0
  132. package/factory/scaffold/base/.github/workflows/ci.yml +34 -0
  133. package/factory/scaffold/base/.gitignore.tmpl +11 -0
  134. package/factory/scaffold/base/AGENTS.md +33 -0
  135. package/factory/scaffold/base/README.md +25 -0
  136. package/factory/scaffold/base/biome.json.tmpl +30 -0
  137. package/factory/scaffold/base/package.json +25 -0
  138. package/factory/scaffold/base/pnpm-workspace.yaml.tmpl +16 -0
  139. package/factory/scaffold/base/src/index.ts +4 -0
  140. package/factory/scaffold/base/test/index.test.ts +8 -0
  141. package/factory/scaffold/base/tsconfig.json +20 -0
  142. package/factory/scaffold/base/tsconfig.test.json +9 -0
  143. package/factory/scaffold/base/vitest.config.ts +29 -0
  144. package/factory/scaffold/mcp/mcp.manifest.json +22 -0
  145. package/factory/scaffold/mcp/package.fragment.json +9 -0
  146. package/factory/scaffold/mcp/src/mcp/README.md +63 -0
  147. package/factory/scaffold/mcp/src/mcp/guard.ts +110 -0
  148. package/factory/scaffold/mcp/src/mcp/redact.ts +69 -0
  149. package/factory/scaffold/mcp/src/mcp/server.ts +55 -0
  150. package/factory/scaffold/mcp/src/mcp/tools/health.ts +60 -0
  151. package/factory/scaffold/mcp/test/mcp/guard.test.ts +108 -0
  152. package/factory/scaffold/mcp/test/mcp/redact.test.ts +61 -0
  153. package/factory/scaffold/mcp/test/mcp/surface.test.ts +100 -0
  154. package/factory/scaffold/ui/index.html +12 -0
  155. package/factory/scaffold/ui/package.fragment.json +13 -0
  156. package/factory/scaffold/ui/src/App.vue +10 -0
  157. package/factory/scaffold/ui/src/main.ts +5 -0
  158. package/factory/scaffold/ui/src/shims.d.ts +11 -0
  159. package/factory/scaffold/ui/vite.config.ts +20 -0
  160. package/factory/scaffold/ui-tailwind/package.fragment.json +6 -0
  161. package/factory/scaffold/ui-tailwind/vite.config.ts +26 -0
  162. package/factory/specs/schema/event.schema.json +82 -0
  163. package/factory/specs/schema/finding-evidence.schema.json +45 -0
  164. package/factory/specs/schema/finding.schema.json +115 -0
  165. package/factory/specs/schema/grader-verdict.schema.json +50 -0
  166. package/factory/specs/schema/judge-verdict.schema.json +19 -0
  167. package/factory/specs/schema/lesson.schema.json +85 -0
  168. package/factory/specs/schema/mcp-manifest.schema.json +230 -0
  169. package/factory/specs/schema/result.schema.json +81 -0
  170. package/factory/specs/schema/spec-change-request.schema.json +46 -0
  171. package/factory/specs/schema/task-spec.schema.json +134 -0
  172. package/package.json +98 -0
@@ -0,0 +1,208 @@
1
+ // Generic CLI judge transport (architecture §6; design decision: Codex runs
2
+ // headless via `codex exec`, ChatGPT-subscription auth — docs/runbooks/
3
+ // providers.md). Prompt delivery: STDIN, not a temp file — no filesystem
4
+ // cleanup/race to manage, and a CLI judge that reads a prompt off stdin
5
+ // (same as an interactive paste) needs no extra flag wiring per provider.
6
+ import { spawn } from 'node:child_process';
7
+ import { extractAndValidate } from './schema-validate.js';
8
+ import { ProviderError } from './types.js';
9
+ const NUDGE = '\n\nReturn only valid JSON per schema.';
10
+ const DIAGNOSTIC_TAIL_BYTES = 400;
11
+ /** Last few hundred bytes of a stream, collapsed to one line, for an error message. */
12
+ function tail(text) {
13
+ const trimmed = text.trim();
14
+ const cut = trimmed.length > DIAGNOSTIC_TAIL_BYTES ? trimmed.slice(-DIAGNOSTIC_TAIL_BYTES) : trimmed;
15
+ return cut.replace(/\s+/g, ' ');
16
+ }
17
+ /**
18
+ * What to quote back to the operator when a judge process failed.
19
+ *
20
+ * Do NOT prefer one stream over the other. Observed against the real binary:
21
+ * `codex exec --json` wrote progress to stderr ("Reading prompt from stdin...")
22
+ * and the actual refusal to stdout as a `{"type":"error"}` event, so a
23
+ * stderr-first rule quotes the chatter and drops the reason. That is why the
24
+ * `refuse` and `refuse-codex` fixtures are exact mirrors of each other.
25
+ *
26
+ * crosscheck.yml no longer passes `--json` (D-118), and plain `exec` splits the
27
+ * streams differently again — the whole prompt is echoed to stderr, the answer
28
+ * goes to stdout. Which only reinforces the rule: no CLI owes us a convention
29
+ * about where it says things, so the combined buffer is the only safe input.
30
+ *
31
+ * The combined tail is the answer in the common case. stderr is prepended only
32
+ * when the tail window no longer shows it, which happens when a chatty judge
33
+ * floods stdout after writing a short reason to stderr.
34
+ */
35
+ function diagnosticFor(outcome) {
36
+ const combined = tail(outcome.combined);
37
+ const errOnly = tail(outcome.stderr);
38
+ if (errOnly === '' || combined.includes(errOnly))
39
+ return combined;
40
+ return combined === '' ? errOnly : `${errOnly} | ${combined}`;
41
+ }
42
+ /**
43
+ * One CLI judge invocation: detached process group (POSIX `setsid` via
44
+ * `detached: true`) + group-kill on timeout or size-cap breach — mirrors
45
+ * testgate.ts's runOne() exactly (a judge CLI can spawn its own child
46
+ * processes; a plain per-child kill would orphan them the same way
47
+ * testgate's own regression note describes).
48
+ */
49
+ function spawnOnce(config, prompt, budget) {
50
+ return new Promise((resolve) => {
51
+ const child = spawn(config.command, config.args, {
52
+ detached: true,
53
+ stdio: ['pipe', 'pipe', 'pipe'],
54
+ });
55
+ let combined = '';
56
+ let stderr = '';
57
+ let totalBytes = 0;
58
+ let timedOut = false;
59
+ let sizeExceeded = false;
60
+ let settled = false;
61
+ let spawnError;
62
+ const killGroup = () => {
63
+ if (child.pid !== undefined) {
64
+ try {
65
+ process.kill(-child.pid, 'SIGKILL');
66
+ }
67
+ catch {
68
+ // Group already gone — fine.
69
+ }
70
+ }
71
+ };
72
+ const timer = setTimeout(() => {
73
+ timedOut = true;
74
+ killGroup();
75
+ }, budget.timeout_ms);
76
+ const onChunk = (chunk) => {
77
+ totalBytes += chunk.length;
78
+ if (totalBytes > budget.max_output_bytes) {
79
+ if (!sizeExceeded) {
80
+ sizeExceeded = true;
81
+ killGroup();
82
+ }
83
+ return;
84
+ }
85
+ combined += chunk.toString('utf8');
86
+ };
87
+ child.stdout?.on('data', onChunk);
88
+ child.stderr?.on('data', (chunk) => {
89
+ // Still folded into `combined` for extraction — a judge that writes its
90
+ // verdict to stderr has always worked and must keep working. The second
91
+ // copy exists only so a refusal reason can be reported on its own.
92
+ stderr += chunk.toString('utf8');
93
+ onChunk(chunk);
94
+ });
95
+ child.stdin?.on('error', () => {
96
+ // Judge exited before consuming stdin (e.g. a fixture that errors
97
+ // immediately) — writing to a closed pipe would otherwise raise an
98
+ // unhandled EPIPE; the close handler below still resolves correctly.
99
+ });
100
+ child.stdin?.write(prompt);
101
+ child.stdin?.end();
102
+ const finish = (exitCode, signal) => {
103
+ if (settled)
104
+ return;
105
+ settled = true;
106
+ clearTimeout(timer);
107
+ resolve({ combined, stderr, exitCode, signal, spawnError, timedOut, sizeExceeded });
108
+ };
109
+ child.on('error', (err) => {
110
+ // The child never ran (ENOENT, EACCES, …). Distinct from a child that
111
+ // ran and failed: 'close' carries no code in this case.
112
+ spawnError = err;
113
+ finish(null, null);
114
+ });
115
+ child.on('close', finish);
116
+ });
117
+ }
118
+ /**
119
+ * The buffer minus the prompt this process just sent, for the extractor to
120
+ * scan.
121
+ *
122
+ * D-195: plain `codex exec` echoes the prompt it read off stdin back onto
123
+ * stderr (see diagnosticFor above), and stderr is folded into `combined`. Every
124
+ * prompt this factory builds interpolates free text an earlier agent wrote — a
125
+ * finding's summary, its claimed failure scenario, a reviewer contract that
126
+ * spells out `[]` for a clean diff — so an answer-shaped string sitting in that
127
+ * text lands in the buffer AHEAD of the judge's own answer and wins
128
+ * extractAndValidate's first-valid-candidate rule. A reviewer who writes
129
+ * `{"verdict": "refute"}` into a summary refutes the finding against itself.
130
+ *
131
+ * The bytes removed are bytes this process sent, which is a fact about the
132
+ * exchange rather than a guess about the judge. Preferring the LAST candidate
133
+ * would be such a guess and breaks on a chatty judge or a protocol trailer;
134
+ * discarding any candidate whose text also occurs in the prompt would throw
135
+ * away a judge's legitimate `[]` clean review, turning a clean review into a
136
+ * hard failure.
137
+ */
138
+ function withoutEcho(combined, prompt) {
139
+ if (prompt === '')
140
+ return combined;
141
+ // Newline, not empty: candidates either side of an excised span must not be
142
+ // glued into one unparseable run.
143
+ return combined.split(prompt).join('\n');
144
+ }
145
+ /** One attempt: spawn, then extract+validate; throws on timeout/size-cap, returns the validation outcome otherwise. */
146
+ async function attempt(provider, config, prompt, request, attemptLabel) {
147
+ const outcome = await spawnOnce(config, prompt, request.budget);
148
+ if (outcome.timedOut) {
149
+ throw new ProviderError('provider.timeout', `Provider "${provider}" CLI judge timed out after ${request.budget.timeout_ms}ms${attemptLabel}.`, { provider });
150
+ }
151
+ if (outcome.sizeExceeded) {
152
+ throw new ProviderError('provider.output-too-large', `Provider "${provider}" CLI judge output exceeded the ${request.budget.max_output_bytes}-byte cap${attemptLabel}.`, { provider });
153
+ }
154
+ const answered = withoutEcho(outcome.combined, prompt);
155
+ return { validation: extractAndValidate(answered, request.schemaName), spawned: outcome };
156
+ }
157
+ /**
158
+ * D-116: the process either refused to run or ran and failed. Either way the
159
+ * defect is not in the prompt, so the caller must not nudge-and-retry — that
160
+ * spends a second call on an already-exhausted quota and then reports a schema
161
+ * critique of whatever protocol chatter the CLI printed before dying. Returns
162
+ * undefined when the process itself was healthy.
163
+ */
164
+ function processFailure(provider, config, outcome) {
165
+ if (outcome.spawnError !== undefined) {
166
+ const reason = outcome.spawnError.code ?? outcome.spawnError.message;
167
+ return new ProviderError('provider.cli-unavailable', `Provider "${provider}" CLI judge could not be started: ${config.command} (${reason}). ` +
168
+ 'Check that the binary is installed and on PATH — see docs/runbooks/providers.md.', { provider, command: config.command, reason });
169
+ }
170
+ if (outcome.exitCode !== null && outcome.exitCode !== 0) {
171
+ const diagnostic = diagnosticFor(outcome);
172
+ return new ProviderError('provider.cli-failed', `Provider "${provider}" CLI judge failed with exit ${outcome.exitCode} and no usable verdict` +
173
+ `${diagnostic ? `: ${diagnostic}` : '.'}`, { provider, exitCode: outcome.exitCode, diagnostic });
174
+ }
175
+ if (outcome.signal !== null) {
176
+ return new ProviderError('provider.cli-failed', `Provider "${provider}" CLI judge was killed by ${outcome.signal} without a usable verdict.`, { provider, signal: outcome.signal });
177
+ }
178
+ return undefined;
179
+ }
180
+ export async function runCliJudge(provider, config, request) {
181
+ const start = Date.now();
182
+ const first = await attempt(provider, config, request.prompt, request, '');
183
+ let result = first.validation;
184
+ if (!result.valid) {
185
+ // A judge that answered is a judge that answered, whatever its exit code —
186
+ // so this check comes *after* validation, never before it. But if the
187
+ // output is unusable AND the process itself failed, the prompt is not the
188
+ // suspect and a retry is waste: report what actually happened.
189
+ const processError = processFailure(provider, config, first.spawned);
190
+ if (processError !== undefined)
191
+ throw processError;
192
+ // ONE retry with a nudge appended — a fresh spawn (the failed attempt's process is already dead).
193
+ const second = await attempt(provider, config, `${request.prompt}${NUDGE}`, request, ' on retry');
194
+ result = second.validation;
195
+ if (!result.valid) {
196
+ const retryProcessError = processFailure(provider, config, second.spawned);
197
+ if (retryProcessError !== undefined)
198
+ throw retryProcessError;
199
+ throw new ProviderError('provider.invalid-output', `Provider "${provider}" CLI judge returned invalid output after one retry: ${result.reason}.`, { provider, reason: result.reason, errors: 'errors' in result ? result.errors : undefined });
200
+ }
201
+ }
202
+ return {
203
+ provider,
204
+ kind: request.kind,
205
+ output: result.value,
206
+ latency_ms: Date.now() - start,
207
+ };
208
+ }
@@ -0,0 +1,54 @@
1
+ // Provider registry (architecture §6: the judge tier is "provider-agnostic by
2
+ // contract", so "any model that can honor the contract can serve"). Adding a
3
+ // provider is a crosscheck.yml entry and nothing else — no file here, no
4
+ // branch below.
5
+ //
6
+ // It did not start that way. This function used to open with two name-keyed
7
+ // branches, `codex` and `deepseek`, each dispatching to a one-line module that
8
+ // forwarded to the same generic transport this file ends with, and each
9
+ // refusing to run if its name was paired with the other transport. The
10
+ // forwarding modules added no behaviour, and the refusals were the real cost:
11
+ // they turned two ordinary strings into reserved words with opinions. An
12
+ // operator reaching Codex over an OpenAI-compatible endpoint, or running
13
+ // DeepSeek's open weights as a local command, wrote a correct config and was
14
+ // told it was misconfigured — and an operator whose own provider happened to
15
+ // be called either name inherited a vendor's transport by coincidence of
16
+ // spelling. Both are configurations someone will write, and neither is wrong.
17
+ //
18
+ // So the name is now data all the way through: it selects a config entry and
19
+ // labels the result, and `transport` alone decides what runs. Keyed by
20
+ // crosscheck.yml's provider names; `claude` (kind: native) is never dispatched
21
+ // through here — it's the in-process judge already wired through
22
+ // gate.ts/findings.ts.
23
+ import { loadCrosscheckPolicy } from '../crosscheck.js';
24
+ import { runApiJudge } from './api-transport.js';
25
+ import { runCliJudge } from './cli-transport.js';
26
+ import { ProviderError } from './types.js';
27
+ export async function runJudge(providerName, request, opts = {}) {
28
+ const policy = opts.policy ?? loadCrosscheckPolicy();
29
+ const config = policy.providers[providerName];
30
+ if (!config) {
31
+ throw new ProviderError('provider.unknown', `Unknown provider "${providerName}" (not in crosscheck.yml).`, { provider: providerName });
32
+ }
33
+ if (config.kind === 'native') {
34
+ throw new ProviderError('provider.not-external', `Provider "${providerName}" is native (in-process); it has no CLI/API transport to run.`, { provider: providerName });
35
+ }
36
+ if (!config.enabled) {
37
+ throw new ProviderError('provider.disabled', `Provider "${providerName}" is disabled in crosscheck.yml (enabled: false) — never invoked.`, { provider: providerName });
38
+ }
39
+ // Every enabled, non-native provider lands here, whatever it is called.
40
+ // `transport` is validated when crosscheck.yml is parsed, so the two arms
41
+ // below are exhaustive and a provider that reached this point has the fields
42
+ // its arm reads.
43
+ if (config.transport === 'cli') {
44
+ return runCliJudge(providerName, { command: config.command, args: config.args }, request);
45
+ }
46
+ return runApiJudge(providerName, {
47
+ baseUrl: config.baseUrl,
48
+ model: config.model,
49
+ apiKeyEnv: config.apiKeyEnv,
50
+ responseFormatJsonObject: config.responseFormatJsonObject,
51
+ }, request, opts.fetchImpl);
52
+ }
53
+ export { ProviderError } from './types.js';
54
+ export { loadCrosscheckPolicy };
@@ -0,0 +1,152 @@
1
+ // Shared by cli-transport.ts and api-transport.ts: pull the first balanced
2
+ // JSON value out of a judge's raw text response (tolerating surrounding
3
+ // prose — LLMs routinely wrap JSON in a sentence or a markdown fence) and
4
+ // schema-validate it via the same schemas.ts/taxonomy.ts pipeline the rest
5
+ // of the orchestrator uses (gate.ts, findings.ts) — no bespoke validation
6
+ // path for provider output.
7
+ import { compileSchemas, validateRecord, } from '../schemas.js';
8
+ import { loadTaxonomy } from '../taxonomy.js';
9
+ let cachedTaxonomy;
10
+ let cachedSchemas;
11
+ function resolve(opts) {
12
+ if (cachedTaxonomy === undefined)
13
+ cachedTaxonomy = loadTaxonomy();
14
+ if (cachedSchemas === undefined)
15
+ cachedSchemas = compileSchemas(cachedTaxonomy);
16
+ return { taxonomy: opts.taxonomy ?? cachedTaxonomy, schemas: opts.schemas ?? cachedSchemas };
17
+ }
18
+ /**
19
+ * Every substring of `text` that starts at a `{`/`[`, balances to its matching
20
+ * `}`/`]` (respecting quoted strings/escapes), and parses as JSON — yielded in
21
+ * the order they appear. Candidate starts that balance but fail to parse are
22
+ * skipped rather than ending the scan.
23
+ *
24
+ * D-118: there is deliberately no "the first one is the answer" rule here.
25
+ * Real `codex exec` echoes the prompt back on stderr, the transport merges
26
+ * stderr into this buffer for extraction, and a prompt that mentions `[]` or
27
+ * shows a schema template therefore plants parseable JSON *ahead* of the
28
+ * verdict. Only the caller's schema can say which candidate is the answer.
29
+ */
30
+ function* balancedJsonCandidates(text) {
31
+ for (let i = 0; i < text.length; i++) {
32
+ const startChar = text[i];
33
+ if (startChar !== '{' && startChar !== '[')
34
+ continue;
35
+ const closeChar = startChar === '{' ? '}' : ']';
36
+ let depth = 0;
37
+ let inString = false;
38
+ let isEscaped = false;
39
+ for (let j = i; j < text.length; j++) {
40
+ const c = text[j];
41
+ if (inString) {
42
+ if (isEscaped)
43
+ isEscaped = false;
44
+ else if (c === '\\')
45
+ isEscaped = true;
46
+ else if (c === '"')
47
+ inString = false;
48
+ continue;
49
+ }
50
+ if (c === '"') {
51
+ inString = true;
52
+ continue;
53
+ }
54
+ // Only the START bracket's own family is depth-counted. Valid JSON
55
+ // never lets `{`/`[` cross-nest without also closing in order (e.g.
56
+ // "{[}]" is not legal JSON), so tracking one family is sufficient to
57
+ // find where the top-level value actually ends; JSON.parse below is
58
+ // the real correctness check regardless.
59
+ if (c === startChar)
60
+ depth++;
61
+ else if (c === closeChar) {
62
+ depth--;
63
+ if (depth === 0) {
64
+ const candidate = text.slice(i, j + 1);
65
+ let parsed;
66
+ try {
67
+ parsed = JSON.parse(candidate);
68
+ }
69
+ catch {
70
+ break; // not valid JSON after all — resume scanning from i + 1
71
+ }
72
+ yield parsed;
73
+ // Resume *after* this value rather than inside it: a nested object
74
+ // is part of the value already yielded, never a sibling answer.
75
+ i = j;
76
+ break;
77
+ }
78
+ }
79
+ }
80
+ }
81
+ }
82
+ /**
83
+ * The first balanced, parseable JSON value in `text`, or null if there is none.
84
+ * Unaware of any schema — kept for callers that genuinely want "whatever JSON
85
+ * this text contains". Judge output goes through `extractAndValidate`, which
86
+ * picks by schema; see D-118 on why the difference matters.
87
+ */
88
+ export function extractBalancedJson(text) {
89
+ for (const candidate of balancedJsonCandidates(text))
90
+ return candidate;
91
+ return null;
92
+ }
93
+ /**
94
+ * Schemas that describe ONE item a `kind: review` judge returns MANY of. Both
95
+ * of them: `finding` is the stored record (the native reviewer's contract,
96
+ * .claude/agents/reviewer.md), `finding-evidence` is the judge's half of it —
97
+ * the same evidence without the six identity fields the orchestrator owns.
98
+ * A set, not a comparison, because the second entry was added a year after the
99
+ * first and the `=== 'finding'` it replaced would have silently validated an
100
+ * array of evidence as a single malformed object.
101
+ */
102
+ const ARRAY_VALUED_SCHEMAS = new Set(['finding', 'finding-evidence']);
103
+ /**
104
+ * Extract + schema-validate a judge's raw text response. `finding.schema.json`
105
+ * describes ONE finding, but a `kind: review` judge's contract
106
+ * (.claude/agents/reviewer.md) returns an ARRAY of findings — so an array
107
+ * top-level value against an ARRAY_VALUED_SCHEMAS name validates element-wise;
108
+ * every other schema (e.g. "judge-verdict") validates the parsed value
109
+ * directly against its own object shape.
110
+ */
111
+ export function extractAndValidate(rawText, schemaName, opts = {}) {
112
+ const { taxonomy, schemas } = resolve(opts);
113
+ const validateOne = (parsed) => {
114
+ if (ARRAY_VALUED_SCHEMAS.has(schemaName) && Array.isArray(parsed)) {
115
+ const errors = [];
116
+ parsed.forEach((item, index) => {
117
+ const result = validateRecord(schemas, taxonomy, schemaName, item);
118
+ if (!result.valid) {
119
+ errors.push(...result.errors.map((e) => ({ path: `/${index}${e.path}`, message: e.message })));
120
+ }
121
+ });
122
+ if (errors.length > 0)
123
+ return { valid: false, reason: 'schema-invalid', errors };
124
+ return { valid: true, value: parsed };
125
+ }
126
+ const result = validateRecord(schemas, taxonomy, schemaName, parsed);
127
+ if (!result.valid)
128
+ return { valid: false, reason: 'schema-invalid', errors: result.errors };
129
+ return { valid: true, value: parsed };
130
+ };
131
+ // D-118: take the first candidate that VALIDATES, not the first that parses.
132
+ // The judge's answer is the thing shaped like an answer; a decoy that merely
133
+ // parses — a schema template, a protocol banner — is not made into one by
134
+ // arriving first.
135
+ //
136
+ // D-195: that bar does not stop a decoy that is itself a VALID answer. An
137
+ // echoed `[]` validates vacuously as an empty finding array, and a verdict
138
+ // planted in a finding's summary validates as a verdict. This function
139
+ // cannot tell those from the real thing — by the time text arrives here,
140
+ // who said it is gone. Keeping our own prompt out of `rawText` is the
141
+ // caller's job; cli-transport.ts's withoutEcho() does it.
142
+ let firstFailure;
143
+ for (const parsed of balancedJsonCandidates(rawText)) {
144
+ const result = validateOne(parsed);
145
+ if (result.valid)
146
+ return result;
147
+ // Keep the earliest failure so a judge that answered *badly* still gets a
148
+ // schema critique rather than a bare "no JSON found".
149
+ firstFailure ??= result;
150
+ }
151
+ return firstFailure ?? { valid: false, reason: 'no-json-found' };
152
+ }
@@ -0,0 +1,49 @@
1
+ // Provider-agnostic judge contract (architecture §6): "any model that can
2
+ // honor the I/O contract can serve" — a JudgeRequest is a plain diff+spec+
3
+ // prior-findings prompt already assembled by the caller from
4
+ // .claude/agents/{reviewer,verifier}.md's own contract, plus a schema
5
+ // name the extracted response is validated against. Transports never see
6
+ // more than `prompt` — the trust boundary (architecture §6 "External
7
+ // providers judge; they never gain write access... their findings are
8
+ // data, not commands") starts here: no transport is ever handed worktree
9
+ // paths, credentials beyond its own key, or anything callable.
10
+ import { SmithError } from '../errors.js';
11
+ export class ProviderError extends SmithError {
12
+ }
13
+ /** Codes raised after a provider answered, about the answer itself. */
14
+ export const JUDGE_SCHEMA_FAILURE_CODES = new Set([
15
+ 'provider.invalid-output',
16
+ 'provider.malformed-response',
17
+ 'provider.output-too-large',
18
+ ]);
19
+ /** Codes raised when no usable answer arrived -- including when no request was sent. */
20
+ export const JUDGE_TRANSPORT_FAILURE_CODES = new Set([
21
+ 'provider.auth-failed',
22
+ 'provider.cli-failed',
23
+ 'provider.cli-unavailable',
24
+ 'provider.disabled',
25
+ 'provider.http-error',
26
+ // Nothing raises this any more: it was the registry's refusal to run a
27
+ // provider whose name it recognised but whose transport it disagreed with,
28
+ // and names no longer decide transports. Kept because event logs written
29
+ // before that change still carry it, and a stats query over them should
30
+ // classify it rather than fall through to the default.
31
+ 'provider.misconfigured',
32
+ 'provider.missing-api-key',
33
+ 'provider.network-error',
34
+ 'provider.not-external',
35
+ 'provider.timeout',
36
+ 'provider.unknown',
37
+ 'provider.unknown-error',
38
+ ]);
39
+ /**
40
+ * Unrecognised codes read as `transport`, the claim-nothing default: a
41
+ * transport failure asserts only that the run did not reach a verdict, where
42
+ * a schema failure asserts the provider's output is unusable. A drift test
43
+ * (test/providers/judgeFailureKind.test.ts) keeps the two sets covering every
44
+ * code the source can raise, so the default stays a fallback and not a
45
+ * silent home for codes nobody classified.
46
+ */
47
+ export function judgeFailureKind(code) {
48
+ return JUDGE_SCHEMA_FAILURE_CODES.has(code) ? 'schema' : 'transport';
49
+ }