unitbob 0.6.3 → 0.7.1

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/dist/cli.js CHANGED
@@ -27,7 +27,7 @@ import { validateBuild } from "./verbs/validateBuild.js";
27
27
  import { fixPrepare } from "./verbs/fixPrepare.js";
28
28
  import { contractPrompt } from "./verbs/contractPrompt.js";
29
29
  import { suiteReviewPrepare } from "./verbs/suiteReviewPrepare.js";
30
- import { validateWorkerPlan } from "./verbs/validateWorkerPlan.js";
30
+ import { acceptWorkerPlan } from "./verbs/acceptWorkerPlan.js";
31
31
  import { validateWorkerCheckpoints } from "./verbs/validateWorkerCheckpoints.js";
32
32
  import { installCodexAgents } from "./verbs/codexInstall.js";
33
33
  const USAGE = `unitbob — thin local hands for the Unitbob server.
@@ -53,7 +53,8 @@ Verbs:
53
53
  suite-review-prepare Internal: bind an independent BDD quality review to the built behavioral candidate.
54
54
  validate-build Internal: check the host's suite answer against the request, locally, before
55
55
  uploading. Reports every problem at once; put-suite-build runs it too.
56
- validate-worker-plan Internal: validate the exact request-bound worker plan before fan-out.
56
+ accept-worker-plan Internal: check the exact request-bound worker plan and seed a checkpoint for
57
+ every slice it names, before fan-out.
57
58
  validate-worker-checkpoints
58
59
  Internal: validate every worker checkpoint before assembly or repair.
59
60
  put-suite-build Internal: upload the host-built guardrail suite (whole spec file + test_metadata),
@@ -120,8 +121,8 @@ export async function main(argv, deps = { ensureLinked }) {
120
121
  case 'validate-build':
121
122
  await validateBuild(await linked(), args);
122
123
  return 0;
123
- case 'validate-worker-plan':
124
- await validateWorkerPlan(await linked(), args);
124
+ case 'accept-worker-plan':
125
+ await acceptWorkerPlan(await linked(), args);
125
126
  return 0;
126
127
  case 'validate-worker-checkpoints':
127
128
  await validateWorkerCheckpoints(await linked(), args);
@@ -0,0 +1,148 @@
1
+ import { readPacketIndex } from "./packets.js";
2
+ // Spec 37-3, criterion 1. How wide a branch's fan-out should be.
3
+ //
4
+ // The rule this replaces said there was no ceiling at all: "an agent re-reads
5
+ // its context every turn, so splitting the work never costs more than keeping it
6
+ // together." Half right, and the wrong half was load-bearing. An agent's cost is
7
+ // the sum of its context over its turns, so splitting pulls in two directions:
8
+ //
9
+ // - the opening context is bought once per worker and re-read every turn, so
10
+ // it multiplies with the width. 26,065 tokens on the bench of 2026-08-24,
11
+ // the same to within ±370 across fifteen workers.
12
+ // - each worker's conversation is shorter, and a conversation's cost grows
13
+ // with the square of its length, so this falls with the width.
14
+ //
15
+ // There is therefore a minimum, and it is neither end. Measured on that bench,
16
+ // against what the fifteen workers actually cost:
17
+ //
18
+ // workers 1 2 3 5 8 15 20
19
+ // input 51.2M 34.6M 29.9M 27.7M 28.8M 35.6M 41.3M
20
+ //
21
+ // Fifteen was 28% over the cheapest width. One worker — which is what "the work
22
+ // fits in one context" would have said, and what the first draft of this rule
23
+ // enforced — is 85% over it, and would have run a 216-turn worker into a
24
+ // 150-turn fuse. The floor is as expensive a mistake as the ceiling.
25
+ //
26
+ // See ai/specs/37-3-fan-out-by-workload/after-2026-08-24.md in the brain repo.
27
+ // What the optimum is not a function of. Productive turns per 1,000 tokens of
28
+ // source were 7.8 on the behavioral branch and 1.4 on the structural one of the
29
+ // same run — 5.6× apart — and per assigned id, 8× apart. Bytes measure how much
30
+ // there is to read, which turns out not to be what a worker spends its turns on.
31
+ // They stay here for the printout and for the record in `fan_out`; they do not
32
+ // set the width.
33
+ const BYTES_PER_TOKEN = 4;
34
+ const WRITTEN_PER_READ = 3;
35
+ // What it is a function of. A planned case is one intent the worker has to turn
36
+ // into a written example or Scenario, and its cost in turns is a property of the
37
+ // branch, not of the project: a Gherkin Scenario needs the World, a session, a
38
+ // fixture and an assertion; a structural example calls a method.
39
+ //
40
+ // Measured 2026-08-24: 35 behavioral cases over 126 productive turns, 91
41
+ // structural cases over 65.
42
+ const TURNS_PER_CASE = { behavioral: 3.6, structural: 0.7 };
43
+ // The optimum width is the branch's productive turns over this. It comes out of
44
+ // setting the derivative of the cost above to zero, which gives
45
+ // `sqrt(2·warmup·preamble/added + warmup²)` — 31.3 on the behavioral branch of
46
+ // that run and 41.7 on the structural one, near enough to each other that one
47
+ // number carries both and the flat bottom of the curve absorbs the difference.
48
+ const TURNS_PER_WORKER = 36;
49
+ // What a worker spends before it writes anything — reading its packets, its plan
50
+ // item and its seeded facts. Measured 2026-08-24: 176 warm-up turns over eight
51
+ // behavioral workers, 202 over seven structural ones. It is per worker and does
52
+ // not divide, which is half of why width costs; it is added back here so that
53
+ // the turns this prints are the whole conversation, the thing that meets the
54
+ // 150-turn fuse.
55
+ const WARMUP_TURNS = { behavioral: 22, structural: 28 };
56
+ // Every case ends up at the same handful of widths, so the rule has to be a band
57
+ // rather than a number: anywhere from three to eight workers cost within 10% of
58
+ // the cheapest on the measured run. What the band excludes is what actually
59
+ // costs — fifteen at one end, one at the other.
60
+ const NARROWEST = 0.5;
61
+ const WIDEST = 1.5;
62
+ // How wide a branch should be, from the cases its plan intends to write.
63
+ // Returns nothing for a branch this connector has no measured cost for: a rule
64
+ // with no measurement behind it must not refuse anybody's plan.
65
+ export function branchWidth(branch, plannedCases) {
66
+ const perCase = TURNS_PER_CASE[branch];
67
+ if (perCase === undefined || plannedCases <= 0)
68
+ return undefined;
69
+ const turns = plannedCases * perCase;
70
+ const workers = Math.max(1, Math.round(turns / TURNS_PER_WORKER));
71
+ return {
72
+ branch,
73
+ planned_cases: plannedCases,
74
+ turns_each: Math.round(turns / workers) + (WARMUP_TURNS[branch] ?? 0),
75
+ workers,
76
+ fewest: Math.max(1, Math.round(workers * NARROWEST)),
77
+ most: Math.max(1, Math.ceil(workers * WIDEST)),
78
+ };
79
+ }
80
+ // What each branch's source weighs. Kept because it is the honest answer to "how
81
+ // much is there", printed before the plan exists and recorded in `fan_out` — but
82
+ // it is not what decides the width. See TURNS_PER_CASE above.
83
+ //
84
+ // `taken` narrows the count to the ids a plan actually took, which matters since
85
+ // criterion 2 let the structural branch be narrowed too: the packets are built
86
+ // from the whole assignment, before anybody chose a scope.
87
+ export function branchWorkloads(projectRoot, taken) {
88
+ const index = readPacketIndex(projectRoot);
89
+ if (!index || index.targets.length === 0)
90
+ return [];
91
+ const measured = new Map();
92
+ const unmeasured = new Map();
93
+ for (const target of index.targets) {
94
+ const ids = taken?.get(target.branch);
95
+ if (taken && !ids?.has(target.id))
96
+ continue;
97
+ const size = sizeOf(target);
98
+ // A file too large to copy still has a path and a size, and it is the
99
+ // heaviest reading on the branch — counting it as nothing would let the
100
+ // biggest sources look like the smallest.
101
+ const file = target.packet ?? target.source_file;
102
+ if (file !== undefined && size !== undefined) {
103
+ const files = measured.get(target.branch) ?? new Map();
104
+ files.set(file, size);
105
+ measured.set(target.branch, files);
106
+ }
107
+ else {
108
+ unmeasured.set(target.branch, (unmeasured.get(target.branch) ?? 0) + 1);
109
+ }
110
+ }
111
+ const branches = new Set([...measured.keys(), ...unmeasured.keys()]);
112
+ return [...branches].sort().flatMap((branch) => {
113
+ const files = measured.get(branch);
114
+ if (!files || files.size === 0)
115
+ return [];
116
+ const bytes = [...files.values()].reduce((sum, size) => sum + size, 0);
117
+ const missing = unmeasured.get(branch) ?? 0;
118
+ const withMissing = bytes + Math.round((bytes / files.size) * missing);
119
+ const read_tokens = Math.round(withMissing / BYTES_PER_TOKEN);
120
+ return [{
121
+ branch, files: files.size, bytes, unmeasured: missing,
122
+ read_tokens, work_tokens: read_tokens * (1 + WRITTEN_PER_READ),
123
+ }];
124
+ });
125
+ }
126
+ // The index is a file on the vibecoder's disk, so a size that is not a real byte
127
+ // count is treated as a size we do not have rather than as zero. Zero would
128
+ // quietly shrink the branch's average.
129
+ function sizeOf(target) {
130
+ const { bytes } = target;
131
+ return typeof bytes === 'number' && Number.isFinite(bytes) && bytes >= 0 ? bytes : undefined;
132
+ }
133
+ export function widthLine(width) {
134
+ return (` ${width.branch} — ${width.planned_cases} planned ` +
135
+ `${width.planned_cases === 1 ? 'case' : 'cases'}: ${width.workers} ` +
136
+ `${width.workers === 1 ? 'worker' : 'workers'} of about ${width.turns_each} turns each ` +
137
+ `(${width.fewest}–${width.most} accepted).\n`);
138
+ }
139
+ export function workloadLine(load) {
140
+ const missing = load.unmeasured === 0
141
+ ? ''
142
+ : ` plus ${load.unmeasured} ${load.unmeasured === 1 ? 'entrypoint' : 'entrypoints'} nothing resolved, ` +
143
+ 'priced at what the others average';
144
+ return (` ${load.branch} — ${load.files} ${load.files === 1 ? 'file' : 'files'}, ` +
145
+ `${load.bytes.toLocaleString('en-US')} bytes${missing}: ` +
146
+ `${load.read_tokens.toLocaleString('en-US')} tokens to read and about ` +
147
+ `${(load.work_tokens - load.read_tokens).toLocaleString('en-US')} to write.\n`);
148
+ }
@@ -67,7 +67,7 @@ abort 'unitbob_helper: refusing to run against a non-test environment' unless Ra
67
67
  // runs need no connector-written support files here (the runtime pytest.ini
68
68
  // lives outside this directory and is written by the pytest runner).
69
69
  //
70
- // Every file, not just the main one (spec 43, §6.4). The directory is wiped
70
+ // Every file, not just the main one (spec one-place-per-rule, §6.4). The directory is wiped
71
71
  // first and only the main file was written back, so a published suite of four
72
72
  // files came back as one and the run that followed it silently protected a
73
73
  // quarter of what the map claimed.
@@ -0,0 +1,325 @@
1
+ import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs';
2
+ import { dirname, join, resolve, sep } from 'node:path';
3
+ import { graphNodes, methodNameOf } from "../surfaces/graph.js";
4
+ import { assertUnitbobPath } from "./artifactPath.js";
5
+ import { surfacesPath } from "./mapBuild.js";
6
+ // Spec 37-1. A worker used to receive names — `User#get_token`, `POST /api/tokens` —
7
+ // and spend its first two thirds finding the code behind them. On microblog,
8
+ // 2026-08-23, that was 474 of 674 worker turns and 63% of their input, once per
9
+ // worker, for an application whose entire source is 92 KB.
10
+ //
11
+ // Resolving a name to a file is a dictionary lookup against two artifacts that
12
+ // are already on this machine, so it costs no tokens and asks no model. What it
13
+ // finds is copied whole: the packet carries exactly what the worker would have
14
+ // opened anyway, which is why it cannot inflate a run.
15
+ //
16
+ // Nothing here goes up the wire. Spec 22 keeps the source on the vibecoder's
17
+ // machine, and a packet is a local file the worker opens by path.
18
+ export const PACKETS_DIR = '.unitbob/suite-build/packets';
19
+ // The fuse, not a setting. It exists for the five-thousand-line controller
20
+ // whose file is not "what a worker would have read anyway" — there the packet
21
+ // carries the path and says so, and the worker opens the part it needs. There
22
+ // is nowhere for this number to live except this source file: the size of a
23
+ // packet is not a fact about the project, it is a fact about what fits.
24
+ export const MAX_PACKET_BYTES = 200_000;
25
+ export function packetsDir(projectRoot) {
26
+ return join(projectRoot, '.unitbob', 'suite-build', 'packets');
27
+ }
28
+ export function packetIndexPath(projectRoot) {
29
+ return join(packetsDir(projectRoot), 'index.json');
30
+ }
31
+ export function readPacketIndex(projectRoot) {
32
+ const path = packetIndexPath(projectRoot);
33
+ if (!existsSync(path))
34
+ return null;
35
+ try {
36
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
37
+ if (!Array.isArray(parsed?.targets))
38
+ return null;
39
+ // Entries, not just the array: a hand-edited index must not throw out of a
40
+ // verb whose whole policy is that a bad index costs the packets and nothing
41
+ // else.
42
+ return { targets: parsed.targets.filter((target) => !!asRecord(target) && asString(target?.id) !== undefined) };
43
+ }
44
+ catch {
45
+ // A packet index we cannot read is the same as none: the worker searches,
46
+ // as it did before this spec. It is never a reason to fail a build.
47
+ return null;
48
+ }
49
+ }
50
+ // Build every packet for this request. Called right after `request.json` is
51
+ // written, and before any plan exists: the entrypoints are known from the
52
+ // request, the workers are not, so a packet belongs to an entrypoint and two
53
+ // entrypoints in one file share one packet.
54
+ export function writeSuitePackets(projectRoot, request) {
55
+ const targets = targetsOf(request);
56
+ const lookup = buildLookup(projectRoot);
57
+ const notes = new Map();
58
+ // Regenerated whole, every run. A packet left over from a previous assignment
59
+ // sits under an authoritative name with nothing on it saying how old it is.
60
+ const dir = packetsDir(projectRoot);
61
+ rmSync(dir, { recursive: true, force: true });
62
+ mkdirSync(dir, { recursive: true });
63
+ const written = new Map();
64
+ const refused = new Map();
65
+ for (const target of targets) {
66
+ const sourceFile = lookup(target.entrypoint);
67
+ if (!sourceFile) {
68
+ target.note = 'no single file in graph.json or surfaces.json answers to this name — find it yourself';
69
+ count(notes, 'did not resolve to one file');
70
+ continue;
71
+ }
72
+ // The path travels even when the contents do not: a worker told which file
73
+ // to open has still been saved the search.
74
+ target.source_file = sourceFile;
75
+ // Two entrypoints in one file get one packet, written once, referenced twice.
76
+ if (!written.has(sourceFile) && !refused.has(sourceFile)) {
77
+ const copied = copyPacket(projectRoot, sourceFile);
78
+ if (typeof copied === 'number')
79
+ written.set(sourceFile, copied);
80
+ else
81
+ refused.set(sourceFile, copied);
82
+ }
83
+ const bytes = written.get(sourceFile);
84
+ if (bytes === undefined) {
85
+ const refusal = refused.get(sourceFile);
86
+ target.note = refusal.note;
87
+ // The size travels even when the contents do not, so spec 37-3 can weigh
88
+ // this entrypoint. `packet` stays unset: there is still nothing to open
89
+ // under the packets folder, and every reader keys off that, not off size.
90
+ if (refusal.bytes !== undefined)
91
+ target.bytes = refusal.bytes;
92
+ count(notes, refusal.kind);
93
+ continue;
94
+ }
95
+ target.packet = `${PACKETS_DIR}/${sourceFile}`;
96
+ target.bytes = bytes;
97
+ }
98
+ writeFileSync(packetIndexPath(projectRoot), `${JSON.stringify({ targets }, null, 2)}\n`);
99
+ return {
100
+ targets: targets.length,
101
+ located: targets.filter((target) => target.source_file).length,
102
+ resolved: targets.filter((target) => target.packet).length,
103
+ files: written.size,
104
+ bytes: [...written.values()].reduce((sum, bytes) => sum + bytes, 0),
105
+ notes: [...notes].map(([note, count]) => `${count} × ${note}`),
106
+ };
107
+ }
108
+ // The two names a request carries. Structural interfaces name methods
109
+ // (`User#get_token`); behavioral capabilities name addresses (`POST /api/tokens`).
110
+ // Both are entrypoints, both resolve to a file, and both produce the same kind
111
+ // of packet — a worker on either branch opens the same thing the same way.
112
+ function targetsOf(request) {
113
+ const targets = [];
114
+ for (const branch of request.branches) {
115
+ const assignment = branch.assignment;
116
+ for (const block of asArray(assignment?.blocks)) {
117
+ for (const iface of asArray(asRecord(block)?.interfaces)) {
118
+ const record = asRecord(iface);
119
+ const id = asString(record?.interface_id);
120
+ if (!id)
121
+ continue;
122
+ for (const entrypoint of asArray(record?.entrypoints)) {
123
+ const name = asString(entrypoint);
124
+ if (name)
125
+ targets.push({ branch: branch.suite_kind, id, entrypoint: name });
126
+ }
127
+ }
128
+ }
129
+ for (const capability of asArray(assignment?.capabilities)) {
130
+ const record = asRecord(capability);
131
+ const id = asString(record?.capability_id);
132
+ if (!id)
133
+ continue;
134
+ for (const surface of asArray(record?.surfaces)) {
135
+ const name = asString(surface);
136
+ if (name)
137
+ targets.push({ branch: branch.suite_kind, id, entrypoint: name });
138
+ }
139
+ }
140
+ }
141
+ return targets;
142
+ }
143
+ // One name in, at most one file out. Two local artifacts answer, both of them
144
+ // built from the graph rather than from anybody's spelling rule:
145
+ //
146
+ // `surfaces.json` — the addresses the router declared, each already carrying
147
+ // the file that serves it. It answers a behavioral surface (`POST /api/tokens`)
148
+ // by its id and a structural entrypoint (`MainRoutes.export_posts`) by the
149
+ // handler label, which is the router's own wording, not a name we built.
150
+ //
151
+ // `graph.json` — every symbol graphify saw, matched by method name. A name
152
+ // alone is not enough: `User#get_token` and `ApiTokens.get_token` are two
153
+ // different entrypoints and graphify saw only one `get_token()`. So the owner
154
+ // the entrypoint named has to agree with the file before it is believed.
155
+ //
156
+ // Ambiguity ends in silence, never in a first match, and neither does a name
157
+ // that resolves to a file its owner has nothing to do with. A packet holding
158
+ // the wrong file is worse than no packet: the worker reads it, believes it, and
159
+ // writes a test about code that does not serve this entrypoint. On microblog,
160
+ // 2026-08-23, matching on the bare name alone sent both `ApiTokens` entrypoints
161
+ // to `app/models.py`, whose `get_token` belongs to `User`.
162
+ function buildLookup(projectRoot) {
163
+ const byAddress = new Map();
164
+ for (const surface of readSurfaces(projectRoot)) {
165
+ const record = asRecord(surface);
166
+ const file = normalisePath(asString(record?.source_file));
167
+ if (!file)
168
+ continue;
169
+ for (const key of [asString(record?.id), asString(record?.handler_label)]) {
170
+ if (key)
171
+ remember(byAddress, key, file);
172
+ }
173
+ }
174
+ const byMethod = new Map();
175
+ const byLabel = new Map();
176
+ for (const node of graphNodes(projectRoot)) {
177
+ const file = normalisePath(typeof node.source_file === 'string' ? node.source_file : undefined);
178
+ if (typeof node.label !== 'string' || !file)
179
+ continue;
180
+ remember(byMethod, methodNameOf(node.label), file);
181
+ remember(byLabel, node.label, file);
182
+ }
183
+ // The owner and the file agree if graphify put something of the owner's name
184
+ // in that file, or if the owner is the file — `ApiAuth` and `app/api/auth.py`
185
+ // are the same thing said twice, once in the map's words and once in the
186
+ // filesystem's. This confirms a file we already found; it never builds a path
187
+ // out of a name, which would be us maintaining somebody else's naming rule.
188
+ const agrees = (owner, file) => byLabel.get(owner)?.has(file) === true ||
189
+ byMethod.get(owner)?.has(file) === true ||
190
+ squash(file).includes(squash(owner));
191
+ return (entrypoint) => {
192
+ const declared = only(byAddress.get(entrypoint));
193
+ if (declared)
194
+ return declared;
195
+ // An address is the router's word, not a symbol, and must not fall through
196
+ // to symbol matching: `GET /users` would resolve by the name `users` to
197
+ // whatever single thing answers to it — a helper, a model, anything.
198
+ if (/\s/.test(entrypoint))
199
+ return undefined;
200
+ const named = byMethod.get(methodNameOf(entrypoint));
201
+ if (!named)
202
+ return undefined;
203
+ const owner = ownerNameOf(entrypoint);
204
+ if (!owner)
205
+ return only(named);
206
+ return only(new Set([...named].filter((file) => agrees(owner, file))));
207
+ };
208
+ }
209
+ // The name the entrypoint hangs its method on: `User` in `User#get_token`,
210
+ // `Invoice` in `Billing::Invoice#total`. Split exactly as `methodNameOf` splits,
211
+ // so the two never disagree about where a name ends.
212
+ function ownerNameOf(entrypoint) {
213
+ const parts = entrypoint.replace(/\(.*\)\s*$/, '').split(/::|[#./]/).filter(Boolean);
214
+ return parts.length > 1 ? parts[parts.length - 2] : undefined;
215
+ }
216
+ function squash(value) {
217
+ return value.toLowerCase().replace(/[^a-z0-9]/g, '');
218
+ }
219
+ // graphify writes a path the way its host does. `app\models.rb` and
220
+ // `./app/models.rb` are the same file as `app/models.rb`, and treating them as
221
+ // three keys makes one file look like three and one method look ambiguous.
222
+ function normalisePath(value) {
223
+ const normalised = value?.replace(/\\/g, '/').replace(/^\.\//, '');
224
+ return normalised || undefined;
225
+ }
226
+ function remember(into, key, file) {
227
+ const seen = into.get(key) ?? new Set();
228
+ seen.add(file);
229
+ into.set(key, seen);
230
+ }
231
+ function count(notes, note) {
232
+ notes.set(note, (notes.get(note) ?? 0) + 1);
233
+ }
234
+ function only(files) {
235
+ return files && files.size === 1 ? [...files][0] : undefined;
236
+ }
237
+ function readSurfaces(projectRoot) {
238
+ const path = surfacesPath(projectRoot);
239
+ if (!existsSync(path))
240
+ return [];
241
+ try {
242
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
243
+ return asArray(parsed?.surfaces);
244
+ }
245
+ catch {
246
+ return [];
247
+ }
248
+ }
249
+ // Copy one source file into the packets folder, keeping the path it has in the
250
+ // checkout so the worker recognises it. Returns the byte count, or why there is
251
+ // no copy to make. It never throws: one unreadable file out of a hundred is one
252
+ // worker searching, not a run without packets.
253
+ function copyPacket(projectRoot, sourceFile) {
254
+ const relative = `${PACKETS_DIR}/${sourceFile}`;
255
+ try {
256
+ assertUnitbobPath(relative, PACKETS_DIR);
257
+ }
258
+ catch {
259
+ return {
260
+ note: `graph.json names a path we will not write (${sourceFile}) — find the file yourself`,
261
+ kind: 'named a path we will not write',
262
+ };
263
+ }
264
+ try {
265
+ // The path came from graphify's output, not from us. A symlink or an
266
+ // absolute path there must not turn "copy a project file" into "copy
267
+ // anything on this machine", so the file is required to really live inside
268
+ // the checkout. `realpathSync` resolves every component, so a symlinked
269
+ // directory in the middle of the path is caught the same way.
270
+ const root = realpathSync(projectRoot);
271
+ let onDisk;
272
+ try {
273
+ onDisk = realpathSync(resolve(projectRoot, sourceFile));
274
+ }
275
+ catch {
276
+ return {
277
+ note: `${sourceFile} is named in graph.json but is not on disk — find the file yourself`,
278
+ kind: 'named a file that is not on disk',
279
+ };
280
+ }
281
+ if (onDisk !== root && !onDisk.startsWith(root + sep)) {
282
+ return {
283
+ note: `${sourceFile} resolves outside the project — find the file yourself`,
284
+ kind: 'resolved outside the project',
285
+ };
286
+ }
287
+ const stat = statSync(onDisk);
288
+ // A directory passes the size check comfortably and then throws EISDIR on
289
+ // read. Said here rather than caught below, so the words name the cause.
290
+ if (!stat.isFile()) {
291
+ return {
292
+ note: `${sourceFile} is not a file — find the code yourself`,
293
+ kind: 'named something that is not a file',
294
+ };
295
+ }
296
+ if (stat.size > MAX_PACKET_BYTES) {
297
+ return {
298
+ note: `${sourceFile} is ${stat.size.toLocaleString('en-US')} bytes, over the ${MAX_PACKET_BYTES.toLocaleString('en-US')}-byte packet fuse — open it at that path instead`,
299
+ kind: 'over the packet fuse — the path travels instead',
300
+ bytes: stat.size,
301
+ };
302
+ }
303
+ // The bytes we actually wrote, not the size we saw a moment ago.
304
+ const body = readFileSync(onDisk);
305
+ const destination = join(projectRoot, relative);
306
+ mkdirSync(dirname(destination), { recursive: true });
307
+ writeFileSync(destination, body);
308
+ return body.length;
309
+ }
310
+ catch (err) {
311
+ return {
312
+ note: `${sourceFile} could not be copied (${err.message}) — find the file yourself`,
313
+ kind: 'could not be copied',
314
+ };
315
+ }
316
+ }
317
+ function asArray(value) {
318
+ return Array.isArray(value) ? value : [];
319
+ }
320
+ function asRecord(value) {
321
+ return value && typeof value === 'object' ? value : undefined;
322
+ }
323
+ function asString(value) {
324
+ return typeof value === 'string' && value.trim() !== '' ? value : undefined;
325
+ }
@@ -1,4 +1,4 @@
1
- import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
1
+ import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
2
2
  import { createHash } from 'node:crypto';
3
3
  import { dirname, join, sep } from 'node:path';
4
4
  import { assertUnitbobPath } from "./artifactPath.js";
@@ -18,6 +18,38 @@ export function reviewRequestPath(projectRoot) {
18
18
  export function candidateRunPath(projectRoot) {
19
19
  return join(projectRoot, '.unitbob', 'suite-build', 'candidate-run.json');
20
20
  }
21
+ // Spec 37-2, criterion 3. What a new build leaves behind, in the order a reader
22
+ // meets it: the plan, the checkpoints written against that plan, and the answer
23
+ // assembled from them. All three are bound to the `request.json` this run is
24
+ // about to overwrite, so from the next line on they are a previous run's papers
25
+ // wearing this run's filenames — which is exactly the confusion the coordinator
26
+ // used to spend a turn untangling before fan-out.
27
+ const PREVIOUS_RUN_ARTIFACTS = ['worker-plan.json', 'checkpoints', 'suite_output.json'];
28
+ const PREVIOUS_DIR = 'previous';
29
+ // Moved, never removed. A run costs hours and real money, and one interrupt plus
30
+ // one restart must not be able to spend that twice — `previous/` is one line
31
+ // more than `rmSync` and it is the line that makes a restart survivable.
32
+ //
33
+ // Replaced one artifact at a time rather than by clearing `previous/` first. The
34
+ // tidier version — wipe, then move whatever exists — loses a complete previous
35
+ // run to a partial one: a build interrupted between writing the plan and seeding
36
+ // its checkpoints displaces only `worker-plan.json`, and clearing would take the
37
+ // finished checkpoints and answer of the run before it with no way back. A
38
+ // `previous/` holding pieces of two runs is worth strictly more than an empty
39
+ // one, and every piece in it is named by the file it kept.
40
+ export function movePreviousRunAside(projectRoot) {
41
+ const buildDir = join(projectRoot, '.unitbob', 'suite-build');
42
+ const found = PREVIOUS_RUN_ARTIFACTS.filter((name) => existsSync(join(buildDir, name)));
43
+ if (found.length === 0)
44
+ return [];
45
+ const previous = join(buildDir, PREVIOUS_DIR);
46
+ mkdirSync(previous, { recursive: true });
47
+ for (const name of found) {
48
+ rmSync(join(previous, name), { recursive: true, force: true });
49
+ renameSync(join(buildDir, name), join(previous, name));
50
+ }
51
+ return found;
52
+ }
21
53
  export function writeBehavioralReviewRequest(projectRoot, output, candidateRun, knownDefectContext = { status: 'not_supplied' }, fixedCandidateRun) {
22
54
  const metadata = output.test_metadata;
23
55
  const candidateDigest = suiteCandidateDigest(output);
@@ -83,7 +115,7 @@ export function branchRunner(output) {
83
115
  return runner;
84
116
  }
85
117
  // What the reviewer actually read: the suite files, and the manifest that runs
86
- // them. Nothing else (spec 43, §4).
118
+ // them. Nothing else (spec one-place-per-rule, §4).
87
119
  //
88
120
  // `test_metadata` used to be in here, and the server's copy of this formula
89
121
  // stripped the review's own keys back out to match — two lists that had to stay
@@ -2,7 +2,7 @@ import { readBehavioralReview } from "./suiteBuild.js";
2
2
  // What travels to the server, and what "published" means when it answers. One
3
3
  // module, because two commands ask those questions: `put-suite-build` sends the
4
4
  // batch, and `validate-build` sends the same batch as a dry run so the server's
5
- // verdict is about the exact bytes the publish will carry (spec 43, §3).
5
+ // verdict is about the exact bytes the publish will carry (spec one-place-per-rule, §3).
6
6
  //
7
7
  // A second assembly would be a second answer to "what are we uploading", and the
8
8
  // dry run would then be checking something the publish does not send — which is