unitbob 0.6.2 → 0.7.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/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);
@@ -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,319 @@
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
+ count(notes, refusal.kind);
88
+ continue;
89
+ }
90
+ target.packet = `${PACKETS_DIR}/${sourceFile}`;
91
+ target.bytes = bytes;
92
+ }
93
+ writeFileSync(packetIndexPath(projectRoot), `${JSON.stringify({ targets }, null, 2)}\n`);
94
+ return {
95
+ targets: targets.length,
96
+ located: targets.filter((target) => target.source_file).length,
97
+ resolved: targets.filter((target) => target.packet).length,
98
+ files: written.size,
99
+ bytes: [...written.values()].reduce((sum, bytes) => sum + bytes, 0),
100
+ notes: [...notes].map(([note, count]) => `${count} × ${note}`),
101
+ };
102
+ }
103
+ // The two names a request carries. Structural interfaces name methods
104
+ // (`User#get_token`); behavioral capabilities name addresses (`POST /api/tokens`).
105
+ // Both are entrypoints, both resolve to a file, and both produce the same kind
106
+ // of packet — a worker on either branch opens the same thing the same way.
107
+ function targetsOf(request) {
108
+ const targets = [];
109
+ for (const branch of request.branches) {
110
+ const assignment = branch.assignment;
111
+ for (const block of asArray(assignment?.blocks)) {
112
+ for (const iface of asArray(asRecord(block)?.interfaces)) {
113
+ const record = asRecord(iface);
114
+ const id = asString(record?.interface_id);
115
+ if (!id)
116
+ continue;
117
+ for (const entrypoint of asArray(record?.entrypoints)) {
118
+ const name = asString(entrypoint);
119
+ if (name)
120
+ targets.push({ branch: branch.suite_kind, id, entrypoint: name });
121
+ }
122
+ }
123
+ }
124
+ for (const capability of asArray(assignment?.capabilities)) {
125
+ const record = asRecord(capability);
126
+ const id = asString(record?.capability_id);
127
+ if (!id)
128
+ continue;
129
+ for (const surface of asArray(record?.surfaces)) {
130
+ const name = asString(surface);
131
+ if (name)
132
+ targets.push({ branch: branch.suite_kind, id, entrypoint: name });
133
+ }
134
+ }
135
+ }
136
+ return targets;
137
+ }
138
+ // One name in, at most one file out. Two local artifacts answer, both of them
139
+ // built from the graph rather than from anybody's spelling rule:
140
+ //
141
+ // `surfaces.json` — the addresses the router declared, each already carrying
142
+ // the file that serves it. It answers a behavioral surface (`POST /api/tokens`)
143
+ // by its id and a structural entrypoint (`MainRoutes.export_posts`) by the
144
+ // handler label, which is the router's own wording, not a name we built.
145
+ //
146
+ // `graph.json` — every symbol graphify saw, matched by method name. A name
147
+ // alone is not enough: `User#get_token` and `ApiTokens.get_token` are two
148
+ // different entrypoints and graphify saw only one `get_token()`. So the owner
149
+ // the entrypoint named has to agree with the file before it is believed.
150
+ //
151
+ // Ambiguity ends in silence, never in a first match, and neither does a name
152
+ // that resolves to a file its owner has nothing to do with. A packet holding
153
+ // the wrong file is worse than no packet: the worker reads it, believes it, and
154
+ // writes a test about code that does not serve this entrypoint. On microblog,
155
+ // 2026-08-23, matching on the bare name alone sent both `ApiTokens` entrypoints
156
+ // to `app/models.py`, whose `get_token` belongs to `User`.
157
+ function buildLookup(projectRoot) {
158
+ const byAddress = new Map();
159
+ for (const surface of readSurfaces(projectRoot)) {
160
+ const record = asRecord(surface);
161
+ const file = normalisePath(asString(record?.source_file));
162
+ if (!file)
163
+ continue;
164
+ for (const key of [asString(record?.id), asString(record?.handler_label)]) {
165
+ if (key)
166
+ remember(byAddress, key, file);
167
+ }
168
+ }
169
+ const byMethod = new Map();
170
+ const byLabel = new Map();
171
+ for (const node of graphNodes(projectRoot)) {
172
+ const file = normalisePath(typeof node.source_file === 'string' ? node.source_file : undefined);
173
+ if (typeof node.label !== 'string' || !file)
174
+ continue;
175
+ remember(byMethod, methodNameOf(node.label), file);
176
+ remember(byLabel, node.label, file);
177
+ }
178
+ // The owner and the file agree if graphify put something of the owner's name
179
+ // in that file, or if the owner is the file — `ApiAuth` and `app/api/auth.py`
180
+ // are the same thing said twice, once in the map's words and once in the
181
+ // filesystem's. This confirms a file we already found; it never builds a path
182
+ // out of a name, which would be us maintaining somebody else's naming rule.
183
+ const agrees = (owner, file) => byLabel.get(owner)?.has(file) === true ||
184
+ byMethod.get(owner)?.has(file) === true ||
185
+ squash(file).includes(squash(owner));
186
+ return (entrypoint) => {
187
+ const declared = only(byAddress.get(entrypoint));
188
+ if (declared)
189
+ return declared;
190
+ // An address is the router's word, not a symbol, and must not fall through
191
+ // to symbol matching: `GET /users` would resolve by the name `users` to
192
+ // whatever single thing answers to it — a helper, a model, anything.
193
+ if (/\s/.test(entrypoint))
194
+ return undefined;
195
+ const named = byMethod.get(methodNameOf(entrypoint));
196
+ if (!named)
197
+ return undefined;
198
+ const owner = ownerNameOf(entrypoint);
199
+ if (!owner)
200
+ return only(named);
201
+ return only(new Set([...named].filter((file) => agrees(owner, file))));
202
+ };
203
+ }
204
+ // The name the entrypoint hangs its method on: `User` in `User#get_token`,
205
+ // `Invoice` in `Billing::Invoice#total`. Split exactly as `methodNameOf` splits,
206
+ // so the two never disagree about where a name ends.
207
+ function ownerNameOf(entrypoint) {
208
+ const parts = entrypoint.replace(/\(.*\)\s*$/, '').split(/::|[#./]/).filter(Boolean);
209
+ return parts.length > 1 ? parts[parts.length - 2] : undefined;
210
+ }
211
+ function squash(value) {
212
+ return value.toLowerCase().replace(/[^a-z0-9]/g, '');
213
+ }
214
+ // graphify writes a path the way its host does. `app\models.rb` and
215
+ // `./app/models.rb` are the same file as `app/models.rb`, and treating them as
216
+ // three keys makes one file look like three and one method look ambiguous.
217
+ function normalisePath(value) {
218
+ const normalised = value?.replace(/\\/g, '/').replace(/^\.\//, '');
219
+ return normalised || undefined;
220
+ }
221
+ function remember(into, key, file) {
222
+ const seen = into.get(key) ?? new Set();
223
+ seen.add(file);
224
+ into.set(key, seen);
225
+ }
226
+ function count(notes, note) {
227
+ notes.set(note, (notes.get(note) ?? 0) + 1);
228
+ }
229
+ function only(files) {
230
+ return files && files.size === 1 ? [...files][0] : undefined;
231
+ }
232
+ function readSurfaces(projectRoot) {
233
+ const path = surfacesPath(projectRoot);
234
+ if (!existsSync(path))
235
+ return [];
236
+ try {
237
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
238
+ return asArray(parsed?.surfaces);
239
+ }
240
+ catch {
241
+ return [];
242
+ }
243
+ }
244
+ // Copy one source file into the packets folder, keeping the path it has in the
245
+ // checkout so the worker recognises it. Returns the byte count, or why there is
246
+ // no copy to make. It never throws: one unreadable file out of a hundred is one
247
+ // worker searching, not a run without packets.
248
+ function copyPacket(projectRoot, sourceFile) {
249
+ const relative = `${PACKETS_DIR}/${sourceFile}`;
250
+ try {
251
+ assertUnitbobPath(relative, PACKETS_DIR);
252
+ }
253
+ catch {
254
+ return {
255
+ note: `graph.json names a path we will not write (${sourceFile}) — find the file yourself`,
256
+ kind: 'named a path we will not write',
257
+ };
258
+ }
259
+ try {
260
+ // The path came from graphify's output, not from us. A symlink or an
261
+ // absolute path there must not turn "copy a project file" into "copy
262
+ // anything on this machine", so the file is required to really live inside
263
+ // the checkout. `realpathSync` resolves every component, so a symlinked
264
+ // directory in the middle of the path is caught the same way.
265
+ const root = realpathSync(projectRoot);
266
+ let onDisk;
267
+ try {
268
+ onDisk = realpathSync(resolve(projectRoot, sourceFile));
269
+ }
270
+ catch {
271
+ return {
272
+ note: `${sourceFile} is named in graph.json but is not on disk — find the file yourself`,
273
+ kind: 'named a file that is not on disk',
274
+ };
275
+ }
276
+ if (onDisk !== root && !onDisk.startsWith(root + sep)) {
277
+ return {
278
+ note: `${sourceFile} resolves outside the project — find the file yourself`,
279
+ kind: 'resolved outside the project',
280
+ };
281
+ }
282
+ const stat = statSync(onDisk);
283
+ // A directory passes the size check comfortably and then throws EISDIR on
284
+ // read. Said here rather than caught below, so the words name the cause.
285
+ if (!stat.isFile()) {
286
+ return {
287
+ note: `${sourceFile} is not a file — find the code yourself`,
288
+ kind: 'named something that is not a file',
289
+ };
290
+ }
291
+ if (stat.size > MAX_PACKET_BYTES) {
292
+ return {
293
+ 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`,
294
+ kind: 'over the packet fuse — the path travels instead',
295
+ };
296
+ }
297
+ // The bytes we actually wrote, not the size we saw a moment ago.
298
+ const body = readFileSync(onDisk);
299
+ const destination = join(projectRoot, relative);
300
+ mkdirSync(dirname(destination), { recursive: true });
301
+ writeFileSync(destination, body);
302
+ return body.length;
303
+ }
304
+ catch (err) {
305
+ return {
306
+ note: `${sourceFile} could not be copied (${err.message}) — find the file yourself`,
307
+ kind: 'could not be copied',
308
+ };
309
+ }
310
+ }
311
+ function asArray(value) {
312
+ return Array.isArray(value) ? value : [];
313
+ }
314
+ function asRecord(value) {
315
+ return value && typeof value === 'object' ? value : undefined;
316
+ }
317
+ function asString(value) {
318
+ return typeof value === 'string' && value.trim() !== '' ? value : undefined;
319
+ }
@@ -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
@@ -1,6 +1,6 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { existsSync, readFileSync } from 'node:fs';
3
- import { join } from 'node:path';
2
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { dirname, join } from 'node:path';
4
4
  import { detectStructuralRunner } from "../runner/precheck.js";
5
5
  import { assertUnitbobPath } from "./artifactPath.js";
6
6
  export function workerPlanPath(projectRoot) {
@@ -34,6 +34,75 @@ export function readWorkerPlan(projectRoot) {
34
34
  }
35
35
  return parsed;
36
36
  }
37
+ export const SUPERSEDED_DIR = 'superseded';
38
+ export function seedWorkerCheckpoints(projectRoot) {
39
+ const plan = readWorkerPlan(projectRoot);
40
+ const request_digest = requestDigest(projectRoot);
41
+ const plan_digest = workerPlanDigest(projectRoot);
42
+ const written = [];
43
+ const kept = [];
44
+ const superseded = [];
45
+ for (const item of plan.workers) {
46
+ const path = checkpointPath(projectRoot, item);
47
+ const label = `${item.branch}:${item.worker_id}`;
48
+ // Left alone when it already belongs to this plan: the coordinator's facts
49
+ // and a worker's finished slice both live in this file, and a run costs
50
+ // hours.
51
+ if (belongsToPlan(path, plan_digest)) {
52
+ kept.push(label);
53
+ continue;
54
+ }
55
+ // Everything else needs a fresh seed — but the file being replaced is not
56
+ // necessarily worthless. `plan_digest` is a digest of the whole
57
+ // `worker-plan.json`, so editing one slice invalidates every checkpoint at
58
+ // once, including finished ones; and replanning after the first slice comes
59
+ // back is a documented, ordinary move. Overwriting in place would have made
60
+ // this verb the one thing in the build that destroys hours of work, on the
61
+ // most ordinary path there is. Moved for the same reason `suite-prepare`
62
+ // moves the previous run rather than deleting it.
63
+ if (existsSync(path)) {
64
+ const aside = join(dirname(path), SUPERSEDED_DIR, `${item.branch}-${item.worker_id}.json`);
65
+ mkdirSync(dirname(aside), { recursive: true });
66
+ rmSync(aside, { force: true });
67
+ renameSync(path, aside);
68
+ superseded.push(label);
69
+ }
70
+ mkdirSync(dirname(path), { recursive: true });
71
+ writeFileSync(path, `${JSON.stringify(seedFor(item, request_digest, plan_digest), null, 2)}\n`);
72
+ written.push(label);
73
+ }
74
+ return { written, kept, superseded };
75
+ }
76
+ function belongsToPlan(path, planDigest) {
77
+ if (!existsSync(path))
78
+ return false;
79
+ try {
80
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
81
+ return parsed?.plan_digest === planDigest;
82
+ }
83
+ catch {
84
+ return false;
85
+ }
86
+ }
87
+ function seedFor(item, request_digest, plan_digest) {
88
+ return {
89
+ request_digest,
90
+ plan_digest,
91
+ branch: item.branch,
92
+ worker_id: item.worker_id,
93
+ // Every promise starts unresolved: the slice has not been worked yet, and
94
+ // the gate wants each one accounted for exactly once.
95
+ unresolved_promises: [...item.promises],
96
+ completed_promises: [],
97
+ written_paths: [],
98
+ decisions: [],
99
+ known_problems: [],
100
+ // Behavioral only, and absent rather than empty elsewhere — it joins Gherkin
101
+ // Scenarios to addresses, and the structural branch has no Scenarios.
102
+ ...(item.branch === 'behavioral' ? { surface_coverage: [] } : {}),
103
+ facts: [],
104
+ };
105
+ }
37
106
  const RUBY_HARNESS = {
38
107
  behavioral: '.unitbob/behavioral/step_definitions/00_unitbob_world.rb',
39
108
  structural: '.unitbob/structural/unitbob_helper.rb',
@@ -70,6 +139,14 @@ export function validateWorkerPlanFiles(projectRoot) {
70
139
  const label = workerLabel(item, index);
71
140
  if (!isNonEmptyString(item?.branch) || !expectedByBranch.has(item.branch))
72
141
  errors.push(`${label}: branch is not in the request`);
142
+ // Both halves of the checkpoint filename, held to the same rule. `worker_id`
143
+ // has always been checked; `branch` never was, because it only ever came
144
+ // from `request.json` and was only ever read. Since spec 37-2 the pair is
145
+ // also a path this connector *writes*, and `request.json` is a file on the
146
+ // vibecoder's disk — so a `suite_kind` of `../../..` would have put a
147
+ // seeded checkpoint outside the project.
148
+ else if (!/^[a-zA-Z0-9_-]+$/.test(item.branch))
149
+ errors.push(`${label}: branch must be a filename-safe name`);
73
150
  if (!isNonEmptyString(item?.worker_id) || !/^[a-zA-Z0-9_-]+$/.test(item.worker_id))
74
151
  errors.push(`${label}: worker_id must be a stable filename-safe id`);
75
152
  else if (seenWorkers.has(item.worker_id))
@@ -109,9 +186,6 @@ export function validateWorkerPlanFiles(projectRoot) {
109
186
  if (!expectedHarness && isNonEmptyString(item?.harness_path) && !item.harness_path.startsWith('.unitbob/')) {
110
187
  errors.push(`${label}: harness_path must be a connector-owned path under .unitbob/ (got "${item.harness_path}")`);
111
188
  }
112
- if (!item?.limits || item.limits.planned_cases !== item.planned_cases?.length) {
113
- errors.push(`${label}: limits.planned_cases must equal planned_cases.length`);
114
- }
115
189
  for (const ownedPath of Array.isArray(item?.owned_paths) ? item.owned_paths : []) {
116
190
  if (!isNonEmptyString(ownedPath)) {
117
191
  errors.push(`${label}: owned path must be a non-empty string`);