@juzser/blacksmith 0.1.1 → 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.
@@ -1,8 +1,9 @@
1
- import { existsSync, readFileSync } from 'node:fs';
1
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { parse as parseYaml } from 'yaml';
4
+ import { loadBudgetPolicy } from './budgets.js';
4
5
  import { SmithError } from './errors.js';
5
- import { AGENTS_DIR, REPO_ROOT } from './paths.js';
6
+ import { AGENTS_DIR, HARNESS_POLICY_PATH, REPO_ROOT } from './paths.js';
6
7
  import { loadGuardrailPolicy } from './policy.js';
7
8
  import { loadTaxonomy } from './taxonomy.js';
8
9
  /**
@@ -22,10 +23,11 @@ import { loadTaxonomy } from './taxonomy.js';
22
23
  * - **harness** — this module — is the program that held the session: gave
23
24
  * the agent its tools, its working directory, its turn limit.
24
25
  *
25
- * `claude-code` + `claude` is the only pair that exists today. `codex-cli` +
26
- * `codex` is the pair that makes the distinction load-bearing, and the point
27
- * of writing the port before that pair exists is that the second harness
28
- * should be a config entry and an invocation shape, not a rewrite of dispatch.
26
+ * `claude-code` + `claude` was the only pair that existed when this port was
27
+ * written. `codex-cli` + `codex` and `claude-cli` + `claude` are the pairs
28
+ * that make the distinction load-bearing, and they are why the second and
29
+ * third harnesses are config entries and an invocation shape, not a rewrite
30
+ * of dispatch.
29
31
  *
30
32
  * ## This module renders an invocation. It never runs one.
31
33
  *
@@ -36,26 +38,32 @@ import { loadTaxonomy } from './taxonomy.js';
36
38
  * session ran would be reading evidence `smith` produced about itself.
37
39
  *
38
40
  * So `planWorkerTurn()` returns a `WorkerInvocation` — a description, in JSON,
39
- * of a process someone else starts. The orchestrator session (or a runner that
40
- * is not Claude Code at all) reads it and acts. That separation is the whole
41
- * reason this is a port rather than a spawner.
41
+ * of a process someone else starts. `smith-run` (src/run-cli.ts) is that
42
+ * someone else for a `cli` invocation: a separate executable that spawns the
43
+ * program a `WorkerInvocation` describes and prints what happened, and opens
44
+ * neither the event log nor the database, because starting a program and
45
+ * observing what a session did are different jobs and this factory keeps
46
+ * them in different binaries. The orchestrator session still writes the
47
+ * event that records the turn — `smith-run` running one is not that turn
48
+ * closing its own loop, any more than `codex` or `claude` running one is.
42
49
  *
43
- * ## The default policy lives here, in code, on purpose
50
+ * ## The shipped policy lives in factory/policies/harness.yml
44
51
  *
45
- * There is no `factory/policies/harness.yml`, and this module does not name
46
- * one. Two reasons, both checkable:
52
+ * It used to live here, in code, as a single frozen `HarnessConfig` this
53
+ * module exported directly — the honest reading of a factory with exactly
54
+ * one harness. That stopped being honest the day a second and third harness
55
+ * needed declaring: a `BUILT_IN_HARNESS_POLICY` object and a shipped YAML
56
+ * file would have said the same thing twice, in two languages, and the copy
57
+ * that goes stale is never the one someone is looking at. So the code copy is
58
+ * gone; `HARNESS_POLICY_PATH` (paths.ts) names the file that answers when
59
+ * `--policy` is not given, and `source: 'default'` on the result says which
60
+ * one that was.
47
61
  *
48
- * - Writing a policy file is the operator's decision, not a side effect of
49
- * adding a seam. The built-in policy below describes what the factory
50
- * already does; it invents no capability.
51
- * - paths.test.ts holds `factory/policies/` and `paths.ts` to each other in
52
- * both directions — a constant with no file on disk fails the build. A
53
- * path constant for a file we are not shipping would be exactly that.
54
- *
55
- * An operator who wants a second harness writes a YAML file wherever they like
56
- * and names it: `--policy <file>`, the same shape `stack show` already uses.
57
- * Absent that flag the built-in policy answers, which is the honest reading of
58
- * a factory that has one harness.
62
+ * `--policy <file>` still overrides it, the same shape `stack show` uses.
63
+ * paths.test.ts holds `factory/policies/` and `paths.ts` to each other in
64
+ * both directions — a constant with no file on disk fails the build, and a
65
+ * policy file with no constant does too. `harness.yml` and
66
+ * `HARNESS_POLICY_PATH` had to land together for that reason.
59
67
  *
60
68
  * ## A cli harness is outside the trust boundary; a judge gets no worktree
61
69
  *
@@ -66,7 +74,23 @@ import { loadTaxonomy } from './taxonomy.js';
66
74
  * holding whatever the command line hands it. So the same boundary applies,
67
75
  * and `planWorkerTurn()` refuses to render a `cli` invocation that would put a
68
76
  * worktree path in a judge role's hands (§18 rule 5, "Judges read; they never
69
- * gain write access").
77
+ * gain write access") — *unless* the harness itself declares `judge_args`:
78
+ * arguments that put the program into a mode where it cannot write, enforced
79
+ * by the OS or the tool itself rather than by this factory (codex's
80
+ * `-s read-only`, claude's `--disallowedTools Write,Edit,...`). That is not a
81
+ * weaker promise than the in-process path's sandbox lease — it is a
82
+ * differently-shaped one. An in-process judge runs under guard.sh with a
83
+ * Bash tool in hand, and the lease is what stops it from writing; a cli judge
84
+ * with `judge_args` set never has the ability to write at all, which a lease
85
+ * revocation cannot improve on. Rule 6's fingerprint-before-and-after still
86
+ * runs either way, because a read-only flag is what the harness promises, not
87
+ * what this factory has watched happen. An empty `judge_args` means the
88
+ * harness makes no such promise, and the refusal stands as before.
89
+ *
90
+ * `schema_args` is a smaller instance of the same shape: arguments appended
91
+ * only when the request resolved a schema, so a turn that validates nothing
92
+ * renders without them and a harness never carries an unconditional
93
+ * `--output-schema` a schema-less turn cannot fill in.
70
94
  *
71
95
  * An `in-process` harness is inside the boundary: a reviewer subagent does
72
96
  * read the worktree, under a lease `smith sandbox open` holds for exactly as
@@ -92,40 +116,19 @@ import { loadTaxonomy } from './taxonomy.js';
92
116
  export class HarnessError extends SmithError {
93
117
  }
94
118
  /** The policy shape this module parses. Bumped when the YAML shape changes. */
