@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.
- package/.claude/agents/planner.md +21 -0
- package/.claude/skills/bs/SKILL.md +35 -19
- package/.claude/skills/bs/audit.md +4 -2
- package/.claude/skills/bs/dispatch.md +5 -5
- package/.claude/skills/bs/new.md +4 -2
- package/.claude/skills/bs/plan.md +22 -5
- package/.claude/skills/bs/report.md +3 -3
- package/.claude/skills/bs/run.md +6 -4
- package/.claude/skills/bs/ui.md +10 -3
- package/.claude/skills/bs/wave.md +13 -7
- package/README.md +56 -53
- package/factory/orchestrator/dist/claims.js +27 -1
- package/factory/orchestrator/dist/cli.js +94 -25
- package/factory/orchestrator/dist/daemon.js +16 -5
- package/factory/orchestrator/dist/events.js +50 -0
- package/factory/orchestrator/dist/harness.js +233 -84
- package/factory/orchestrator/dist/impact.js +76 -5
- package/factory/orchestrator/dist/paths.js +2 -0
- package/factory/orchestrator/dist/plan.js +60 -0
- package/factory/orchestrator/dist/providers/cli-transport.js +7 -73
- package/factory/orchestrator/dist/run-cli.js +130 -0
- package/factory/orchestrator/dist/runner.js +264 -0
- package/factory/orchestrator/dist/spawn.js +84 -0
- package/factory/orchestrator/dist/usage.js +8 -6
- package/factory/orchestrator/dist/waveNext.js +42 -14
- package/factory/orchestrator/dist/waveSchedule.js +7 -1
- package/factory/policies/budgets.yml +1 -1
- package/factory/policies/harness.yml +82 -0
- package/factory/policies/severity.yml +1 -1
- package/factory/specs/schema/task-spec.schema.json +5 -0
- package/package.json +3 -2
|
@@ -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`
|
|
26
|
-
* `codex`
|
|
27
|
-
*
|
|
28
|
-
*
|
|
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.
|
|
40
|
-
*
|
|
41
|
-
*
|
|
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
|
|
50
|
+
* ## The shipped policy lives in factory/policies/harness.yml
|
|
44
51
|
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
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
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
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 =
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
|
272
|
+
* The shipped policy (factory/policies/harness.yml), or the file the caller
|
|
273
|
+
* named.
|
|
218
274
|
*
|
|
219
|
-
* No
|
|
220
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
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 ??
|
|
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:
|
|
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
|
-
|
|
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
|
|
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 ??
|
|
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 (
|
|
360
|
-
return
|
|
361
|
-
|
|
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
|
|
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
|
-
|
|
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
|
*
|