@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.
Files changed (72) hide show
  1. package/.claude/agents/coder.md +9 -3
  2. package/.claude/agents/grader.md +13 -5
  3. package/.claude/agents/merger.md +2 -0
  4. package/.claude/agents/planner.md +22 -1
  5. package/.claude/agents/researcher.md +3 -1
  6. package/.claude/agents/reviewer.md +9 -2
  7. package/.claude/agents/security-reviewer.md +15 -7
  8. package/.claude/agents/spec-reviewer.md +12 -3
  9. package/.claude/agents/tester.md +5 -2
  10. package/.claude/agents/uiux.md +10 -3
  11. package/.claude/agents/verifier.md +8 -3
  12. package/.claude/skills/bs/SKILL.md +119 -0
  13. package/.claude/skills/bs/audit.md +252 -0
  14. package/.claude/skills/bs/dispatch.md +394 -0
  15. package/.claude/skills/bs/lessons.md +73 -0
  16. package/.claude/skills/bs/mcp.md +29 -0
  17. package/.claude/skills/bs/new.md +35 -0
  18. package/.claude/skills/bs/plan.md +172 -0
  19. package/.claude/skills/bs/report.md +129 -0
  20. package/.claude/skills/bs/run.md +327 -0
  21. package/.claude/skills/bs/status.md +54 -0
  22. package/.claude/skills/bs/ui.md +19 -0
  23. package/.claude/skills/bs/waivers.md +13 -0
  24. package/.claude/skills/bs/wave.md +267 -0
  25. package/README.md +136 -79
  26. package/factory/orchestrator/dist/actors.js +4 -3
  27. package/factory/orchestrator/dist/agents-registry.js +96 -19
  28. package/factory/orchestrator/dist/artifacts.js +36 -4
  29. package/factory/orchestrator/dist/budgets.js +7 -5
  30. package/factory/orchestrator/dist/claims.js +27 -1
  31. package/factory/orchestrator/dist/cli.js +196 -27
  32. package/factory/orchestrator/dist/crosscheck.js +11 -0
  33. package/factory/orchestrator/dist/daemon.js +30 -5
  34. package/factory/orchestrator/dist/db/projector.js +272 -90
  35. package/factory/orchestrator/dist/db/queries.js +83 -14
  36. package/factory/orchestrator/dist/db/schema.js +7 -0
  37. package/factory/orchestrator/dist/events.js +88 -4
  38. package/factory/orchestrator/dist/harness.js +516 -0
  39. package/factory/orchestrator/dist/impact.js +76 -5
  40. package/factory/orchestrator/dist/judges.js +49 -8
  41. package/factory/orchestrator/dist/lessons.js +4 -4
  42. package/factory/orchestrator/dist/mcp.js +4 -3
  43. package/factory/orchestrator/dist/paths.js +169 -15
  44. package/factory/orchestrator/dist/plan.js +61 -1
  45. package/factory/orchestrator/dist/planQuorum.js +36 -8
  46. package/factory/orchestrator/dist/providers/api-transport.js +62 -5
  47. package/factory/orchestrator/dist/providers/cli-transport.js +7 -73
  48. package/factory/orchestrator/dist/providers/index.js +1 -0
  49. package/factory/orchestrator/dist/providers/schema-validate.js +58 -7
  50. package/factory/orchestrator/dist/providers/types.js +5 -0
  51. package/factory/orchestrator/dist/roadmap.js +3 -3
  52. package/factory/orchestrator/dist/run-cli.js +130 -0
  53. package/factory/orchestrator/dist/runner.js +264 -0
  54. package/factory/orchestrator/dist/scaffold.js +3 -2
  55. package/factory/orchestrator/dist/spawn.js +84 -0
  56. package/factory/orchestrator/dist/stack.js +10 -2
  57. package/factory/orchestrator/dist/templateFragments.js +178 -0
  58. package/factory/orchestrator/dist/usage.js +30 -9
  59. package/factory/orchestrator/dist/waveNext.js +42 -14
  60. package/factory/orchestrator/dist/waveSchedule.js +7 -1
  61. package/factory/orchestrator/dist/workroot.js +113 -0
  62. package/factory/orchestrator/drizzle/0011_findings_spec_ref.sql +3 -0
  63. package/factory/orchestrator/drizzle/meta/_journal.json +7 -0
  64. package/factory/policies/budgets.yml +7 -4
  65. package/factory/policies/crosscheck.yml +10 -8
  66. package/factory/policies/harness.yml +82 -0
  67. package/factory/policies/lessons.md +122 -0
  68. package/factory/policies/severity.yml +1 -1
  69. package/factory/policies/taxonomy.yml +6 -2
  70. package/factory/specs/roadmap.md +154 -0
  71. package/factory/specs/schema/task-spec.schema.json +5 -0
  72. 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 content = parsed.choices?.[0]?.message?.content ?? '';
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
- if (!result.valid) {
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
- if (!result.valid) {
107
- throw new ProviderError('provider.invalid-output', `Provider "${provider}" API judge returned invalid output after one retry: ${result.reason}.`, { provider, reason: result.reason, errors: 'errors' in result ? result.errors : undefined });
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 { spawn } from 'node:child_process';
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: 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).
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 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
- });
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 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.
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) && Array.isArray(parsed)) {
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
- parsed.forEach((item, index) => {
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: parsed };
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 { ROADMAP_PATH } from './paths.js';
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 = ROADMAP_PATH) {
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 = ROADMAP_PATH) {
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
+ });