95
- export const HARNESS_POLICY_VERSION = 1;
119
+ export const HARNESS_POLICY_VERSION = 2;
120
+ const MODEL_TIERS = ['frontier', 'mid', 'small'];
121
+ const OUTPUT_MODES = ['text', 'codex-json', 'claude-json'];
96
122
  /**
97
123
  * What a rendered invocation is allowed to interpolate. Closed on purpose: a
98
124
  * typo'd `{worktre}` in an args template would otherwise reach the runner as a
99
125
  * literal argument, and a harness would start in the wrong directory rather
100
126
  * than fail to start.
101
127
  */
102
- const PLACEHOLDERS = ['prompt_file', 'worktree', 'role', 'task'];
128
+ const PLACEHOLDERS = ['prompt_file', 'worktree', 'role', 'task', 'model', 'schema_file'];
103
129
  const PLACEHOLDER_RE = /\{([a-z_]+)\}/g;
104
- /**
105
- * Today's factory, written down.
106
- *
107
- * One harness, serving every role, starting no program — because the Claude
108
- * Code `Agent` tool is a call inside the orchestrator's own session, not an
109
- * exec. `roles: []` rather than the fifteen taxonomy values: the constraint
110
- * that actually binds is whether `.claude/agents/<role>.md` exists, and
111
- * `planWorkerTurn()` checks that against the directory rather than against a
112
- * list here that would go stale the next time a role is added.
113
- */
114
- export const BUILT_IN_HARNESS_POLICY = Object.freeze({
115
- version: HARNESS_POLICY_VERSION,
116
- defaultHarness: 'claude-code',
117
- harnesses: Object.freeze([
118
- Object.freeze({
119
- name: 'claude-code',
120
- kind: 'in-process',
121
- roles: Object.freeze([]),
122
- command: null,
123
- args: Object.freeze([]),
124
- envAllowlist: Object.freeze([]),
125
- }),
126
- ]),
127
- source: 'built-in',
128
- });
130
+ /** Per-harness output-size cap when the policy declares none: 2 MiB. */
131
+ export const HARNESS_DEFAULT_MAX_OUTPUT_BYTES = 2 * 1024 * 1024;
129
132
  function invalid(message, details) {
130
133
  return new HarnessError('harness.invalid-policy', message, details);
131
134
  }
