@juzser/blacksmith 0.1.0 → 0.2.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/coder.md +9 -3
- package/.claude/agents/grader.md +13 -5
- package/.claude/agents/merger.md +2 -0
- package/.claude/agents/planner.md +22 -1
- package/.claude/agents/researcher.md +3 -1
- package/.claude/agents/reviewer.md +9 -2
- package/.claude/agents/security-reviewer.md +15 -7
- package/.claude/agents/spec-reviewer.md +12 -3
- package/.claude/agents/tester.md +5 -2
- package/.claude/agents/uiux.md +10 -3
- package/.claude/agents/verifier.md +8 -3
- package/.claude/skills/bs/SKILL.md +119 -0
- package/.claude/skills/bs/audit.md +252 -0
- package/.claude/skills/bs/dispatch.md +394 -0
- package/.claude/skills/bs/lessons.md +73 -0
- package/.claude/skills/bs/mcp.md +29 -0
- package/.claude/skills/bs/new.md +35 -0
- package/.claude/skills/bs/plan.md +172 -0
- package/.claude/skills/bs/report.md +129 -0
- package/.claude/skills/bs/run.md +327 -0
- package/.claude/skills/bs/status.md +54 -0
- package/.claude/skills/bs/ui.md +19 -0
- package/.claude/skills/bs/waivers.md +13 -0
- package/.claude/skills/bs/wave.md +267 -0
- package/README.md +136 -79
- package/factory/orchestrator/dist/actors.js +4 -3
- package/factory/orchestrator/dist/agents-registry.js +96 -19
- package/factory/orchestrator/dist/artifacts.js +36 -4
- package/factory/orchestrator/dist/budgets.js +7 -5
- package/factory/orchestrator/dist/claims.js +27 -1
- package/factory/orchestrator/dist/cli.js +196 -27
- package/factory/orchestrator/dist/crosscheck.js +11 -0
- package/factory/orchestrator/dist/daemon.js +30 -5
- package/factory/orchestrator/dist/db/projector.js +272 -90
- package/factory/orchestrator/dist/db/queries.js +83 -14
- package/factory/orchestrator/dist/db/schema.js +7 -0
- package/factory/orchestrator/dist/events.js +88 -4
- package/factory/orchestrator/dist/harness.js +516 -0
- package/factory/orchestrator/dist/impact.js +76 -5
- package/factory/orchestrator/dist/judges.js +49 -8
- package/factory/orchestrator/dist/lessons.js +4 -4
- package/factory/orchestrator/dist/mcp.js +4 -3
- package/factory/orchestrator/dist/paths.js +169 -15
- package/factory/orchestrator/dist/plan.js +61 -1
- package/factory/orchestrator/dist/planQuorum.js +36 -8
- package/factory/orchestrator/dist/providers/api-transport.js +62 -5
- package/factory/orchestrator/dist/providers/cli-transport.js +7 -73
- package/factory/orchestrator/dist/providers/index.js +1 -0
- package/factory/orchestrator/dist/providers/schema-validate.js +58 -7
- package/factory/orchestrator/dist/providers/types.js +5 -0
- package/factory/orchestrator/dist/roadmap.js +3 -3
- package/factory/orchestrator/dist/run-cli.js +130 -0
- package/factory/orchestrator/dist/runner.js +264 -0
- package/factory/orchestrator/dist/scaffold.js +3 -2
- package/factory/orchestrator/dist/spawn.js +84 -0
- package/factory/orchestrator/dist/stack.js +10 -2
- package/factory/orchestrator/dist/templateFragments.js +178 -0
- package/factory/orchestrator/dist/usage.js +30 -9
- package/factory/orchestrator/dist/waveNext.js +42 -14
- package/factory/orchestrator/dist/waveSchedule.js +7 -1
- package/factory/orchestrator/dist/workroot.js +113 -0
- package/factory/orchestrator/drizzle/0011_findings_spec_ref.sql +3 -0
- package/factory/orchestrator/drizzle/meta/_journal.json +7 -0
- package/factory/policies/budgets.yml +7 -4
- package/factory/policies/crosscheck.yml +10 -8
- package/factory/policies/harness.yml +82 -0
- package/factory/policies/lessons.md +122 -0
- package/factory/policies/severity.yml +1 -1
- package/factory/policies/taxonomy.yml +6 -2
- package/factory/specs/roadmap.md +154 -0
- package/factory/specs/schema/task-spec.schema.json +5 -0
- package/package.json +10 -7
|
@@ -7,6 +7,12 @@ import { extractAndValidate } from './schema-validate.js';
|
|
|
7
7
|
import { ProviderError } from './types.js';
|
|
8
8
|
const NUDGE = '\n\nReturn only valid JSON per schema.';
|
|
9
9
|
const RESPONSE_BODY_ERROR_SNIPPET_LIMIT = 500;
|
|
10
|
+
// Raw answer kept on a rejected verdict: enough head to see what the model
|
|
11
|
+
// started writing, enough tail to see where it stopped. A truncated JSON
|
|
12
|
+
// answer and a prose answer look alike in a bare `no-json-found`.
|
|
13
|
+
const CONTENT_SNIPPET_HEAD = 300;
|
|
14
|
+
const CONTENT_SNIPPET_TAIL = 200;
|
|
15
|
+
const CONTENT_SNIPPET_JOINER = ' … ';
|
|
10
16
|
function resolveApiKey(config, provider) {
|
|
11
17
|
const key = process.env[config.apiKeyEnv];
|
|
12
18
|
if (!key) {
|
|
@@ -14,9 +20,31 @@ function resolveApiKey(config, provider) {
|
|
|
14
20
|
}
|
|
15
21
|
return key;
|
|
16
22
|
}
|
|
23
|
+
/**
|
|
24
|
+
* The raw answer as it may appear in an error: the API key scrubbed the same
|
|
25
|
+
* way a non-OK body is, and bounded to head + tail so a 64K truncation shows
|
|
26
|
+
* its last bytes instead of nothing.
|
|
27
|
+
*/
|
|
28
|
+
function contentSnippet(content, apiKey) {
|
|
29
|
+
const scrubbed = content.split(apiKey).join('[REDACTED]');
|
|
30
|
+
if (scrubbed.length <= CONTENT_SNIPPET_HEAD + CONTENT_SNIPPET_TAIL)
|
|
31
|
+
return scrubbed;
|
|
32
|
+
return `${scrubbed.slice(0, CONTENT_SNIPPET_HEAD)}${CONTENT_SNIPPET_JOINER}${scrubbed.slice(-CONTENT_SNIPPET_TAIL)}`;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Whether a second call with the nudge appended can change anything. It can
|
|
36
|
+
* when the model finished on its own (`stop`) — the prompt is the suspect. It
|
|
37
|
+
* cannot when the model was cut off at `max_tokens`: the same prompt runs to
|
|
38
|
+
* the same cap and bills the same tokens. An absent finish_reason is read as
|
|
39
|
+
* `stop` so a provider that omits the field keeps the retry it always had.
|
|
40
|
+
*/
|
|
41
|
+
function nudgeCanHelp(finishReason) {
|
|
42
|
+
return finishReason === undefined || finishReason === 'stop';
|
|
43
|
+
}
|
|
17
44
|
async function callOnce(provider, config, apiKey, prompt, budget, fetchImpl) {
|
|
18
45
|
const controller = new AbortController();
|
|
19
46
|
const timer = setTimeout(() => controller.abort(), budget.timeout_ms);
|
|
47
|
+
const maxTokens = budget.max_output_tokens ?? config.maxTokens;
|
|
20
48
|
let response;
|
|
21
49
|
try {
|
|
22
50
|
response = await fetchImpl(`${config.baseUrl.replace(/\/$/, '')}/chat/completions`, {
|
|
@@ -27,6 +55,7 @@ async function callOnce(provider, config, apiKey, prompt, budget, fetchImpl) {
|
|
|
27
55
|
messages: [{ role: 'user', content: prompt }],
|
|
28
56
|
stream: false,
|
|
29
57
|
...(config.responseFormatJsonObject ? { response_format: { type: 'json_object' } } : {}),
|
|
58
|
+
...(maxTokens === undefined ? {} : { max_tokens: maxTokens }),
|
|
30
59
|
}),
|
|
31
60
|
signal: controller.signal,
|
|
32
61
|
});
|
|
@@ -65,13 +94,39 @@ async function callOnce(provider, config, apiKey, prompt, budget, fetchImpl) {
|
|
|
65
94
|
catch {
|
|
66
95
|
throw new ProviderError('provider.malformed-response', `Provider "${provider}" API judge response was not valid JSON.`, { provider });
|
|
67
96
|
}
|
|
68
|
-
const
|
|
97
|
+
const choice = parsed.choices?.[0];
|
|
98
|
+
const content = choice?.message?.content ?? '';
|
|
69
99
|
return {
|
|
70
100
|
content,
|
|
71
101
|
usage: parsed.usage
|
|
72
102
|
? { input_tokens: parsed.usage.prompt_tokens, output_tokens: parsed.usage.completion_tokens }
|
|
73
103
|
: undefined,
|
|
104
|
+
finishReason: typeof choice?.finish_reason === 'string' ? choice.finish_reason : undefined,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* The error for an answer that never validated. Two codes, because the two
|
|
109
|
+
* situations are fixed in different places: `output-truncated` (the model
|
|
110
|
+
* stopped at the cap mid-answer) is fixed by a cap or a shorter prompt;
|
|
111
|
+
* `invalid-output` (the model finished and still did not answer the schema)
|
|
112
|
+
* is fixed at the prompt. Both carry the raw answer, scrubbed and bounded, so
|
|
113
|
+
* the operator can tell the two apart in the log instead of guessing from a
|
|
114
|
+
* reason string.
|
|
115
|
+
*/
|
|
116
|
+
function rejectedAnswerError(provider, apiKey, call, result, retried) {
|
|
117
|
+
const details = {
|
|
118
|
+
provider,
|
|
119
|
+
reason: result.reason,
|
|
120
|
+
errors: result.errors,
|
|
121
|
+
finish_reason: call.finishReason,
|
|
122
|
+
content_length: call.content.length,
|
|
123
|
+
content_snippet: contentSnippet(call.content, apiKey),
|
|
124
|
+
retried,
|
|
74
125
|
};
|
|
126
|
+
if (call.finishReason === 'length') {
|
|
127
|
+
return new ProviderError('provider.output-truncated', `Provider "${provider}" API judge stopped at its output cap (finish_reason "length") after ${call.content.length} characters, before the answer closed; not retried. Raise max_tokens for the provider (crosscheck.yml) or --max-output-tokens, or shorten the prompt.`, details);
|
|
128
|
+
}
|
|
129
|
+
return new ProviderError('provider.invalid-output', `Provider "${provider}" API judge returned invalid output${retried ? ' after one retry' : ''}: ${result.reason}.`, details);
|
|
75
130
|
}
|
|
76
131
|
/**
|
|
77
132
|
* Both attempts, added together — the provider billed both, and this is the
|
|
@@ -99,13 +154,15 @@ export async function runApiJudge(provider, config, request, fetchImpl = fetch)
|
|
|
99
154
|
let call = await callOnce(provider, config, apiKey, request.prompt, request.budget, fetchImpl);
|
|
100
155
|
let usage = call.usage;
|
|
101
156
|
let result = extractAndValidate(call.content, request.schemaName);
|
|
102
|
-
|
|
157
|
+
let retried = false;
|
|
158
|
+
if (!result.valid && nudgeCanHelp(call.finishReason)) {
|
|
103
159
|
call = await callOnce(provider, config, apiKey, `${request.prompt}${NUDGE}`, request.budget, fetchImpl);
|
|
104
160
|
usage = addUsage(usage, call.usage);
|
|
105
161
|
result = extractAndValidate(call.content, request.schemaName);
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
162
|
+
retried = true;
|
|
163
|
+
}
|
|
164
|
+
if (!result.valid) {
|
|
165
|
+
throw rejectedAnswerError(provider, apiKey, call, { reason: result.reason, errors: 'errors' in result ? result.errors : undefined }, retried);
|
|
109
166
|
}
|
|
110
167
|
return {
|
|
111
168
|
provider,
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// providers.md). Prompt delivery: STDIN, not a temp file — no filesystem
|
|
4
4
|
// cleanup/race to manage, and a CLI judge that reads a prompt off stdin
|
|
5
5
|
// (same as an interactive paste) needs no extra flag wiring per provider.
|
|
6
|
-
import {
|
|
6
|
+
import { spawnCapped } from '../spawn.js';
|
|
7
7
|
import { extractAndValidate } from './schema-validate.js';
|
|
8
8
|
import { ProviderError } from './types.js';
|
|
9
9
|
const NUDGE = '\n\nReturn only valid JSON per schema.';
|
|
@@ -40,80 +40,14 @@ function diagnosticFor(outcome) {
|
|
|
40
40
|
return combined === '' ? errOnly : `${errOnly} | ${combined}`;
|
|
41
41
|
}
|
|
42
42
|
/**
|
|
43
|
-
* One CLI judge invocation
|
|
44
|
-
* `
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
43
|
+
* One CLI judge invocation via the shared capped spawn (src/spawn.ts). No
|
|
44
|
+
* `cwd` in the call: a CLI judge transport is handed a prompt and nothing
|
|
45
|
+
* else — architecture §18 rule 5, "judges never gain write access" — and a
|
|
46
|
+
* process started with no working directory of its own has no worktree to
|
|
47
|
+
* write into even if its command line wanted one.
|
|
48
48
|
*/
|
|
49
49
|
function spawnOnce(config, prompt, budget) {
|
|
50
|
-
return
|
|
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
|
-
});
|
|
50
|
+
return spawnCapped({ command: config.command, args: config.args }, prompt, budget);
|
|
117
51
|
}
|
|
118
52
|
/**
|
|
119
53
|
* The buffer minus the prompt this process just sent, for the extractor to
|
|
@@ -48,6 +48,7 @@ export async function runJudge(providerName, request, opts = {}) {
|
|
|
48
48
|
model: config.model,
|
|
49
49
|
apiKeyEnv: config.apiKeyEnv,
|
|
50
50
|
responseFormatJsonObject: config.responseFormatJsonObject,
|
|
51
|
+
...(config.maxTokens === undefined ? {} : { maxTokens: config.maxTokens }),
|
|
51
52
|
}, request, opts.fetchImpl);
|
|
52
53
|
}
|
|
53
54
|
export { ProviderError } from './types.js';
|
|
@@ -100,20 +100,71 @@ export function extractBalancedJson(text) {
|
|
|
100
100
|
* array of evidence as a single malformed object.
|
|
101
101
|
*/
|
|
102
102
|
const ARRAY_VALUED_SCHEMAS = new Set(['finding', 'finding-evidence']);
|
|
103
|
+
/**
|
|
104
|
+
* The list a judge answered with for an array-valued schema, or undefined when
|
|
105
|
+
* it answered with some other shape. A bare array is the list. An object with
|
|
106
|
+
* exactly one key whose value is an array is the list wrapped — the shape an
|
|
107
|
+
* API judge under `response_format: json_object` has to produce, because that
|
|
108
|
+
* mode refuses a top-level array (FD-41; deepseek-chat answered
|
|
109
|
+
* `{"findings":[...]}` and lost the whole review to `schema-invalid`).
|
|
110
|
+
* Anything else — a bare record, a wrapper with a second key, a scalar — is
|
|
111
|
+
* refused: `balancedJsonCandidates` never descends into a value it has
|
|
112
|
+
* yielded, so this is the only place a wrapped list can be recognised, and it
|
|
113
|
+
* recognises exactly that one shape rather than searching for an array
|
|
114
|
+
* somewhere inside the answer.
|
|
115
|
+
*/
|
|
116
|
+
function unwrapArray(parsed) {
|
|
117
|
+
if (Array.isArray(parsed))
|
|
118
|
+
return parsed;
|
|
119
|
+
if (typeof parsed !== 'object' || parsed === null)
|
|
120
|
+
return undefined;
|
|
121
|
+
const keys = Object.keys(parsed);
|
|
122
|
+
if (keys.length !== 1)
|
|
123
|
+
return undefined;
|
|
124
|
+
const inner = parsed[keys[0]];
|
|
125
|
+
return Array.isArray(inner) ? inner : undefined;
|
|
126
|
+
}
|
|
127
|
+
function describeShape(parsed) {
|
|
128
|
+
if (Array.isArray(parsed))
|
|
129
|
+
return 'an array';
|
|
130
|
+
if (parsed === null)
|
|
131
|
+
return 'null';
|
|
132
|
+
if (typeof parsed === 'object')
|
|
133
|
+
return `an object with ${Object.keys(parsed).length} key(s)`;
|
|
134
|
+
return `a ${typeof parsed}`;
|
|
135
|
+
}
|
|
103
136
|
/**
|
|
104
137
|
* Extract + schema-validate a judge's raw text response. `finding.schema.json`
|
|
105
138
|
* describes ONE finding, but a `kind: review` judge's contract
|
|
106
|
-
* (.claude/agents/reviewer.md) returns an ARRAY of findings — so an
|
|
107
|
-
* top-level value
|
|
108
|
-
*
|
|
109
|
-
*
|
|
139
|
+
* (.claude/agents/reviewer.md) returns an ARRAY of findings — so against an
|
|
140
|
+
* ARRAY_VALUED_SCHEMAS name the top-level value must be that array, bare or
|
|
141
|
+
* wrapped in one single-key object (see `unwrapArray`), and validates
|
|
142
|
+
* element-wise; the value returned is the array itself, unwrapped. A bare
|
|
143
|
+
* record is refused there even when it would validate on its own: one finding
|
|
144
|
+
* where a list was asked for is a judge that misread its contract, and
|
|
145
|
+
* accepting it would file the answer as a review of exactly one issue. Every
|
|
146
|
+
* other schema (e.g. "judge-verdict") validates the parsed value directly
|
|
147
|
+
* against its own object shape.
|
|
110
148
|
*/
|
|
111
149
|
export function extractAndValidate(rawText, schemaName, opts = {}) {
|
|
112
150
|
const { taxonomy, schemas } = resolve(opts);
|
|
113
151
|
const validateOne = (parsed) => {
|
|
114
|
-
if (ARRAY_VALUED_SCHEMAS.has(schemaName)
|
|
152
|
+
if (ARRAY_VALUED_SCHEMAS.has(schemaName)) {
|
|
153
|
+
const items = unwrapArray(parsed);
|
|
154
|
+
if (items === undefined) {
|
|
155
|
+
return {
|
|
156
|
+
valid: false,
|
|
157
|
+
reason: 'schema-invalid',
|
|
158
|
+
errors: [
|
|
159
|
+
{
|
|
160
|
+
path: '',
|
|
161
|
+
message: `expected an array of ${schemaName} records (or one object whose single key holds that array); got ${describeShape(parsed)}`,
|
|
162
|
+
},
|
|
163
|
+
],
|
|
164
|
+
};
|
|
165
|
+
}
|
|
115
166
|
const errors = [];
|
|
116
|
-
|
|
167
|
+
items.forEach((item, index) => {
|
|
117
168
|
const result = validateRecord(schemas, taxonomy, schemaName, item);
|
|
118
169
|
if (!result.valid) {
|
|
119
170
|
errors.push(...result.errors.map((e) => ({ path: `/${index}${e.path}`, message: e.message })));
|
|
@@ -121,7 +172,7 @@ export function extractAndValidate(rawText, schemaName, opts = {}) {
|
|
|
121
172
|
});
|
|
122
173
|
if (errors.length > 0)
|
|
123
174
|
return { valid: false, reason: 'schema-invalid', errors };
|
|
124
|
-
return { valid: true, value:
|
|
175
|
+
return { valid: true, value: items };
|
|
125
176
|
}
|
|
126
177
|
const result = validateRecord(schemas, taxonomy, schemaName, parsed);
|
|
127
178
|
if (!result.valid)
|
|
@@ -15,6 +15,11 @@ export const JUDGE_SCHEMA_FAILURE_CODES = new Set([
|
|
|
15
15
|
'provider.invalid-output',
|
|
16
16
|
'provider.malformed-response',
|
|
17
17
|
'provider.output-too-large',
|
|
18
|
+
// The provider stopped writing at its output cap (finish_reason `length`)
|
|
19
|
+
// before the answer closed. The answer is unusable for the same reason a
|
|
20
|
+
// malformed one is, and the fix is the same side of the wire: a cap that
|
|
21
|
+
// fits the answer, or a prompt that asks for a shorter one.
|
|
22
|
+
'provider.output-truncated',
|
|
18
23
|
]);
|
|
19
24
|
/** Codes raised when no usable answer arrived -- including when no request was sent. */
|
|
20
25
|
export const JUDGE_TRANSPORT_FAILURE_CODES = new Set([
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// entry (documented as a deviation in ui/docs/DESIGN.md).
|
|
12
12
|
import { readFileSync } from 'node:fs';
|
|
13
13
|
import { SmithError } from './errors.js';
|
|
14
|
-
import {
|
|
14
|
+
import { roadmapReadPath } from './paths.js';
|
|
15
15
|
export class RoadmapError extends SmithError {
|
|
16
16
|
}
|
|
17
17
|
/**
|
|
@@ -347,7 +347,7 @@ export function parseRoadmap(markdown) {
|
|
|
347
347
|
* guard is around the read rather than an existsSync in front of it so a
|
|
348
348
|
* directory, a permission error and a missing file all answer the same way.
|
|
349
349
|
*/
|
|
350
|
-
export function readRoadmapText(roadmapPath =
|
|
350
|
+
export function readRoadmapText(roadmapPath = roadmapReadPath()) {
|
|
351
351
|
try {
|
|
352
352
|
return readFileSync(roadmapPath, 'utf8');
|
|
353
353
|
}
|
|
@@ -392,7 +392,7 @@ export function roadmapDeclaresId(markdown, id) {
|
|
|
392
392
|
return false;
|
|
393
393
|
}
|
|
394
394
|
/** Read and parse roadmap.md from disk (defaults to the real repo path). */
|
|
395
|
-
export function loadRoadmap(roadmapPath =
|
|
395
|
+
export function loadRoadmap(roadmapPath = roadmapReadPath()) {
|
|
396
396
|
return parseRoadmap(readRoadmapText(roadmapPath));
|
|
397
397
|
}
|
|
398
398
|
/**
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// smith-run: a separate executable, not a `smith` verb. `smith harness plan`
|
|
3
|
+
// renders a WorkerInvocation and stops there — architecture §18 rule 3,
|
|
4
|
+
// "nothing that observes may dispatch". This program is the only thing in
|
|
5
|
+
// this repo that actually starts one, and it does nothing else: no event
|
|
6
|
+
// log, no state/, no DB (see runner.ts's own header and its source-level
|
|
7
|
+
// test in test/runner.test.ts).
|
|
8
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
9
|
+
import { parseArgs } from './args.js';
|
|
10
|
+
import { runInvocation } from './runner.js';
|
|
11
|
+
const HELP = `Usage: smith-run <invocation.json | -> [--prompt-file <path>] [--timeout-ms <n>] [--out <file>]
|
|
12
|
+
|
|
13
|
+
Spawns ONE already-rendered worker invocation (the JSON \`smith harness plan\`
|
|
14
|
+
prints) and reports what happened as JSON. This is not \`smith harness run\`:
|
|
15
|
+
\`smith\` only ever renders an invocation and never starts it (§18 rule 3,
|
|
16
|
+
"nothing that observes may dispatch"), so starting the process an invocation
|
|
17
|
+
describes lives in its own binary instead.
|
|
18
|
+
|
|
19
|
+
Arguments:
|
|
20
|
+
<invocation.json> Path to a rendered WorkerInvocation, or "-" for stdin.
|
|
21
|
+
|
|
22
|
+
Options:
|
|
23
|
+
--prompt-file <path> Read the prompt from this file instead of invocation.promptFile.
|
|
24
|
+
--timeout-ms <n> Override invocation.budget.timeout_ms for this run.
|
|
25
|
+
--out <file> Write the JSON result here instead of stdout.
|
|
26
|
+
--help Show this message.
|
|
27
|
+
|
|
28
|
+
Exit codes:
|
|
29
|
+
0 completed (exit 0), answer validated against its schema (or no schema set)
|
|
30
|
+
1 the harness process failed to start, or exited non-zero, or the harness
|
|
31
|
+
reported an error in its output
|
|
32
|
+
2 completed (exit 0), but schema validation failed
|
|
33
|
+
3 timed out, or exceeded the output size cap
|
|
34
|
+
`;
|
|
35
|
+
const FLAG_SPEC = new Map([
|
|
36
|
+
['prompt-file', true],
|
|
37
|
+
['timeout-ms', true],
|
|
38
|
+
['out', true],
|
|
39
|
+
['help', false],
|
|
40
|
+
]);
|
|
41
|
+
function readInvocation(source) {
|
|
42
|
+
const text = source === '-' ? readFileSync(0, 'utf8') : readFileSync(source, 'utf8');
|
|
43
|
+
return JSON.parse(text);
|
|
44
|
+
}
|
|
45
|
+
/** See HELP's own exit-code table — kept in one place so the two cannot drift. */
|
|
46
|
+
function exitCodeFor(outcome) {
|
|
47
|
+
if (outcome.spawnError !== null)
|
|
48
|
+
return 1;
|
|
49
|
+
if (outcome.timedOut || outcome.sizeExceeded)
|
|
50
|
+
return 3;
|
|
51
|
+
if (outcome.exitCode !== 0)
|
|
52
|
+
return 1;
|
|
53
|
+
// The process exited 0, but its own output reported a failure (a codex
|
|
54
|
+
// top-level `error` event, or claude's `is_error: true`) — only reachable
|
|
55
|
+
// for a clean exit, since a nonzero exitCode above already returned 1.
|
|
56
|
+
if (outcome.harness_error !== null)
|
|
57
|
+
return 1;
|
|
58
|
+
if (outcome.validation !== null && !outcome.validation.ok)
|
|
59
|
+
return 2;
|
|
60
|
+
return 0;
|
|
61
|
+
}
|
|
62
|
+
async function main() {
|
|
63
|
+
const argv = process.argv.slice(2);
|
|
64
|
+
const parsed = parseArgs(argv, FLAG_SPEC);
|
|
65
|
+
if (parsed.flags.help === 'true') {
|
|
66
|
+
process.stdout.write(HELP);
|
|
67
|
+
return 0;
|
|
68
|
+
}
|
|
69
|
+
const source = parsed.positional[0];
|
|
70
|
+
if (source === undefined) {
|
|
71
|
+
process.stderr.write('smith-run: missing <invocation.json | -> argument.\n\n');
|
|
72
|
+
process.stderr.write(HELP);
|
|
73
|
+
return 1;
|
|
74
|
+
}
|
|
75
|
+
let invocation;
|
|
76
|
+
try {
|
|
77
|
+
invocation = readInvocation(source);
|
|
78
|
+
}
|
|
79
|
+
catch (err) {
|
|
80
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
81
|
+
process.stderr.write(`smith-run: could not read/parse invocation: ${message}\n`);
|
|
82
|
+
return 1;
|
|
83
|
+
}
|
|
84
|
+
const timeoutOverride = parsed.flags['timeout-ms'];
|
|
85
|
+
if (timeoutOverride !== undefined && invocation.kind === 'cli') {
|
|
86
|
+
const timeout_ms = Number.parseInt(timeoutOverride, 10);
|
|
87
|
+
if (Number.isNaN(timeout_ms)) {
|
|
88
|
+
process.stderr.write(`smith-run: --timeout-ms must be a number, got "${timeoutOverride}".\n`);
|
|
89
|
+
return 1;
|
|
90
|
+
}
|
|
91
|
+
invocation = { ...invocation, budget: { ...invocation.budget, timeout_ms } };
|
|
92
|
+
}
|
|
93
|
+
const opts = {};
|
|
94
|
+
const promptFile = parsed.flags['prompt-file'];
|
|
95
|
+
if (promptFile !== undefined) {
|
|
96
|
+
try {
|
|
97
|
+
opts.promptText = readFileSync(promptFile, 'utf8');
|
|
98
|
+
}
|
|
99
|
+
catch (err) {
|
|
100
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
101
|
+
process.stderr.write(`smith-run: could not read --prompt-file: ${message}\n`);
|
|
102
|
+
return 1;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
let outcome;
|
|
106
|
+
try {
|
|
107
|
+
outcome = await runInvocation(invocation, opts);
|
|
108
|
+
}
|
|
109
|
+
catch (err) {
|
|
110
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
111
|
+
process.stderr.write(`smith-run: ${message}\n`);
|
|
112
|
+
return 1;
|
|
113
|
+
}
|
|
114
|
+
const json = `${JSON.stringify(outcome, null, 2)}\n`;
|
|
115
|
+
const outFile = parsed.flags.out;
|
|
116
|
+
if (outFile !== undefined) {
|
|
117
|
+
writeFileSync(outFile, json);
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
process.stdout.write(json);
|
|
121
|
+
}
|
|
122
|
+
return exitCodeFor(outcome);
|
|
123
|
+
}
|
|
124
|
+
main().then((code) => {
|
|
125
|
+
process.exitCode = code;
|
|
126
|
+
}, (err) => {
|
|
127
|
+
const message = err instanceof Error ? (err.stack ?? err.message) : String(err);
|
|
128
|
+
process.stderr.write(`smith-run: unexpected error: ${message}\n`);
|
|
129
|
+
process.exitCode = 1;
|
|
130
|
+
});
|