@juzser/blacksmith 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/agents/coder.md +9 -3
- package/.claude/agents/grader.md +13 -5
- package/.claude/agents/merger.md +2 -0
- package/.claude/agents/planner.md +22 -1
- package/.claude/agents/researcher.md +3 -1
- package/.claude/agents/reviewer.md +9 -2
- package/.claude/agents/security-reviewer.md +15 -7
- package/.claude/agents/spec-reviewer.md +12 -3
- package/.claude/agents/tester.md +5 -2
- package/.claude/agents/uiux.md +10 -3
- package/.claude/agents/verifier.md +8 -3
- package/.claude/skills/bs/SKILL.md +119 -0
- package/.claude/skills/bs/audit.md +252 -0
- package/.claude/skills/bs/dispatch.md +394 -0
- package/.claude/skills/bs/lessons.md +73 -0
- package/.claude/skills/bs/mcp.md +29 -0
- package/.claude/skills/bs/new.md +35 -0
- package/.claude/skills/bs/plan.md +172 -0
- package/.claude/skills/bs/report.md +129 -0
- package/.claude/skills/bs/run.md +327 -0
- package/.claude/skills/bs/status.md +54 -0
- package/.claude/skills/bs/ui.md +19 -0
- package/.claude/skills/bs/waivers.md +13 -0
- package/.claude/skills/bs/wave.md +267 -0
- package/README.md +136 -79
- package/factory/orchestrator/dist/actors.js +4 -3
- package/factory/orchestrator/dist/agents-registry.js +96 -19
- package/factory/orchestrator/dist/artifacts.js +36 -4
- package/factory/orchestrator/dist/budgets.js +7 -5
- package/factory/orchestrator/dist/claims.js +27 -1
- package/factory/orchestrator/dist/cli.js +196 -27
- package/factory/orchestrator/dist/crosscheck.js +11 -0
- package/factory/orchestrator/dist/daemon.js +30 -5
- package/factory/orchestrator/dist/db/projector.js +272 -90
- package/factory/orchestrator/dist/db/queries.js +83 -14
- package/factory/orchestrator/dist/db/schema.js +7 -0
- package/factory/orchestrator/dist/events.js +88 -4
- package/factory/orchestrator/dist/harness.js +516 -0
- package/factory/orchestrator/dist/impact.js +76 -5
- package/factory/orchestrator/dist/judges.js +49 -8
- package/factory/orchestrator/dist/lessons.js +4 -4
- package/factory/orchestrator/dist/mcp.js +4 -3
- package/factory/orchestrator/dist/paths.js +169 -15
- package/factory/orchestrator/dist/plan.js +61 -1
- package/factory/orchestrator/dist/planQuorum.js +36 -8
- package/factory/orchestrator/dist/providers/api-transport.js +62 -5
- package/factory/orchestrator/dist/providers/cli-transport.js +7 -73
- package/factory/orchestrator/dist/providers/index.js +1 -0
- package/factory/orchestrator/dist/providers/schema-validate.js +58 -7
- package/factory/orchestrator/dist/providers/types.js +5 -0
- package/factory/orchestrator/dist/roadmap.js +3 -3
- package/factory/orchestrator/dist/run-cli.js +130 -0
- package/factory/orchestrator/dist/runner.js +264 -0
- package/factory/orchestrator/dist/scaffold.js +3 -2
- package/factory/orchestrator/dist/spawn.js +84 -0
- package/factory/orchestrator/dist/stack.js +10 -2
- package/factory/orchestrator/dist/templateFragments.js +178 -0
- package/factory/orchestrator/dist/usage.js +30 -9
- package/factory/orchestrator/dist/waveNext.js +42 -14
- package/factory/orchestrator/dist/waveSchedule.js +7 -1
- package/factory/orchestrator/dist/workroot.js +113 -0
- package/factory/orchestrator/drizzle/0011_findings_spec_ref.sql +3 -0
- package/factory/orchestrator/drizzle/meta/_journal.json +7 -0
- package/factory/policies/budgets.yml +7 -4
- package/factory/policies/crosscheck.yml +10 -8
- package/factory/policies/harness.yml +82 -0
- package/factory/policies/lessons.md +122 -0
- package/factory/policies/severity.yml +1 -1
- package/factory/policies/taxonomy.yml +6 -2
- package/factory/specs/roadmap.md +154 -0
- package/factory/specs/schema/task-spec.schema.json +5 -0
- package/package.json +10 -7
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { parse as parseYaml } from 'yaml';
|
|
4
|
+
import { loadBudgetPolicy } from './budgets.js';
|
|
5
|
+
import { SmithError } from './errors.js';
|
|
6
|
+
import { AGENTS_DIR, HARNESS_POLICY_PATH, REPO_ROOT } from './paths.js';
|
|
7
|
+
import { loadGuardrailPolicy } from './policy.js';
|
|
8
|
+
import { loadTaxonomy } from './taxonomy.js';
|
|
9
|
+
/**
|
|
10
|
+
* The worker harness port: *how* a turn runs, named at last.
|
|
11
|
+
*
|
|
12
|
+
* Every worker turn this factory has ever dispatched ran under one harness —
|
|
13
|
+
* Claude Code's `Agent` tool — and nothing anywhere says so. It is true by
|
|
14
|
+
* accident of deployment: the orchestrator is itself a Claude Code session, so
|
|
15
|
+
* the only thing it can spawn is another one. `dispatch_decision` records the
|
|
16
|
+
* role, the provider, the model tier and the model (P9-23), which is to say it
|
|
17
|
+
* records *who answered* and with *what weight*, and never *what ran the
|
|
18
|
+
* turn*. The two are independent axes and the factory has been folding them
|
|
19
|
+
* into one:
|
|
20
|
+
*
|
|
21
|
+
* - **provider** — taxonomy.yml `provider: [claude, codex, deepseek]` — is
|
|
22
|
+
* the vendor whose model produced the words.
|
|
23
|
+
* - **harness** — this module — is the program that held the session: gave
|
|
24
|
+
* the agent its tools, its working directory, its turn limit.
|
|
25
|
+
*
|
|
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.
|
|
31
|
+
*
|
|
32
|
+
* ## This module renders an invocation. It never runs one.
|
|
33
|
+
*
|
|
34
|
+
* Architecture §18 rule 3 — "nothing that observes may dispatch". `smith` is
|
|
35
|
+
* an observer: it reads logs, folds them, and prints. If `smith harness plan`
|
|
36
|
+
* spawned the process it describes, the CLI would close its own loop, and
|
|
37
|
+
* every check that reads a `dispatch_decision` as evidence that a separate
|
|
38
|
+
* session ran would be reading evidence `smith` produced about itself.
|
|
39
|
+
*
|
|
40
|
+
* So `planWorkerTurn()` returns a `WorkerInvocation` — a description, in JSON,
|
|
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.
|
|
49
|
+
*
|
|
50
|
+
* ## The shipped policy lives in factory/policies/harness.yml
|
|
51
|
+
*
|
|
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.
|
|
61
|
+
*
|
|
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.
|
|
67
|
+
*
|
|
68
|
+
* ## A cli harness is outside the trust boundary; a judge gets no worktree
|
|
69
|
+
*
|
|
70
|
+
* `providers/types.ts` states the rule for external judge transports: they are
|
|
71
|
+
* handed nothing but `prompt` — "no worktree paths, credentials beyond its own
|
|
72
|
+
* key, or anything callable". A `cli` harness is the same kind of thing one
|
|
73
|
+
* step over: a process this factory starts, on the far side of its own memory,
|
|
74
|
+
* holding whatever the command line hands it. So the same boundary applies,
|
|
75
|
+
* and `planWorkerTurn()` refuses to render a `cli` invocation that would put a
|
|
76
|
+
* worktree path in a judge role's hands (§18 rule 5, "Judges read; they never
|
|
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.
|
|
94
|
+
*
|
|
95
|
+
* An `in-process` harness is inside the boundary: a reviewer subagent does
|
|
96
|
+
* read the worktree, under a lease `smith sandbox open` holds for exactly as
|
|
97
|
+
* long as the verdict takes. The invocation says so — `sandboxRequired` is the
|
|
98
|
+
* obligation, stated where the caller will see it rather than left to the
|
|
99
|
+
* playbook to remember.
|
|
100
|
+
*
|
|
101
|
+
* Which roles are judges is not a list this file keeps. It is
|
|
102
|
+
* guardrails.yml's `judge_sandbox.roles` and `role_write_scopes.scopes`, read
|
|
103
|
+
* through policy.ts, which already refuses a role declared as both. A second
|
|
104
|
+
* hand-written copy here would be a copy that drifts, and the direction it
|
|
105
|
+
* would drift in is a judge quietly reclassified as a worker.
|
|
106
|
+
*
|
|
107
|
+
* ## Env is an allowlist of names. Values never pass through here.
|
|
108
|
+
*
|
|
109
|
+
* A harness that shells out needs credentials, and a rendered invocation is
|
|
110
|
+
* printed as JSON to a terminal and into logs. So the invocation carries the
|
|
111
|
+
* *names* a harness may read from the environment and never their values: the
|
|
112
|
+
* runner applies the allowlist against its own `process.env`. Naming a path
|
|
113
|
+
* and never a value is the same rule every artifact in this factory writes
|
|
114
|
+
* under.
|
|
115
|
+
*/
|
|
116
|
+
export class HarnessError extends SmithError {
|
|
117
|
+
}
|
|
118
|
+
/** The policy shape this module parses. Bumped when the YAML shape changes. */
|
|
119
|
+
export const HARNESS_POLICY_VERSION = 2;
|
|
120
|
+
const MODEL_TIERS = ['frontier', 'mid', 'small'];
|
|
121
|
+
const OUTPUT_MODES = ['text', 'codex-json', 'claude-json'];
|
|
122
|
+
/**
|
|
123
|
+
* What a rendered invocation is allowed to interpolate. Closed on purpose: a
|
|
124
|
+
* typo'd `{worktre}` in an args template would otherwise reach the runner as a
|
|
125
|
+
* literal argument, and a harness would start in the wrong directory rather
|
|
126
|
+
* than fail to start.
|
|
127
|
+
*/
|
|
128
|
+
const PLACEHOLDERS = ['prompt_file', 'worktree', 'role', 'task', 'model', 'schema_file'];
|
|
129
|
+
const PLACEHOLDER_RE = /\{([a-z_]+)\}/g;
|
|
130
|
+
/** Per-harness output-size cap when the policy declares none: 2 MiB. */
|
|
131
|
+
export const HARNESS_DEFAULT_MAX_OUTPUT_BYTES = 2 * 1024 * 1024;
|
|
132
|
+
function invalid(message, details) {
|
|
133
|
+
return new HarnessError('harness.invalid-policy', message, details);
|
|
134
|
+
}
|
|
135
|
+
function requireStringArray(raw, key) {
|
|
136
|
+
if (raw === undefined || raw === null)
|
|
137
|
+
return [];
|
|
138
|
+
if (!Array.isArray(raw) || raw.some((v) => typeof v !== 'string')) {
|
|
139
|
+
throw invalid(`harness policy ${key} is not a list of strings.`);
|
|
140
|
+
}
|
|
141
|
+
return raw;
|
|
142
|
+
}
|
|
143
|
+
/** Every placeholder an args template names, in order of appearance. */
|
|
144
|
+
function placeholdersIn(text) {
|
|
145
|
+
return [...text.matchAll(PLACEHOLDER_RE)].map((m) => m[1]);
|
|
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
|
+
}
|
|
192
|
+
export function parseHarnessPolicy(yamlText) {
|
|
193
|
+
const doc = (parseYaml(yamlText) ?? {});
|
|
194
|
+
if (typeof doc !== 'object' || doc === null || Array.isArray(doc)) {
|
|
195
|
+
throw invalid('harness policy is not a mapping.');
|
|
196
|
+
}
|
|
197
|
+
const version = doc.version ?? HARNESS_POLICY_VERSION;
|
|
198
|
+
if (typeof version !== 'number' || !Number.isInteger(version)) {
|
|
199
|
+
throw invalid('harness policy `version` is not an integer.');
|
|
200
|
+
}
|
|
201
|
+
const rawHarnesses = doc.harnesses;
|
|
202
|
+
if (!Array.isArray(rawHarnesses) || rawHarnesses.length === 0) {
|
|
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.');
|
|
204
|
+
}
|
|
205
|
+
const harnesses = [];
|
|
206
|
+
for (const [index, entry] of rawHarnesses.entries()) {
|
|
207
|
+
const key = `harnesses[${index}]`;
|
|
208
|
+
if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
|
|
209
|
+
throw invalid(`harness policy ${key} is not a mapping.`);
|
|
210
|
+
}
|
|
211
|
+
const item = entry;
|
|
212
|
+
const name = item.name;
|
|
213
|
+
if (typeof name !== 'string' || name === '') {
|
|
214
|
+
throw invalid(`harness policy ${key} is missing \`name\`.`);
|
|
215
|
+
}
|
|
216
|
+
if (harnesses.some((h) => h.name === name)) {
|
|
217
|
+
throw invalid(`harness policy declares "${name}" twice.`, { harness: name });
|
|
218
|
+
}
|
|
219
|
+
const kind = item.kind;
|
|
220
|
+
if (kind !== 'in-process' && kind !== 'cli') {
|
|
221
|
+
throw invalid(`harness policy ${key} has kind "${String(kind)}"; it is one of in-process, cli.`, { harness: name });
|
|
222
|
+
}
|
|
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`);
|
|
227
|
+
const command = item.command ?? null;
|
|
228
|
+
if (kind === 'cli') {
|
|
229
|
+
if (typeof command !== 'string' || command === '') {
|
|
230
|
+
throw invalid(`harness policy ${key} is a cli harness with no \`command\`, so nothing would start.`, { harness: name });
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
else if (command !== null) {
|
|
234
|
+
// An in-process harness runs inside the caller's session. A command here
|
|
235
|
+
// would be a line nothing executes, read by the next person as a
|
|
236
|
+
// promise the port does not keep.
|
|
237
|
+
throw invalid(`harness policy ${key} is in-process but names a \`command\`; an in-process harness starts no program.`, { harness: name });
|
|
238
|
+
}
|
|
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);
|
|
243
|
+
harnesses.push({
|
|
244
|
+
name,
|
|
245
|
+
kind,
|
|
246
|
+
roles: requireStringArray(item.roles, `${key}.roles`),
|
|
247
|
+
command: kind === 'cli' ? command : null,
|
|
248
|
+
args,
|
|
249
|
+
workerArgs,
|
|
250
|
+
judgeArgs,
|
|
251
|
+
schemaArgs,
|
|
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),
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
const declaredDefault = doc.default;
|
|
260
|
+
const defaultHarness = declaredDefault === undefined || declaredDefault === null
|
|
261
|
+
? harnesses[0].name
|
|
262
|
+
: declaredDefault;
|
|
263
|
+
if (typeof defaultHarness !== 'string' || defaultHarness === '') {
|
|
264
|
+
throw invalid('harness policy `default` is not a harness name.');
|
|
265
|
+
}
|
|
266
|
+
if (!harnesses.some((h) => h.name === defaultHarness)) {
|
|
267
|
+
throw invalid(`harness policy names "${defaultHarness}" as the default, and declares no harness by that name.`, { harness: defaultHarness });
|
|
268
|
+
}
|
|
269
|
+
return { version, defaultHarness, harnesses, source: 'file' };
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* The shipped policy (factory/policies/harness.yml), or the file the caller
|
|
273
|
+
* named.
|
|
274
|
+
*
|
|
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.
|
|
278
|
+
*/
|
|
279
|
+
export function loadHarnessPolicy(filePath) {
|
|
280
|
+
if (filePath === undefined) {
|
|
281
|
+
return { ...parseHarnessPolicy(readFileSync(HARNESS_POLICY_PATH, 'utf8')), source: 'default' };
|
|
282
|
+
}
|
|
283
|
+
if (!existsSync(filePath)) {
|
|
284
|
+
throw new HarnessError('harness.policy-not-found', `No harness policy at ${filePath}. Omit --policy to use the shipped one.`, { filePath });
|
|
285
|
+
}
|
|
286
|
+
return parseHarnessPolicy(readFileSync(filePath, 'utf8'));
|
|
287
|
+
}
|
|
288
|
+
export function roleAccess(role, guardrails = loadGuardrailPolicy()) {
|
|
289
|
+
if (guardrails.judgeSandbox.roles.includes(role))
|
|
290
|
+
return 'judge';
|
|
291
|
+
if (guardrails.roleWriteScopes.some((s) => s.role === role))
|
|
292
|
+
return 'write-scoped';
|
|
293
|
+
return 'worker';
|
|
294
|
+
}
|
|
295
|
+
/** Where a role's prompt template lives, and whether it is there. */
|
|
296
|
+
function templateFor(role, agentsDir) {
|
|
297
|
+
const file = path.join(agentsDir, `${role}.md`);
|
|
298
|
+
return { file, exists: existsSync(file) };
|
|
299
|
+
}
|
|
300
|
+
function serves(harness, role) {
|
|
301
|
+
return harness.roles.length === 0 || harness.roles.includes(role);
|
|
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
|
+
}
|
|
358
|
+
function substitute(template, values, harness) {
|
|
359
|
+
return template.replace(PLACEHOLDER_RE, (_match, token) => {
|
|
360
|
+
const value = values[token];
|
|
361
|
+
if (value === undefined || value === null || value === '') {
|
|
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 });
|
|
364
|
+
}
|
|
365
|
+
return value;
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* Describe the process that would run this turn. Start nothing.
|
|
370
|
+
*
|
|
371
|
+
* Every refusal below is a fact the caller could not have checked cheaply:
|
|
372
|
+
* whether the role exists in the taxonomy, whether a template ships for it,
|
|
373
|
+
* whether the harness serves it, and whether handing it a worktree would cross
|
|
374
|
+
* the judge boundary. A runner that skips this call is not faster — it is
|
|
375
|
+
* making the same four decisions with none of them written down.
|
|
376
|
+
*/
|
|
377
|
+
export function planWorkerTurn(request, options = {}) {
|
|
378
|
+
const policy = options.policy ?? loadHarnessPolicy();
|
|
379
|
+
const guardrails = options.guardrails ?? loadGuardrailPolicy();
|
|
380
|
+
const taxonomy = options.taxonomy ?? loadTaxonomy();
|
|
381
|
+
const agentsDir = options.agentsDir ?? AGENTS_DIR;
|
|
382
|
+
const { role, taskId } = request;
|
|
383
|
+
const agentRoles = taxonomy.dimensions.agent ?? [];
|
|
384
|
+
if (!agentRoles.includes(role)) {
|
|
385
|
+
throw new HarnessError('harness.unknown-role', `"${role}" is not a taxonomy \`agent\` value, so no dispatch about it could be written even if the turn ran.`, { role });
|
|
386
|
+
}
|
|
387
|
+
if (taskId === undefined || taskId === '') {
|
|
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 });
|
|
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
|
+
}
|
|
393
|
+
if (request.promptFile === undefined || request.promptFile === '') {
|
|
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 });
|
|
395
|
+
}
|
|
396
|
+
const name = request.harness ?? policy.defaultHarness;
|
|
397
|
+
const harness = policy.harnesses.find((h) => h.name === name);
|
|
398
|
+
if (harness === undefined) {
|
|
399
|
+
throw new HarnessError('harness.unknown', `No harness named "${name}". This policy declares: ${policy.harnesses.map((h) => h.name).join(', ')}.`, { harness: name, declared: policy.harnesses.map((h) => h.name) });
|
|
400
|
+
}
|
|
401
|
+
if (!serves(harness, role)) {
|
|
402
|
+
throw new HarnessError('harness.role-not-served', `Harness "${harness.name}" serves ${harness.roles.join(', ')}, and not ${role}.`, { harness: harness.name, role });
|
|
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);
|
|
417
|
+
const worktree = request.worktree ?? null;
|
|
418
|
+
const access = roleAccess(role, guardrails);
|
|
419
|
+
const base = {
|
|
420
|
+
harness: harness.name,
|
|
421
|
+
role,
|
|
422
|
+
taskId,
|
|
423
|
+
access,
|
|
424
|
+
promptFile: request.promptFile,
|
|
425
|
+
worktree,
|
|
426
|
+
sandboxRequired: access === 'judge' && worktree !== null,
|
|
427
|
+
};
|
|
428
|
+
if (harness.kind === 'in-process') {
|
|
429
|
+
return {
|
|
430
|
+
...base,
|
|
431
|
+
kind: 'in-process',
|
|
432
|
+
subagentType: role,
|
|
433
|
+
template: templateRelative,
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
if (access === 'judge' && worktree !== null && harness.judgeArgs.length === 0) {
|
|
437
|
+
// §18 rule 5, one step out from providers/types.ts. An external process
|
|
438
|
+
// holding a worktree path holds write access to it; no lease this side of
|
|
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 });
|
|
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);
|
|
446
|
+
const values = {
|
|
447
|
+
prompt_file: request.promptFile,
|
|
448
|
+
worktree,
|
|
449
|
+
role,
|
|
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),
|
|
462
|
+
};
|
|
463
|
+
return {
|
|
464
|
+
...base,
|
|
465
|
+
kind: 'cli',
|
|
466
|
+
command: harness.command,
|
|
467
|
+
args,
|
|
468
|
+
cwd: worktree,
|
|
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,
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
/**
|
|
480
|
+
* What `smith harness list` prints: not the policy as written, but the policy
|
|
481
|
+
* as it resolves. A harness whose `roles` is empty reads as "all" in YAML and
|
|
482
|
+
* means "every role that ships a template" in practice, and the difference
|
|
483
|
+
* between those two is the kind of thing an operator finds out at 2am
|
|
484
|
+
* otherwise.
|
|
485
|
+
*/
|
|
486
|
+
export function summarizeHarnesses(options = {}) {
|
|
487
|
+
const policy = options.policy ?? loadHarnessPolicy();
|
|
488
|
+
const taxonomy = options.taxonomy ?? loadTaxonomy();
|
|
489
|
+
const guardrails = options.guardrails ?? loadGuardrailPolicy();
|
|
490
|
+
const agentsDir = options.agentsDir ?? AGENTS_DIR;
|
|
491
|
+
const agentRoles = taxonomy.dimensions.agent ?? [];
|
|
492
|
+
return {
|
|
493
|
+
source: policy.source,
|
|
494
|
+
version: policy.version,
|
|
495
|
+
defaultHarness: policy.defaultHarness,
|
|
496
|
+
harnesses: policy.harnesses.map((harness) => ({
|
|
497
|
+
name: harness.name,
|
|
498
|
+
kind: harness.kind,
|
|
499
|
+
isDefault: harness.name === policy.defaultHarness,
|
|
500
|
+
roles: agentRoles.filter((role) => {
|
|
501
|
+
if (!serves(harness, role))
|
|
502
|
+
return false;
|
|
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;
|
|
509
|
+
}),
|
|
510
|
+
command: harness.command,
|
|
511
|
+
envAllowlist: harness.envAllowlist,
|
|
512
|
+
output: harness.output,
|
|
513
|
+
models: harness.models,
|
|
514
|
+
})),
|
|
515
|
+
};
|
|
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.
|