@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.
- package/.claude/agents/auditor.md +146 -0
- package/.claude/agents/coder.md +164 -0
- package/.claude/agents/grader.md +110 -0
- package/.claude/agents/merger.md +107 -0
- package/.claude/agents/planner.md +92 -0
- package/.claude/agents/researcher.md +105 -0
- package/.claude/agents/reviewer.md +171 -0
- package/.claude/agents/scribe.md +72 -0
- package/.claude/agents/security-reviewer.md +130 -0
- package/.claude/agents/spec-reviewer.md +215 -0
- package/.claude/agents/tester.md +121 -0
- package/.claude/agents/uiux.md +126 -0
- package/.claude/agents/verifier.md +103 -0
- package/.claude/agents/wave-runner.md +74 -0
- package/LICENSE +21 -0
- package/README.md +389 -0
- package/factory/orchestrator/dist/actors.js +46 -0
- package/factory/orchestrator/dist/agents-registry.js +308 -0
- package/factory/orchestrator/dist/args.js +84 -0
- package/factory/orchestrator/dist/artifacts.js +111 -0
- package/factory/orchestrator/dist/attribution.js +160 -0
- package/factory/orchestrator/dist/audit.js +812 -0
- package/factory/orchestrator/dist/autonomy.js +151 -0
- package/factory/orchestrator/dist/budgetAlarm.js +366 -0
- package/factory/orchestrator/dist/budgets.js +196 -0
- package/factory/orchestrator/dist/claims.js +633 -0
- package/factory/orchestrator/dist/cli.js +3185 -0
- package/factory/orchestrator/dist/commit.js +152 -0
- package/factory/orchestrator/dist/coverage.js +188 -0
- package/factory/orchestrator/dist/crossFinding.js +479 -0
- package/factory/orchestrator/dist/crosscheck.js +396 -0
- package/factory/orchestrator/dist/daemon.js +707 -0
- package/factory/orchestrator/dist/db/projector.js +1317 -0
- package/factory/orchestrator/dist/db/queries.js +1714 -0
- package/factory/orchestrator/dist/db/schema.js +305 -0
- package/factory/orchestrator/dist/delegation.js +400 -0
- package/factory/orchestrator/dist/diffstat.js +164 -0
- package/factory/orchestrator/dist/dispatchAudit.js +275 -0
- package/factory/orchestrator/dist/dotenv.js +72 -0
- package/factory/orchestrator/dist/effort.js +202 -0
- package/factory/orchestrator/dist/epic.js +1018 -0
- package/factory/orchestrator/dist/epicWidth.js +238 -0
- package/factory/orchestrator/dist/errorIssues.js +249 -0
- package/factory/orchestrator/dist/errors.js +11 -0
- package/factory/orchestrator/dist/escalation.js +500 -0
- package/factory/orchestrator/dist/events.js +1015 -0
- package/factory/orchestrator/dist/findingAge.js +91 -0
- package/factory/orchestrator/dist/findingContext.js +103 -0
- package/factory/orchestrator/dist/findings.js +1156 -0
- package/factory/orchestrator/dist/gate.js +911 -0
- package/factory/orchestrator/dist/gh.js +146 -0
- package/factory/orchestrator/dist/git.js +87 -0
- package/factory/orchestrator/dist/goalCheck.js +380 -0
- package/factory/orchestrator/dist/graph.js +61 -0
- package/factory/orchestrator/dist/hookDecision.js +106 -0
- package/factory/orchestrator/dist/immutability.js +171 -0
- package/factory/orchestrator/dist/impact.js +280 -0
- package/factory/orchestrator/dist/integration.js +141 -0
- package/factory/orchestrator/dist/judgePreflight.js +155 -0
- package/factory/orchestrator/dist/judges.js +310 -0
- package/factory/orchestrator/dist/lessonAudit.js +399 -0
- package/factory/orchestrator/dist/lessons.js +1329 -0
- package/factory/orchestrator/dist/mcp.js +555 -0
- package/factory/orchestrator/dist/paths.js +82 -0
- package/factory/orchestrator/dist/plan.js +502 -0
- package/factory/orchestrator/dist/planQuorum.js +421 -0
- package/factory/orchestrator/dist/policy.js +1308 -0
- package/factory/orchestrator/dist/policyHook.js +28 -0
- package/factory/orchestrator/dist/preconditions.js +47 -0
- package/factory/orchestrator/dist/projects.js +134 -0
- package/factory/orchestrator/dist/prompts.js +57 -0
- package/factory/orchestrator/dist/provenance.js +201 -0
- package/factory/orchestrator/dist/providers/api-transport.js +117 -0
- package/factory/orchestrator/dist/providers/cli-transport.js +208 -0
- package/factory/orchestrator/dist/providers/index.js +54 -0
- package/factory/orchestrator/dist/providers/schema-validate.js +152 -0
- package/factory/orchestrator/dist/providers/types.js +49 -0
- package/factory/orchestrator/dist/queue.js +263 -0
- package/factory/orchestrator/dist/quorum.js +347 -0
- package/factory/orchestrator/dist/quorumEscalations.js +132 -0
- package/factory/orchestrator/dist/results.js +65 -0
- package/factory/orchestrator/dist/roadmap.js +437 -0
- package/factory/orchestrator/dist/runtime.js +86 -0
- package/factory/orchestrator/dist/sameMistakeKpi.js +243 -0
- package/factory/orchestrator/dist/sandbox.js +165 -0
- package/factory/orchestrator/dist/scaffold.js +373 -0
- package/factory/orchestrator/dist/scheduler.js +408 -0
- package/factory/orchestrator/dist/schemas.js +195 -0
- package/factory/orchestrator/dist/security.js +222 -0
- package/factory/orchestrator/dist/severity.js +293 -0
- package/factory/orchestrator/dist/spec.js +376 -0
- package/factory/orchestrator/dist/specChange.js +497 -0
- package/factory/orchestrator/dist/stack.js +324 -0
- package/factory/orchestrator/dist/symbols.js +966 -0
- package/factory/orchestrator/dist/taskEvents.js +399 -0
- package/factory/orchestrator/dist/taskId.js +126 -0
- package/factory/orchestrator/dist/taxonomy.js +113 -0
- package/factory/orchestrator/dist/testSelect.js +129 -0
- package/factory/orchestrator/dist/testerAudit.js +264 -0
- package/factory/orchestrator/dist/testgate.js +171 -0
- package/factory/orchestrator/dist/usage.js +874 -0
- package/factory/orchestrator/dist/waivers.js +228 -0
- package/factory/orchestrator/dist/waveBudget.js +223 -0
- package/factory/orchestrator/dist/waveConcurrency.js +250 -0
- package/factory/orchestrator/dist/waveNext.js +210 -0
- package/factory/orchestrator/dist/waveSchedule.js +146 -0
- package/factory/orchestrator/dist/worktree.js +207 -0
- package/factory/orchestrator/drizzle/0000_broken_rockslide.sql +183 -0
- package/factory/orchestrator/drizzle/0001_luxuriant_zaladane.sql +1 -0
- package/factory/orchestrator/drizzle/0002_yellow_wild_pack.sql +8 -0
- package/factory/orchestrator/drizzle/0003_damp_riptide.sql +12 -0
- package/factory/orchestrator/drizzle/0004_broad_lady_vermin.sql +1 -0
- package/factory/orchestrator/drizzle/0005_romantic_jocasta.sql +2 -0
- package/factory/orchestrator/drizzle/0006_bent_madelyne_pryor.sql +16 -0
- package/factory/orchestrator/drizzle/0007_overjoyed_goliath.sql +1 -0
- package/factory/orchestrator/drizzle/0008_smart_iron_patriot.sql +2 -0
- package/factory/orchestrator/drizzle/0009_agents_epic_id.sql +2 -0
- package/factory/orchestrator/drizzle/0010_milestone_kind.sql +1 -0
- package/factory/orchestrator/drizzle/meta/_journal.json +83 -0
- package/factory/policies/budgets.yml +262 -0
- package/factory/policies/crosscheck.yml +308 -0
- package/factory/policies/delegation.yml +44 -0
- package/factory/policies/effort.yml +142 -0
- package/factory/policies/guardrails.yml +306 -0
- package/factory/policies/lessons.md +239 -0
- package/factory/policies/scheduler.yml +90 -0
- package/factory/policies/sensitive-paths.yml +148 -0
- package/factory/policies/severity.yml +83 -0
- package/factory/policies/stack.yml +60 -0
- package/factory/policies/taxonomy.yml +340 -0
- package/factory/policies/worktree.yml +95 -0
- package/factory/scaffold/base/.github/workflows/ci.yml +34 -0
- package/factory/scaffold/base/.gitignore.tmpl +11 -0
- package/factory/scaffold/base/AGENTS.md +33 -0
- package/factory/scaffold/base/README.md +25 -0
- package/factory/scaffold/base/biome.json.tmpl +30 -0
- package/factory/scaffold/base/package.json +25 -0
- package/factory/scaffold/base/pnpm-workspace.yaml.tmpl +16 -0
- package/factory/scaffold/base/src/index.ts +4 -0
- package/factory/scaffold/base/test/index.test.ts +8 -0
- package/factory/scaffold/base/tsconfig.json +20 -0
- package/factory/scaffold/base/tsconfig.test.json +9 -0
- package/factory/scaffold/base/vitest.config.ts +29 -0
- package/factory/scaffold/mcp/mcp.manifest.json +22 -0
- package/factory/scaffold/mcp/package.fragment.json +9 -0
- package/factory/scaffold/mcp/src/mcp/README.md +63 -0
- package/factory/scaffold/mcp/src/mcp/guard.ts +110 -0
- package/factory/scaffold/mcp/src/mcp/redact.ts +69 -0
- package/factory/scaffold/mcp/src/mcp/server.ts +55 -0
- package/factory/scaffold/mcp/src/mcp/tools/health.ts +60 -0
- package/factory/scaffold/mcp/test/mcp/guard.test.ts +108 -0
- package/factory/scaffold/mcp/test/mcp/redact.test.ts +61 -0
- package/factory/scaffold/mcp/test/mcp/surface.test.ts +100 -0
- package/factory/scaffold/ui/index.html +12 -0
- package/factory/scaffold/ui/package.fragment.json +13 -0
- package/factory/scaffold/ui/src/App.vue +10 -0
- package/factory/scaffold/ui/src/main.ts +5 -0
- package/factory/scaffold/ui/src/shims.d.ts +11 -0
- package/factory/scaffold/ui/vite.config.ts +20 -0
- package/factory/scaffold/ui-tailwind/package.fragment.json +6 -0
- package/factory/scaffold/ui-tailwind/vite.config.ts +26 -0
- package/factory/specs/schema/event.schema.json +82 -0
- package/factory/specs/schema/finding-evidence.schema.json +45 -0
- package/factory/specs/schema/finding.schema.json +115 -0
- package/factory/specs/schema/grader-verdict.schema.json +50 -0
- package/factory/specs/schema/judge-verdict.schema.json +19 -0
- package/factory/specs/schema/lesson.schema.json +85 -0
- package/factory/specs/schema/mcp-manifest.schema.json +230 -0
- package/factory/specs/schema/result.schema.json +81 -0
- package/factory/specs/schema/spec-change-request.schema.json +46 -0
- package/factory/specs/schema/task-spec.schema.json +134 -0
- 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
|
+
}
|