@@ -141,6 +144,51 @@ function requireStringArray(raw, key) {
141
144
  function placeholdersIn(text) {
142
145
  return [...text.matchAll(PLACEHOLDER_RE)].map((m) => m[1]);
143
146
  }
147
+ function checkPlaceholders(args, key, name) {
148
+ for (const arg of args) {
149
+ for (const found of placeholdersIn(arg)) {
150
+ if (!PLACEHOLDERS.includes(found)) {
151
+ throw invalid(`harness policy ${key} names {${found}}, which no invocation substitutes. Known placeholders: ${PLACEHOLDERS.map((p) => `{${p}}`).join(', ')}.`, { harness: name, placeholder: found });
152
+ }
153
+ }
154
+ }
155
+ }
156
+ function parseOutput(raw, key, name) {
157
+ if (raw === undefined || raw === null)
158
+ return 'text';
159
+ if (typeof raw !== 'string' || !OUTPUT_MODES.includes(raw)) {
160
+ throw invalid(`harness policy ${key} has output "${String(raw)}"; it is one of ${OUTPUT_MODES.join(', ')}.`, { harness: name });
161
+ }
162
+ return raw;
163
+ }
164
+ function parseModels(raw, key, name) {
165
+ if (raw === undefined || raw === null)
166
+ return {};
167
+ if (typeof raw !== 'object' || Array.isArray(raw)) {
168
+ throw invalid(`harness policy ${key} is not a mapping of tier to model id.`, { harness: name });
169
+ }
170
+ const models = {};
171
+ for (const [tier, value] of Object.entries(raw)) {
172
+ if (!MODEL_TIERS.includes(tier)) {
173
+ throw invalid(`harness policy ${key} names tier "${tier}"; it is one of ${MODEL_TIERS.join(', ')}.`, { harness: name, tier });
174
+ }
175
+ if (typeof value !== 'string' || value === '') {
176
+ throw invalid(`harness policy ${key}.${tier} is not a model id.`, { harness: name, tier });
177
+ }
178
+ models[tier] = value;
179
+ }
180
+ return models;
181
+ }
182
+ function parsePositiveIntOrNull(raw, key, name) {
183
+ if (raw === undefined || raw === null)
184
+ return null;
185
+ if (typeof raw !== 'number' || !Number.isFinite(raw) || raw <= 0) {
186
+ throw invalid(`harness policy ${key} must be a positive number; got ${JSON.stringify(raw)}.`, {
187
+ harness: name,
188
+ });
189
+ }
190
+ return raw;
191
+ }
144
192
  export function parseHarnessPolicy(yamlText) {
145
193
  const doc = (parseYaml(yamlText) ?? {});
146
194
  if (typeof doc !== 'object' || doc === null || Array.isArray(doc)) {
@@ -152,7 +200,7 @@ export function parseHarnessPolicy(yamlText) {
152
200
  }
153
201
  const rawHarnesses = doc.harnesses;
154
202
  if (!Array.isArray(rawHarnesses) || rawHarnesses.length === 0) {
155
- throw invalid('harness policy declares no `harnesses`. A file that names none says less than shipping no file at all, which is why the built-in policy is what answers when no --policy is given.');
203
+ throw invalid('harness policy declares no `harnesses`. A file that names none says less than shipping no file at all, which is why the shipped policy is what answers when no --policy is given.');
156
204
  }
157
205
  const harnesses = [];
158
206
  for (const [index, entry] of rawHarnesses.entries()) {
@@ -173,6 +221,9 @@ export function parseHarnessPolicy(yamlText) {
173
221
  throw invalid(`harness policy ${key} has kind "${String(kind)}"; it is one of in-process, cli.`, { harness: name });
174
222
  }
175
223
  const args = requireStringArray(item.args, `${key}.args`);
224
+ const workerArgs = requireStringArray(item.worker_args, `${key}.worker_args`);
225
+ const judgeArgs = requireStringArray(item.judge_args, `${key}.judge_args`);
226
+ const schemaArgs = requireStringArray(item.schema_args, `${key}.schema_args`);
176
227
  const command = item.command ?? null;
177
228
  if (kind === 'cli') {
178
229
  if (typeof command !== 'string' || command === '') {
@@ -185,20 +236,24 @@ export function parseHarnessPolicy(yamlText) {
185
236
  // promise the port does not keep.
186
237
  throw invalid(`harness policy ${key} is in-process but names a \`command\`; an in-process harness starts no program.`, { harness: name });
187
238
  }
188
- for (const arg of args) {
189
- for (const found of placeholdersIn(arg)) {
190
- if (!PLACEHOLDERS.includes(found)) {
191
- throw invalid(`harness policy ${key}.args names {${found}}, which no invocation substitutes. Known placeholders: ${PLACEHOLDERS.map((p) => `{${p}}`).join(', ')}.`, { harness: name, placeholder: found });
192
- }
193
- }
194
- }
239
+ checkPlaceholders(args, `${key}.args`, name);
240
+ checkPlaceholders(workerArgs, `${key}.worker_args`, name);
241
+ checkPlaceholders(judgeArgs, `${key}.judge_args`, name);
242
+ checkPlaceholders(schemaArgs, `${key}.schema_args`, name);
195
243
  harnesses.push({
196
244
  name,
197
245
  kind,
198
246
  roles: requireStringArray(item.roles, `${key}.roles`),
199
247
  command: kind === 'cli' ? command : null,
200
248
  args,
249
+ workerArgs,
250
+ judgeArgs,
251
+ schemaArgs,
201
252
  envAllowlist: requireStringArray(item.env, `${key}.env`),
253
+ output: parseOutput(item.output, `${key}.output`, name),
254
+ models: parseModels(item.models, `${key}.models`, name),
255
+ timeoutMs: parsePositiveIntOrNull(item.timeout_ms, `${key}.timeout_ms`, name),
256
+ maxOutputBytes: parsePositiveIntOrNull(item.max_output_bytes, `${key}.max_output_bytes`, name),
202
257
  });
203
258
  }
204
259
  const declaredDefault = doc.default;
@@ -214,16 +269,19 @@ export function parseHarnessPolicy(yamlText) {
214
269
  return { version, defaultHarness, harnesses, source: 'file' };
215
270
  }
216
271
  /**
217
- * The built-in policy, or the file the caller named.
272
+ * The shipped policy (factory/policies/harness.yml), or the file the caller
273
+ * named.
218
274
  *
219
- * No default path: see the header. An absent `filePath` is not a missing file,
220
- * it is the statement that this factory runs one harness.
275
+ * No caching: every call re-reads and re-parses, the same choice
276
+ * loadBudgetPolicy makes, so an operator editing `--policy <file>` between
277
+ * two `smith harness plan` calls in the same process sees the edit.
221
278
  */
222
279
  export function loadHarnessPolicy(filePath) {
223
- if (filePath === undefined)
224
- return BUILT_IN_HARNESS_POLICY;
280
+ if (filePath === undefined) {
281
+ return { ...parseHarnessPolicy(readFileSync(HARNESS_POLICY_PATH, 'utf8')), source: 'default' };
282
+ }
225
283
  if (!existsSync(filePath)) {
226
- throw new HarnessError('harness.policy-not-found', `No harness policy at ${filePath}. Omit --policy to use the built-in one.`, { filePath });
284
+ throw new HarnessError('harness.policy-not-found', `No harness policy at ${filePath}. Omit --policy to use the shipped one.`, { filePath });
227
285
  }
228
286
  return parseHarnessPolicy(readFileSync(filePath, 'utf8'));
229
287
  }
@@ -242,11 +300,67 @@ function templateFor(role, agentsDir) {
242
300
  function serves(harness, role) {
243
301
  return harness.roles.length === 0 || harness.roles.includes(role);
244
302
  }
303
+ const TIER_BY_TEMPLATE_MODEL = Object.freeze({
304
+ opus: 'frontier',
305
+ sonnet: 'mid',
306
+ haiku: 'small',
307
+ });
308
+ function modelLineOf(markdown) {
309
+ const line = markdown.split('\n').find((l) => l.startsWith('model:'));
310
+ if (line === undefined)
311
+ return null;
312
+ const value = line.slice('model:'.length).trim();
313
+ return value === '' ? null : value;
314
+ }
315
+ /**
316
+ * Tier, when the request names none: read off the role template's own
317
+ * frontmatter `model:` line and fold the model id it names (`opus`, `sonnet`,
318
+ * `haiku`) through the tier taxonomy.yml's `model_tier` already gives that
319
+ * vendor's weight class. PR 2 moves role templates to `factory/agents/` with
320
+ * a `tier:` field of their own, which retires this mapping in favor of
321
+ * reading it directly — this is the bridge until then, not a second source of
322
+ * truth meant to outlive it.
323
+ */
324
+ function tierFromTemplate(templateFile) {
325
+ if (!existsSync(templateFile))
326
+ return null;
327
+ const model = modelLineOf(readFileSync(templateFile, 'utf8'));
328
+ return model === null ? null : (TIER_BY_TEMPLATE_MODEL[model] ?? null);
329
+ }
330
+ const SCHEMA_DIR = path.join(REPO_ROOT, 'factory', 'specs', 'schema');
331
+ const SCHEMA_SUFFIX = '.schema.json';
332
+ /** `factory/specs/schema/<name>.schema.json`, checked against what actually ships. */
333
+ function resolveSchema(name, harnessName) {
334
+ if (name === undefined || name === null || name === '')
335
+ return null;
336
+ const file = path.join(SCHEMA_DIR, `${name}${SCHEMA_SUFFIX}`);
337
+ if (!existsSync(file)) {
338
+ const known = readdirSync(SCHEMA_DIR)
339
+ .filter((f) => f.endsWith(SCHEMA_SUFFIX))
340
+ .map((f) => f.slice(0, -SCHEMA_SUFFIX.length))
341
+ .sort();
342
+ throw new HarnessError('harness.unknown-schema', `Harness "${harnessName}" was asked to validate against schema "${name}", and factory/specs/schema/ ships no ${name}${SCHEMA_SUFFIX}. It ships: ${known.join(', ')}.`, { harness: harnessName, schema: name, known });
343
+ }
344
+ return { name, file };
345
+ }
346
+ function capTokensFor(role, access, budgets) {
347
+ // Only these three named roles carry a cap in budgets.yml today — every
348
+ // other role (security-reviewer, merger, tester, uiux, planner, scribe)
349
+ // has none, and null says so rather than inventing a number nobody set.
350
+ if (access === 'judge')
351
+ return budgets.task.judges.capTokens;
352
+ if (role === 'coder')
353
+ return budgets.task.coder.capTokens;
354
+ if (role === 'researcher')
355
+ return budgets.task.researcher.capTokens;
356
+ return null;
357
+ }
245
358
  function substitute(template, values, harness) {
246
359
  return template.replace(PLACEHOLDER_RE, (_match, token) => {
247
360
  const value = values[token];
248
361
  if (value === undefined || value === null || value === '') {
249
- throw new HarnessError('harness.missing-substitution', `Harness "${harness}" interpolates {${token}}, and this request carries no ${token.replace('_', ' ')}.`, { harness, placeholder: token });
362
+ const hint = token === 'schema_file' ? ' — pass --schema <name>' : '';
363
+ throw new HarnessError('harness.missing-substitution', `Harness "${harness}" interpolates {${token}}, and this request carries no ${token.replace('_', ' ')}${hint}.`, { harness, placeholder: token });
250
364
  }
251
365
  return value;
252
366
  });
@@ -261,7 +375,7 @@ function substitute(template, values, harness) {
261
375
  * making the same four decisions with none of them written down.
262
376
  */
263
377
  export function planWorkerTurn(request, options = {}) {
264
- const policy = options.policy ?? BUILT_IN_HARNESS_POLICY;
378
+ const policy = options.policy ?? loadHarnessPolicy();
265
379
  const guardrails = options.guardrails ?? loadGuardrailPolicy();
266
380
  const taxonomy = options.taxonomy ?? loadTaxonomy();
267
381
  const agentsDir = options.agentsDir ?? AGENTS_DIR;
@@ -273,6 +387,9 @@ export function planWorkerTurn(request, options = {}) {
273
387
  if (taskId === undefined || taskId === '') {
274
388
  throw new HarnessError('harness.missing-task', 'A worker turn is about a task; the invocation carries its id so the dispatch the caller writes can name the same one.', { role });
275
389
  }
390
+ if (request.tier !== undefined && !MODEL_TIERS.includes(request.tier)) {
391
+ throw new HarnessError('harness.unknown-tier', `"${request.tier}" is not a model tier. It is one of ${MODEL_TIERS.join(', ')}.`, { tier: request.tier });
392
+ }
276
393
  if (request.promptFile === undefined || request.promptFile === '') {
277
394
  throw new HarnessError('harness.missing-prompt', 'A worker turn needs a prompt file. This port renders invocations, not prompts — write the prompt first (`smith prompt record`), then plan the turn that carries it.', { role });
278
395
  }
@@ -284,6 +401,19 @@ export function planWorkerTurn(request, options = {}) {
284
401
  if (!serves(harness, role)) {
285
402
  throw new HarnessError('harness.role-not-served', `Harness "${harness.name}" serves ${harness.roles.join(', ')}, and not ${role}.`, { harness: harness.name, role });
286
403
  }
404
+ // A template is required to start an agent under either kind: an in-process
405
+ // harness reads it directly, and a cli harness's runner prepends its body
406
+ // to the prompt on stdin (see WorkerInvocation.template). No template, no
407
+ // agent to start, regardless of which program would have started it.
408
+ const template = templateFor(role, agentsDir);
409
+ if (!template.exists) {
410
+ // D-191's reading, applied to the harness rather than to a grant: a
411
+ // role with no template reaches no agent. `operator` is the standing
412
+ // case — taxonomy.yml carries it as a `found_by` for a defect a person
413
+ // read off the code, and it is never dispatched.
414
+ throw new HarnessError('harness.no-template', `${role} ships no template at ${path.relative(REPO_ROOT, template.file)}, so no harness has an agent to start.`, { role, template: path.relative(REPO_ROOT, template.file) });
415
+ }
416
+ const templateRelative = path.relative(REPO_ROOT, template.file);
287
417
  const worktree = request.worktree ?? null;
288
418
  const access = roleAccess(role, guardrails);
289
419
  const base = {
@@ -296,40 +426,54 @@ export function planWorkerTurn(request, options = {}) {
296
426
  sandboxRequired: access === 'judge' && worktree !== null,
297
427
  };
298
428
  if (harness.kind === 'in-process') {
299
- const template = templateFor(role, agentsDir);
300
- if (!template.exists) {
301
- // D-191's reading, applied to the harness rather than to a grant: a
302
- // role with no template reaches no agent. `operator` is the standing
303
- // case — taxonomy.yml carries it as a `found_by` for a defect a person
304
- // read off the code, and it is never dispatched.
305
- throw new HarnessError('harness.no-template', `${role} ships no template at ${path.relative(REPO_ROOT, template.file)}, so an in-process harness has no agent to start.`, { role, template: path.relative(REPO_ROOT, template.file) });
306
- }
307
429
  return {
308
430
  ...base,
309
431
  kind: 'in-process',
310
432
  subagentType: role,
311
- template: path.relative(REPO_ROOT, template.file),
433
+ template: templateRelative,
312
434
  };
313
435
  }
314
- if (access === 'judge' && worktree !== null) {
436
+ if (access === 'judge' && worktree !== null && harness.judgeArgs.length === 0) {
315
437
  // §18 rule 5, one step out from providers/types.ts. An external process
316
438
  // holding a worktree path holds write access to it; no lease this side of
317
- // the boundary can take that back.
318
- throw new HarnessError('harness.judge-worktree', `Harness "${harness.name}" runs a separate program, and ${role} is a judge role. A judge outside this process gets the prompt and nothing else — a worktree path it holds is write access no sandbox lease can revoke (architecture §18 rule 5).`, { harness: harness.name, role });
439
+ // the boundary can take that back — unless the harness's own policy entry
440
+ // declares judge_args that make the program read-only. See the header.
441
+ throw new HarnessError('harness.judge-worktree', `Harness "${harness.name}" runs a separate program, and ${role} is a judge role. A judge outside this process gets the prompt and nothing else — a worktree path it holds is write access no sandbox lease can revoke (architecture §18 rule 5). ${harness.name} could still serve ${role} a worktree if its policy entry were to declare judge_args that make the program read-only.`, { harness: harness.name, role });
319
442
  }
443
+ const tier = request.tier ?? tierFromTemplate(template.file);
444
+ const model = tier !== null ? (harness.models[tier] ?? null) : null;
445
+ const schema = resolveSchema(request.schema, harness.name);
320
446
  const values = {
321
447
  prompt_file: request.promptFile,
322
448
  worktree,
323
449
  role,
324
450
  task: taskId,
451
+ model,
452
+ schema_file: schema?.file ?? null,
453
+ };
454
+ const roleArgs = access === 'judge' ? harness.judgeArgs : harness.workerArgs;
455
+ const rawArgs = [...harness.args, ...roleArgs, ...(schema !== null ? harness.schemaArgs : [])];
456
+ const args = rawArgs.map((arg) => substitute(arg, values, harness.name));
457
+ const budgets = options.budgets ?? loadBudgetPolicy();
458
+ const budget = {
459
+ timeout_ms: harness.timeoutMs,
460
+ max_output_bytes: harness.maxOutputBytes ?? HARNESS_DEFAULT_MAX_OUTPUT_BYTES,
461
+ cap_tokens: capTokensFor(role, access, budgets),
325
462
  };
326
463
  return {
327
464
  ...base,
328
465
  kind: 'cli',
329
466
  command: harness.command,
330
- args: harness.args.map((arg) => substitute(arg, values, harness.name)),
467
+ args,
331
468
  cwd: worktree,
332
469
  envAllowlist: harness.envAllowlist,
470
+ template: templateRelative,
471
+ output: harness.output,
472
+ model,
473
+ tier,
474
+ schema: schema?.name ?? null,
475
+ stdin: 'prompt',
476
+ budget,
333
477
  };
334
478
  }
335
479
  /**
@@ -340,7 +484,7 @@ export function planWorkerTurn(request, options = {}) {
340
484
  * otherwise.
341
485
  */
342
486
  export function summarizeHarnesses(options = {}) {
343
- const policy = options.policy ?? BUILT_IN_HARNESS_POLICY;
487
+ const policy = options.policy ?? loadHarnessPolicy();
344
488
  const taxonomy = options.taxonomy ?? loadTaxonomy();
345
489
  const guardrails = options.guardrails ?? loadGuardrailPolicy();
346
490
  const agentsDir = options.agentsDir ?? AGENTS_DIR;
@@ -356,12 +500,17 @@ export function summarizeHarnesses(options = {}) {
356
500
  roles: agentRoles.filter((role) => {
357
501
  if (!serves(harness, role))
358
502
  return false;
359
- if (harness.kind === 'in-process')
360
- return templateFor(role, agentsDir).exists;
361
- return roleAccess(role, guardrails) !== 'judge';
503
+ if (!templateFor(role, agentsDir).exists)
504
+ return false;
505
+ if (roleAccess(role, guardrails) === 'judge') {
506
+ return harness.kind === 'in-process' || harness.judgeArgs.length > 0;
507
+ }
508
+ return true;
362
509
  }),
363
510
  command: harness.command,
364
511
  envAllowlist: harness.envAllowlist,
512
+ output: harness.output,
513
+ models: harness.models,
365
514
  })),
366
515
  };
367
516
  }
@@ -20,6 +20,12 @@
20
20
  * it is a break, and it is provable. A changed signature is weaker: this
21
21
  * module reads text, not types, so it says `possible` and means it.
22
22
  *
23
+ * A `keeps_exports` promise (task-spec.schema.json) is the declaration that
24
+ * joins the two halves: the pre-run half trusts it and lets a crossing whose
25
+ * producer promised the exporting file run in the same wave, and the post-run
26
+ * half verifies it against the diff, where a removed or re-declared export
27
+ * under a promise is a broken promise and fails the check.
28
+ *
23
29
  * Holes are reported, never fatal. The scanner has blind spots (a `.vue` file,
24
30
  * an unterminated literal), and failing a wave for the scanner's limits would
25
31
  * teach operators to reach for the override — which costs more than the check
@@ -46,6 +52,17 @@ function ownersOf(files, tasks) {
46
52
  }
47
53
  return owner;
48
54
  }
55
+ /**
56
+ * Did the crossing's producer promise to keep the exports of the file the
57
+ * crossing imports from? A promise is a literal path (plan.ts refuses a
58
+ * pattern), matched the way `ownersOf` matches a claim to a file so the two
59
+ * agree on what "this file" means.
60
+ */
61
+ function promisedBy(crossing, tasks) {
62
+ const producer = tasks.find((task) => task.task_id === crossing.producer);
63
+ const promises = producer?.keeps_exports ?? [];
64
+ return promises.some((promise) => claimCoversPath(promise, crossing.exportedBy));
65
+ }
49
66
  export function waveImpact(graph, tasks) {
50
67
  // Unreadable files are absent from `modules` but still claimable, and a task
51
68
  // that claims one has matched a real file — it is the reading that failed.
@@ -101,7 +118,7 @@ export function waveImpact(graph, tasks) {
101
118
  }
102
119
  }
103
120
  }
104
- const orderedCrossings = [...crossings.values()]
121
+ const orderCrossings = (found) => found
105
122
  .map((c) => ({
106
123
  producer: c.producer,
107
124
  consumer: c.consumer,
@@ -115,6 +132,11 @@ export function waveImpact(graph, tasks) {
115
132
  a.consumer.localeCompare(b.consumer) ||
116
133
  a.exportedBy.localeCompare(b.exportedBy) ||
117
134
  a.importedBy.localeCompare(b.importedBy));
135
+ // A promise moves the crossing from the blocking set to the promised one;
136
+ // it does not erase it, because the post-run verifier and the operator both
137
+ // want to see which crossings the wave is running on trust.
138
+ const orderedCrossings = orderCrossings([...crossings.values()].filter((c) => !promisedBy(c, tasks)));
139
+ const orderedPromised = orderCrossings([...crossings.values()].filter((c) => promisedBy(c, tasks)));
118
140
  const orderedExposure = [...exposure.values()]
119
141
  .map((e) => ({
120
142
  producer: e.producer,
@@ -136,14 +158,15 @@ export function waveImpact(graph, tasks) {
136
158
  status,
137
159
  ok: status !== 'coupled',
138
160
  crossings: orderedCrossings,
161
+ promised: orderedPromised,
139
162
  exposure: orderedExposure,
140
163
  claimsWithoutFiles,
141
164
  unanalyzed,
142
165
  unresolved,
143
- detail: describeWave(status, orderedCrossings, orderedExposure, unanalyzed.length + unresolved.length),
166
+ detail: describeWave(status, orderedCrossings, orderedPromised.length, orderedExposure, unanalyzed.length + unresolved.length),
144
167
  };
145
168
  }
146
- function describeWave(status, crossings, exposure, holes) {
169
+ function describeWave(status, crossings, promised, exposure, holes) {
147
170
  const parts = [];
148
171
  if (status === 'coupled') {
149
172
  const pairs = new Set(crossings.map((c) => `${c.producer}/${c.consumer}`));
@@ -155,6 +178,9 @@ function describeWave(status, crossings, exposure, holes) {
155
178
  else {
156
179
  parts.push('No task in this wave imports a symbol another task in this wave exports.');
157
180
  }
181
+ if (promised > 0) {
182
+ parts.push(`${promised} crossing(s) run on a keeps_exports promise, verified post-run.`);
183
+ }
158
184
  if (exposure.length > 0) {
159
185
  parts.push(`${exposure.length} file(s) outside the wave import from claimed files.`);
160
186
  }
@@ -187,14 +213,52 @@ export function diffExports(before, after, file) {
187
213
  function consumes(names, symbol) {
188
214
  return names.includes('*') || names.includes(symbol);
189
215
  }
216
+ /**
217
+ * A promise against the diff of the file it names. A file the diff does not
218
+ * hold was not touched, so its exports are as they were: kept.
219
+ */
220
+ function verdictOn(file, diffs) {
221
+ const diff = diffs.find((entry) => entry.file === file);
222
+ if (diff === undefined) {
223
+ return { file, status: 'kept', removed: [], signatureChanged: [], added: [] };
224
+ }
225
+ const lists = {
226
+ removed: [...diff.removed],
227
+ signatureChanged: [...diff.signatureChanged],
228
+ added: [...diff.added],
229
+ };
230
+ if (diff.unverifiable)
231
+ return { file, status: 'unverified', ...lists };
232
+ if (diff.removed.length > 0)
233
+ return { file, status: 'broken', reason: 'removed', ...lists };
234
+ if (diff.signatureChanged.length > 0) {
235
+ return { file, status: 'broken', reason: 'signature-changed', ...lists };
236
+ }
237
+ return { file, status: 'kept', ...lists };
238
+ }
239
+ function describePromise(verdict) {
240
+ const changes = [];
241
+ if (verdict.removed.length > 0)
242
+ changes.push(`removed [${verdict.removed.join(', ')}]`);
243
+ if (verdict.signatureChanged.length > 0) {
244
+ changes.push(`changed the declaration of [${verdict.signatureChanged.join(', ')}]`);
245
+ }
246
+ return `Promise broken: ${verdict.file} ${changes.join(' and ')}.`;
247
+ }
190
248
  /**
191
249
  * Whose files broke. `claims` is the acting task's own claim list: an importer
192
250
  * the task already owns is not a break, because the same task is free to fix
193
251
  * it in the same diff — and usually has.
252
+ *
253
+ * `keepsExports` is the task's own `keeps_exports` promise, the one the wave
254
+ * gate ran a crossing on. It is held against the diff regardless of who
255
+ * imports the file: the importer the promise covered belongs to another task,
256
+ * whose file this diff cannot see, so "inside the claims" excuses nothing here.
194
257
  */
195
- export function exportImpact(graph, diffs, claims) {
258
+ export function exportImpact(graph, diffs, claims, keepsExports = []) {
196
259
  const breaks = [];
197
260
  const unverifiable = [];
261
+ const promises = keepsExports.map((file) => verdictOn(file, diffs));
198
262
  for (const diff of diffs) {
199
263
  if (diff.unverifiable) {
200
264
  unverifiable.push(diff.file);
@@ -243,7 +307,14 @@ export function exportImpact(graph, diffs, claims) {
243
307
  }
244
308
  if (unverifiable.length > 0)
245
309
  parts.push(`Unreadable, so unchecked: ${unverifiable.join(', ')}.`);
246
- return { ok: proven === 0, breaks, detail: parts.join(' ') };
310
+ const broken = promises.filter((verdict) => verdict.status === 'broken');
311
+ for (const verdict of broken)
312
+ parts.push(describePromise(verdict));
313
+ const unverified = promises.filter((verdict) => verdict.status === 'unverified');
314
+ if (unverified.length > 0) {
315
+ parts.push(`Promise unverified: ${unverified.map((verdict) => verdict.file).join(', ')}.`);
316
+ }
317
+ return { ok: proven === 0 && broken.length === 0, breaks, promises, detail: parts.join(' ') };
247
318
  }
248
319
  /**
249
320
  * The collector half (P9-3): read both sides of each changed file out of git.
@@ -196,6 +196,8 @@ export const EFFORT_POLICY_PATH = path.join(REPO_ROOT, 'factory', 'policies', 'e
196
196
  export const SENSITIVE_PATHS_POLICY_PATH = path.join(REPO_ROOT, 'factory', 'policies', 'sensitive-paths.yml');
197
197
  /** The guard hook's rule data — read by `smith policy check`/`smith policy hook` (policy.ts). */
198
198
  export const GUARDRAILS_POLICY_PATH = path.join(REPO_ROOT, 'factory', 'policies', 'guardrails.yml');
199
+ /** Which programs may hold a worker turn, and how to start each one (harness.ts). */
200
+ export const HARNESS_POLICY_PATH = path.join(REPO_ROOT, 'factory', 'policies', 'harness.yml');
199
201
  /**
200
202
  * The S1-S4 ladder and which levels block a merge (severity.ts).
201
203
  *