unitbob 0.4.4 → 0.4.5

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
@@ -133,8 +133,10 @@ export async function main(argv, deps = { ensureLinked }) {
133
133
  await contractPrompt(await linked(), args);
134
134
  return 0;
135
135
  case 'run-local':
136
- await runLocal(await linked(), args);
137
- return 0;
136
+ // The one verb whose non-zero exit is not an error: a branch that failed
137
+ // the same set of cases twice in a row (spec 34-6, criterion 3). Red
138
+ // tests still exit zero — a live defect is the suite working.
139
+ return await runLocal(await linked(), args);
138
140
  case 'run':
139
141
  case 'check':
140
142
  await run(await linked(), args);
@@ -2,7 +2,6 @@ import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from
2
2
  import { createHash } from 'node:crypto';
3
3
  import { dirname, join, sep } from 'node:path';
4
4
  import { assertUnitbobPath } from "./artifactPath.js";
5
- import { readBudget, RUN_BUDGET } from "./budget.js";
6
5
  import { readWorkerPlan, validateWorkerPlanFiles, workerPlanDigest, workerPlanPath } from "./workerPlan.js";
7
6
  export function requestPath(projectRoot) {
8
7
  return join(projectRoot, '.unitbob', 'suite-build', 'request.json');
@@ -232,7 +231,6 @@ export function writeSuiteBuildRequest(projectRoot, branches, knownDefectContext
232
231
  output_path: outputPath(projectRoot),
233
232
  branches,
234
233
  known_defect_context: knownDefectContext,
235
- budget: RUN_BUDGET,
236
234
  };
237
235
  const path = requestPath(projectRoot);
238
236
  mkdirSync(dirname(path), { recursive: true });
@@ -251,10 +249,13 @@ export function readSuiteBuildRequest(projectRoot) {
251
249
  !Array.isArray(request.branches)) {
252
250
  throw new Error(`${path} is malformed: expected project_root, output_path, and a branches array.`);
253
251
  }
252
+ // Spec 34-6, criterion 2.3. A request written by an older connector still
253
+ // carries a `budget` block; it is spread through untouched and read by nobody,
254
+ // which is the whole of the compatibility story — there is no ceiling left for
255
+ // it to name.
254
256
  return {
255
257
  ...request,
256
258
  known_defect_context: readKnownDefectContext(request.known_defect_context, path),
257
- budget: readBudget(request.budget),
258
259
  };
259
260
  }
260
261
  function readKnownDefectContext(value, path) {
@@ -54,8 +54,6 @@ export function validateWorkerPlanFiles(projectRoot) {
54
54
  branch.suite_kind,
55
55
  assignmentIds(branch.assignment),
56
56
  ]));
57
- const budget = request.budget;
58
- const workerCeiling = typeof budget?.workers === 'number' ? budget.workers : Number.POSITIVE_INFINITY;
59
57
  const seenWorkers = new Set();
60
58
  const seenPaths = new Map();
61
59
  for (const [index, item] of plan.workers.entries()) {
@@ -97,9 +95,6 @@ export function validateWorkerPlanFiles(projectRoot) {
97
95
  if (!item?.limits || item.limits.planned_cases !== item.planned_cases?.length) {
98
96
  errors.push(`${label}: limits.planned_cases must equal planned_cases.length`);
99
97
  }
100
- if (!Number.isInteger(item?.limits?.fact_finder_lookups) || item.limits.fact_finder_lookups < 0 || item.limits.fact_finder_lookups > 8) {
101
- errors.push(`${label}: limits.fact_finder_lookups must be an integer from 0 to 8`);
102
- }
103
98
  for (const ownedPath of Array.isArray(item?.owned_paths) ? item.owned_paths : []) {
104
99
  if (!isNonEmptyString(ownedPath)) {
105
100
  errors.push(`${label}: owned path must be a non-empty string`);
@@ -118,27 +113,29 @@ export function validateWorkerPlanFiles(projectRoot) {
118
113
  seenPaths.set(ownedPath, label);
119
114
  }
120
115
  }
116
+ // Spec 34-6, criterion 1.6. What is left here catches a corrupted plan, never
117
+ // a small one. The gate used to also demand that every assigned capability
118
+ // appear — which made the plan's size a copy of the assignment's size, and the
119
+ // assignment is the whole product map. That is the load one worker could not
120
+ // finish on a2time, 2026-08-10. Scope is chosen by the coordinator with the
121
+ // user (workflow step 3), and the plan is the only record of it, so a plan
122
+ // that covers part of the assignment is now an ordinary plan.
123
+ //
124
+ // The neighbours stay for the opposite reason: naming a capability the request
125
+ // never assigned, or naming one twice, are ways a plan is wrong rather than
126
+ // ways it is narrow. So is an empty plan for a branch that was given work.
121
127
  for (const [branch, expected] of expectedByBranch) {
122
128
  const items = plan.workers.filter((item) => item && typeof item === 'object' && !Array.isArray(item) && item.branch === branch);
123
129
  if (expected.length > 0 && items.length === 0)
124
130
  errors.push(`${branch}: no worker slice was planned`);
125
- if (items.length > Math.min(workerCeiling, expected.length)) {
126
- errors.push(`${branch}: worker count ${items.length} exceeds min(budget.workers, capability count)`);
127
- }
128
131
  const assigned = items.flatMap((item) => Array.isArray(item.capability_ids) ? item.capability_ids : []);
129
132
  for (const id of expected) {
130
- const count = assigned.filter((candidate) => candidate === id).length;
131
- if (count === 0)
132
- errors.push(`${branch}: assigned capability ${id} is missing from the plan`);
133
- if (count > 1)
133
+ if (assigned.filter((candidate) => candidate === id).length > 1) {
134
134
  errors.push(`${branch}: assigned capability ${id} appears more than once`);
135
+ }
135
136
  }
136
137
  for (const id of assigned.filter((id) => !expected.includes(id)))
137
138
  errors.push(`${branch}: capability ${id} was not assigned by the request`);
138
- const sizes = items.map((item) => Array.isArray(item.planned_cases) ? item.planned_cases.length : 0).filter((size) => size > 0);
139
- if (sizes.length > 1 && Math.max(...sizes) / Math.min(...sizes) > 1.5) {
140
- errors.push(`${branch}: planned case slice ratio exceeds 1.5`);
141
- }
142
139
  }
143
140
  return errors;
144
141
  }
package/dist/proc.js CHANGED
@@ -115,6 +115,21 @@ export const GRAPH_NOISE_PATTERNS = [
115
115
  // Type declarations — a contract for a compiler, with no runtime behaviour.
116
116
  '*.d.ts',
117
117
  '*.pyi',
118
+ // Spec 34-6, criterion 6. Unitbob's own session reports, left in the repo root
119
+ // by the operator, are read as source: on a2time, 2026-08-10, two of them
120
+ // contributed 44 and 25 nodes and pushed their community to third-largest in
121
+ // the whole project. The more often you run unitbob, the dirtier its map gets —
122
+ // a feedback loop with no floor.
123
+ '/unitbob*.md',
124
+ // Test scaffolding and boot wiring. Factories and model specs describe the
125
+ // fixtures a suite builds, not a business promise a guardrail could protect;
126
+ // `config/initializers/` and `config/deploy/` run once at boot and at deploy.
127
+ // The project's own request and feature specs stay — they are evidence of what
128
+ // the app promises.
129
+ 'spec/factories/',
130
+ 'spec/models/',
131
+ 'config/deploy/',
132
+ 'config/initializers/',
118
133
  ];
119
134
  export function ensureUnitbobIgnored(projectRoot) {
120
135
  // `.graphifyignore` is unitbob's own bookkeeping, like the other two entries —
@@ -0,0 +1,238 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { dirname, join } from 'node:path';
4
+ export const RUN_STATE_FILE = 'run-state.json';
5
+ // A marker embedded in a reported test name or Gherkin tag. Same shape the
6
+ // server joins on (`CaseMarker::EMBEDDED`): a full 12-hex marker, never a prefix
7
+ // of a longer hex run. A case with no marker still counts as a failure — it is
8
+ // identified by its file and message alone.
9
+ const MARKER = /ubc_[0-9a-f]{12}(?![0-9a-f])/;
10
+ // The failures a report describes, canonically ordered, or `null` when the
11
+ // report cannot be read as one. `null` is not "green": a runner that died before
12
+ // the first test produces no set at all, and comparing against nothing would
13
+ // stop a branch over a harness problem the loop never even reached.
14
+ export function failureSet(runner, report) {
15
+ if (!report.trim())
16
+ return null;
17
+ const found = extract(runner, report);
18
+ return found && canonical(found);
19
+ }
20
+ // One hash for one set. Same set, same hash, on any machine and in any order.
21
+ export function digestOf(failures) {
22
+ return createHash('sha256').update(JSON.stringify(failures)).digest('hex');
23
+ }
24
+ export function runStatePath(projectRoot) {
25
+ return join(projectRoot, '.unitbob', 'suite-build', RUN_STATE_FILE);
26
+ }
27
+ // A damaged, hand-edited or absent file reads as "no previous run". Refusing to
28
+ // run over a bookkeeping file would turn the one soft stop in the loop into the
29
+ // hardest thing in it — and this file is written by a process that repair
30
+ // deliberately kills and restarts.
31
+ export function readRunState(projectRoot) {
32
+ try {
33
+ const parsed = JSON.parse(readFileSync(runStatePath(projectRoot), 'utf8'));
34
+ const branches = parsed?.branches;
35
+ if (!branches || typeof branches !== 'object' || Array.isArray(branches))
36
+ return {};
37
+ return Object.fromEntries(Object.entries(branches).filter(([, value]) => typeof value === 'string'));
38
+ }
39
+ catch {
40
+ return {};
41
+ }
42
+ }
43
+ // `undefined` forgets this branch: a run that produced no comparable set leaves
44
+ // the next one to be a first run again.
45
+ export function rememberFailures(projectRoot, branch, digest) {
46
+ const branches = readRunState(projectRoot);
47
+ if (digest === undefined)
48
+ delete branches[branch];
49
+ else
50
+ branches[branch] = digest;
51
+ const path = runStatePath(projectRoot);
52
+ mkdirSync(dirname(path), { recursive: true });
53
+ // Written whole and moved into place. A plain write that is interrupted leaves
54
+ // truncated JSON, which reads back as "no previous run" — exactly at the
55
+ // moment a run is being killed and restarted, which is the loop this bounds.
56
+ const staging = `${path}.tmp`;
57
+ writeFileSync(staging, `${JSON.stringify({ branches }, null, 2)}\n`);
58
+ renameSync(staging, path);
59
+ }
60
+ export function clearRunState(projectRoot) {
61
+ rmSync(runStatePath(projectRoot), { force: true });
62
+ }
63
+ function canonical(failures) {
64
+ const byKey = new Map(failures.map((failure) => [keyOf(failure), failure]));
65
+ return [...byKey.keys()].sort().map((key) => byKey.get(key));
66
+ }
67
+ function keyOf(failure) {
68
+ return `${failure.marker}\u0000${failure.file}\u0000${failure.message}`;
69
+ }
70
+ // The connector interprets a report in exactly one other place — `boundReport`,
71
+ // which bounds it for transport — and this switch deliberately mirrors that
72
+ // one's shape rather than inventing a second dispatch. Neither of them judges a
73
+ // run — the server owns every verdict a report leads to. This one only asks "is
74
+ // this the same wall we hit last time", and its answer reaches nothing but an
75
+ // exit code.
76
+ function extract(runner, report) {
77
+ switch (runner) {
78
+ case 'rspec':
79
+ return fromRspec(report);
80
+ case 'vitest':
81
+ return fromVitest(report);
82
+ case 'pytest':
83
+ return fromJunitXml(report);
84
+ case 'cucumber':
85
+ case 'cucumber-js':
86
+ return fromCucumberMessages(report);
87
+ case 'pytest-bdd':
88
+ return fromPytestBdd(report);
89
+ default:
90
+ return null;
91
+ }
92
+ }
93
+ function fromRspec(report) {
94
+ const data = parseObject(report);
95
+ if (!Array.isArray(data?.examples))
96
+ return null;
97
+ return rows(data.examples).flatMap((example) => {
98
+ if (example.status === 'passed')
99
+ return [];
100
+ const name = `${text(example.description)} ${text(example.full_description)}`;
101
+ const exception = example.exception;
102
+ return [failure(name, text(example.file_path), text(exception?.message))];
103
+ });
104
+ }
105
+ function fromVitest(report) {
106
+ const data = parseObject(report);
107
+ if (!Array.isArray(data?.testResults))
108
+ return null;
109
+ return rows(data.testResults).flatMap((file) => {
110
+ const assertions = Array.isArray(file.assertionResults) ? rows(file.assertionResults) : [];
111
+ return assertions.flatMap((assertion) => {
112
+ if (assertion.status === 'passed')
113
+ return [];
114
+ const messages = Array.isArray(assertion.failureMessages) ? assertion.failureMessages : [];
115
+ const name = `${text(assertion.title)} ${text(assertion.fullName)}`;
116
+ return [failure(name, text(file.name), messages.map((m) => text(m)).join('\n'))];
117
+ });
118
+ });
119
+ }
120
+ // pytest's JUnit XML, read the way `boundReport` already reads it: by pattern,
121
+ // because a whole XML parser to answer one yes/no question is a dependency this
122
+ // package does not need.
123
+ //
124
+ // `failure` and `error`, but not `skipped` — and this is the one place the set
125
+ // deliberately does not match the server's "did not pass". A skip never moves:
126
+ // it reports the same thing on every run forever, so counting it here would stop
127
+ // a branch whose only permanent case is a skip, and it would do it while the
128
+ // repair was still fixing everything else. A skip that should not be there is
129
+ // caught at upload, where it costs a message rather than a branch.
130
+ function fromJunitXml(report) {
131
+ if (!/<testsuites?\b/.test(report))
132
+ return null;
133
+ const cases = report.match(/<testcase\b[^>]*(?:\/>|>[\s\S]*?<\/testcase>)/g) ?? [];
134
+ return cases.flatMap((testcase) => {
135
+ const problem = testcase.match(/<(?:failure|error)\b[^>]*(?:\/>|>[\s\S]*?<\/(?:failure|error)>)/);
136
+ if (!problem)
137
+ return [];
138
+ const name = attribute(testcase, 'name');
139
+ const file = attribute(testcase, 'file') || attribute(testcase, 'classname');
140
+ return [failure(name, file, attribute(problem[0], 'message'))];
141
+ });
142
+ }
143
+ // Cucumber Messages (NDJSON), both the Ruby and the JS emitter. One scenario is
144
+ // spread over several envelopes: the pickle holds its name, tags and file, the
145
+ // testCase maps its steps, and testStepFinished carries each step's result.
146
+ function fromCucumberMessages(report) {
147
+ const envelopes = [];
148
+ for (const line of report.split('\n')) {
149
+ if (!line.trim())
150
+ continue;
151
+ const parsed = parseObject(line);
152
+ if (!parsed)
153
+ return null;
154
+ envelopes.push(parsed);
155
+ }
156
+ if (envelopes.length === 0)
157
+ return null;
158
+ const pickles = indexBy(envelopes, 'pickle');
159
+ const testCases = indexBy(envelopes, 'testCase');
160
+ const results = new Map();
161
+ for (const envelope of envelopes) {
162
+ const finished = envelope.testStepFinished;
163
+ if (!finished)
164
+ continue;
165
+ const startedId = text(finished.testCaseStartedId);
166
+ const list = results.get(startedId) ?? [];
167
+ list.push(finished.testStepResult ?? {});
168
+ results.set(startedId, list);
169
+ }
170
+ return envelopes.flatMap((envelope) => {
171
+ const started = envelope.testCaseStarted;
172
+ if (!started)
173
+ return [];
174
+ const testCase = testCases.get(text(started.testCaseId)) ?? {};
175
+ const pickle = pickles.get(text(testCase.pickleId)) ?? {};
176
+ const steps = results.get(text(started.id)) ?? [];
177
+ const failed = steps.filter((step) => text(step.status) !== 'PASSED' && text(step.status) !== 'SKIPPED');
178
+ if (failed.length === 0)
179
+ return [];
180
+ const tags = Array.isArray(pickle.tags) ? pickle.tags : [];
181
+ const tagText = rows(tags).map((tag) => text(tag.name)).join(' ');
182
+ const message = failed.map((step) => text(step.message)).find((line) => line.trim()) ?? '';
183
+ return [failure(`${tagText} ${text(pickle.name)}`, text(pickle.uri), message)];
184
+ });
185
+ }
186
+ // The connector's own pytest-bdd report (`runner/pytestBddPlugin.ts`). It names
187
+ // no file — the whole behavioral bundle is one run — so the scenario's marker
188
+ // and message carry the identity alone.
189
+ function fromPytestBdd(report) {
190
+ const data = parseObject(report);
191
+ if (!Array.isArray(data?.scenarios))
192
+ return null;
193
+ return rows(data.scenarios).flatMap((scenario) => {
194
+ if (text(scenario.status) === 'passed')
195
+ return [];
196
+ const tags = Array.isArray(scenario.tags) ? scenario.tags.map((tag) => text(tag)).join(' ') : '';
197
+ return [failure(`${tags} ${text(scenario.name)}`, '', text(scenario.failure))];
198
+ });
199
+ }
200
+ // Only the first line of a message. Later lines are backtraces and diffs, which
201
+ // carry object ids and absolute paths that differ between two runs of the same
202
+ // unchanged failure — the very drift that would make this comparison useless.
203
+ function failure(name, file, message) {
204
+ return {
205
+ marker: name.match(MARKER)?.[0] ?? '',
206
+ file,
207
+ message: message.split('\n')[0]?.trim() ?? '',
208
+ };
209
+ }
210
+ function indexBy(envelopes, key) {
211
+ const found = new Map();
212
+ for (const envelope of envelopes) {
213
+ const item = envelope[key];
214
+ if (item)
215
+ found.set(text(item.id), item);
216
+ }
217
+ return found;
218
+ }
219
+ function attribute(tag, name) {
220
+ return tag.match(new RegExp(`\\b${name}="([^"]*)"`))?.[1] ?? '';
221
+ }
222
+ function rows(values) {
223
+ return values.filter((value) => value !== null && typeof value === 'object' && !Array.isArray(value));
224
+ }
225
+ function text(value) {
226
+ return typeof value === 'string' ? value : '';
227
+ }
228
+ function parseObject(source) {
229
+ try {
230
+ const parsed = JSON.parse(source);
231
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
232
+ ? parsed
233
+ : null;
234
+ }
235
+ catch {
236
+ return null;
237
+ }
238
+ }
@@ -1,5 +1,5 @@
1
1
  import { branchRunner, readHostSuiteOutputsPerBranch, readSuiteBuildRequest, } from "../files/suiteBuild.js";
2
- import { spend } from "../files/budget.js";
2
+ import { digestOf, failureSet, readRunState, rememberFailures } from "../runner/failureDigest.js";
3
3
  import { validateStack } from "../runner/precheck.js";
4
4
  import { runBddSuite } from "../runner/bdd.js";
5
5
  import { runStructuralByRunner } from "./run.js";
@@ -15,6 +15,8 @@ export async function runLocal(config, args = [], deps) {
15
15
  const request = readSuiteBuildRequest(config.projectRoot);
16
16
  const { outputs, unreadable } = readHostSuiteOutputsPerBranch(request.output_path, request);
17
17
  const wanted = selectBranches(request, args);
18
+ const previous = readRunState(config.projectRoot);
19
+ let stuck = false;
18
20
  for (const suiteKind of wanted) {
19
21
  d.stdout.write(`\n── ${suiteKind} ──\n`);
20
22
  // An entry that exists but will not parse is a different problem from an
@@ -27,16 +29,47 @@ export async function runLocal(config, args = [], deps) {
27
29
  continue;
28
30
  }
29
31
  const ran = await runOneBranch(config, d, suiteKind, outputs.find((entry) => entry.suite_kind === suiteKind));
30
- // Only a run that happened spends a repair round. A branch with no entry
31
- // written yet, or one the stack cannot execute, produced nothing to repair
32
- // against charging it would exhaust the budget on rounds that never
33
- // examined the suite.
32
+ // A branch with no entry written yet, or one the stack cannot execute,
33
+ // produced nothing to compare: it is the ordinary state halfway through a
34
+ // build, not a repair loop going nowhere.
34
35
  if (!ran)
35
36
  continue;
36
- // Kept as compatibility diagnostics only. The bounded repair role owns the
37
- // mechanical ceiling; this counter never stops execution or classifies reds.
38
- spend(config.projectRoot, `run-local:${suiteKind}`);
37
+ if (compareFailures(config, d, suiteKind, ran, previous[suiteKind]))
38
+ stuck = true;
39
39
  }
40
+ return stuck ? 1 : 0;
41
+ }
42
+ // Spec 34-6, criterion 3. The whole stop condition, and it stops the branch
43
+ // rather than the worker: the set of failures belongs to the branch, and a
44
+ // repair worker looking only at its own slice cannot see that the branch as a
45
+ // whole has stopped moving.
46
+ //
47
+ // Returns true when this branch is the one that has stopped moving.
48
+ function compareFailures(config, d, suiteKind, ran, before) {
49
+ const failures = failureSet(ran.runner, ran.result.report);
50
+ // No comparable set: the run produced no readable report, which is a harness
51
+ // problem the loop never reached. Forget the branch so the next run that does
52
+ // produce one is a first run again, rather than a match against a set from
53
+ // before the harness broke.
54
+ if (!failures) {
55
+ rememberFailures(config.projectRoot, suiteKind, undefined);
56
+ return false;
57
+ }
58
+ // Green. Nothing to be stuck on, and remembering an empty set would stop a
59
+ // branch that passes twice in a row.
60
+ if (failures.length === 0) {
61
+ rememberFailures(config.projectRoot, suiteKind, undefined);
62
+ return false;
63
+ }
64
+ const digest = digestOf(failures);
65
+ rememberFailures(config.projectRoot, suiteKind, digest);
66
+ if (digest !== before)
67
+ return false;
68
+ d.stdout.write(`\nStopping ${suiteKind}: it just failed the same ${failures.length} case(s) as the previous run, ` +
69
+ 'down to the first line of every message. The edits since then changed nothing this run can see.\n' +
70
+ 'Look at the failures yourself, replan the slice, or record the branch as a build_error. ' +
71
+ 'Running it again unchanged prints this same line.\n');
72
+ return true;
40
73
  }
41
74
  // Which branches to run. No argument runs every branch the request asked for —
42
75
  // the same "one suite, one run" shape both recipes insist on, so the default
@@ -53,19 +86,20 @@ function selectBranches(request, args) {
53
86
  }
54
87
  return named;
55
88
  }
56
- // True when the runner actually executed the branch which is what a repair
57
- // round is, and the only thing the caller charges the budget for.
89
+ // Non-null when the runner actually executed the branch. Everything else no
90
+ // entry, a declared `build_error`, a stack that cannot run it — is a branch that
91
+ // produced no result to compare against.
58
92
  async function runOneBranch(config, d, suiteKind, output) {
59
93
  // Nothing written for this branch yet. That is the ordinary state halfway
60
94
  // through a build, not an error — say what is missing and move to the peer.
61
95
  if (!output) {
62
96
  d.stdout.write(`Nothing to run: your answer has no entry for this branch yet. Write its suite under ` +
63
97
  `${branchRoot(config, suiteKind)} and add its entry to the answer, then run this again.\n`);
64
- return false;
98
+ return null;
65
99
  }
66
100
  if (output.build_error) {
67
101
  d.stdout.write(`Not built, by your own answer: ${output.build_error.message}\n`);
68
- return false;
102
+ return null;
69
103
  }
70
104
  let runner;
71
105
  let suitePath;
@@ -75,12 +109,12 @@ async function runOneBranch(config, d, suiteKind, output) {
75
109
  }
76
110
  catch (err) {
77
111
  d.stdout.write(`Cannot run this branch: ${err.message}\n`);
78
- return false;
112
+ return null;
79
113
  }
80
114
  const check = d.validateStack(config.projectRoot, runner);
81
115
  if (!check.ok) {
82
116
  d.stdout.write(`Cannot run this branch: ${check.message ?? `this project does not match "${runner}".`}\n`);
83
- return false;
117
+ return null;
84
118
  }
85
119
  let result;
86
120
  try {
@@ -91,10 +125,10 @@ async function runOneBranch(config, d, suiteKind, output) {
91
125
  }
92
126
  catch (err) {
93
127
  d.stdout.write(`The runner could not start: ${err.message}\n`);
94
- return false;
128
+ return null;
95
129
  }
96
130
  d.stdout.write(report(result));
97
- return true;
131
+ return { runner, result };
98
132
  }
99
133
  // The command first, and always — including on a green run. It is the answer to
100
134
  // "how do I run just this one file again", which is the question the whole
@@ -1,4 +1,4 @@
1
- import { clearSpending } from "../files/budget.js";
1
+ import { clearRunState } from "../runner/failureDigest.js";
2
2
  import { materializeHelper } from "../files/guardrails.js";
3
3
  import { materializeBehavioralWorld } from "../files/behavioral.js";
4
4
  import { recipeNameFor, writeSuiteBuildRequest, } from "../files/suiteBuild.js";
@@ -153,13 +153,11 @@ export async function suitePrepare(config, args = [], deps) {
153
153
  throw new Error(`No suite branch can be built this run:\n${blockedNotices.join('\n')}\nNothing was written and nothing was uploaded.`);
154
154
  }
155
155
  const request = writeSuiteBuildRequest(config.projectRoot, branches, defectContext);
156
- // A new request is a new build, and a new build starts on the whole budget
157
- // (spec 34-2). Said out loud, because re-running this verb is a documented
158
- // step of the loop the fixable-runner path below ends by asking for it — so
159
- // a reset that happened silently would be a ceiling that quietly is not one.
160
- if (clearSpending(config.projectRoot)) {
161
- actual.stdout.write('Starting a fresh build: the review and run counts from the previous one are cleared.\n');
162
- }
156
+ // A new request is a new build, and a new build has no previous run to be
157
+ // stuck against (spec 34-6, criterion 3). Re-running this verb is a documented
158
+ // step of the loop, so a failure set remembered from the build before it would
159
+ // stop a branch that has not run once yet.
160
+ clearRunState(config.projectRoot);
163
161
  const kinds = branches.map((branch) => branch.suite_kind).join(' and ');
164
162
  const nextCommand = branches.some((branch) => branch.suite_kind === 'behavioral')
165
163
  ? '`unitbob suite-review-prepare` before upload'
@@ -6,7 +6,6 @@ import { copyBehavioralRunnerEnvironment, filesLostOnMaterialize, materializeBeh
6
6
  import { runBddSuite } from "../runner/bdd.js";
7
7
  import { boundReport } from "../runner/boundReport.js";
8
8
  import { branchRunner, readHostSuiteOutputs, readSuiteBuildRequest, reviewRequestPath, writeBehavioralReviewRequest, } from "../files/suiteBuild.js";
9
- import { spend } from "../files/budget.js";
10
9
  export async function suiteReviewPrepare(config, _args = [], deps) {
11
10
  const actual = {
12
11
  runCandidate: runCandidate,
@@ -38,27 +37,6 @@ export async function suiteReviewPrepare(config, _args = [], deps) {
38
37
  const request = writeBehavioralReviewRequest(config.projectRoot, behavioral, candidateRun, buildRequest.known_defect_context, fixedCandidateRun);
39
38
  actual.stdout.write(`Behavioral review request written to ${reviewRequestPath(config.projectRoot)}\n`);
40
39
  actual.stdout.write(`Next: have an independent reviewer inspect this exact candidate and write bdd_quality_review and known_defect_probe to ${request.output_path}, then run \`unitbob put-suite-build\`.\n`);
41
- // Counted after the request is written, never before it. Refusing the round
42
- // would rebuild the autobrella deadlock on a different number: the branch
43
- // could not publish, so the work would be lost again for the sake of the
44
- // ceiling meant to protect it. What the ceiling buys is a sentence.
45
- const round = spend(config.projectRoot, 'review_rounds');
46
- const budget = buildRequest.budget;
47
- if (budget && round > budget.review_rounds) {
48
- actual.stdout.write(lastRoundNotice(round, budget.review_rounds));
49
- }
50
- }
51
- // Says what to do, not that something is forbidden. This is only sayable because
52
- // spec 34-2 already made an imperfect suite publishable: before it, "publish what
53
- // you have" was not available to the reviewer at all, and the only way to record
54
- // a bad Scenario was to take the branch down.
55
- function lastRoundNotice(round, allowed) {
56
- return (`\nThis is review round ${round}; the budget for this build was ${allowed}. Treat it as the last round ` +
57
- 'and publish what you have.\n' +
58
- 'A Scenario the reviewer still objects to does not hold the branch back: it is recorded with ' +
59
- '`verdict: "does_not_pass"` and a `reviewer_objection_text` saying what is wrong, the branch publishes, ' +
60
- 'and the objection is read afterwards by the operator. Another round of rewriting buys less than ' +
61
- 'publishing the objection does.\n');
62
40
  }
63
41
  async function runCandidate(projectRoot, output, revision) {
64
42
  if (revision)
@@ -227,10 +227,16 @@ function checkSurfaceCoverage(row, id, expected, suiteText, add) {
227
227
  // Spec 34-3, criterion 6. Cheap here and expensive later: over the ceiling is
228
228
  // one of the answers the server rejects, and finding it after the suite has
229
229
  // been written, run and reviewed costs the whole cycle.
230
+ //
231
+ // Spec 34-6, criterion 5: it names how many surfaces have to move, because
232
+ // that number is the edit, and it lands in the same batch as every other
233
+ // capability over the ceiling. On a2time, 2026-08-09 the server's version of
234
+ // this complaint arrived one capability at a time and cost five
235
+ // `validate-build` rounds for one kind of mistake.
230
236
  const budget = expected.surfaceBudget;
231
237
  if (budget !== undefined && reached.size > budget) {
232
238
  add(`${id} guards ${reached.size} surfaces, over the surface_budget of ${budget}` +
233
- 'guard the most important ones up to that number and list the rest in deferred_surfaces.');
239
+ `move ${reached.size - budget} of them into deferred_surfaces and keep the most important ones guarded.`);
234
240
  }
235
241
  const missed = expected.surfaces.filter((surface) => !reached.has(surface) && !declaredUnreachable.has(surface) && !deferred.has(surface));
236
242
  if (missed.length > 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unitbob",
3
- "version": "0.4.4",
3
+ "version": "0.4.5",
4
4
  "description": "Unitbob connector — thin local hands for the Unitbob Rails brain. Owns no domain logic: it runs tools, relays bytes over the wire, and prints what the server returns.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -7,6 +7,11 @@ You receive one failure packet: one validated plan item, its checkpoint, branch,
7
7
  owned paths and case markers, and only related failures or stack traces. This is
8
8
  your complete write scope.
9
9
 
10
+ The checkpoint's `facts` come to you established, by the coordinator and by the
11
+ worker before you. Inherit them; do not go and find them out again. A fact
12
+ carries source references, so you can check one on the spot when a failure makes
13
+ you doubt it — that is a targeted re-check, not a fresh survey.
14
+
10
15
  Complete `unresolved_promises` first while preserving every completed file and
11
16
  decision. You may read the plan item's source paths and, only as needed for the
12
17
  owned diagnosis, stack-referenced project source, the runner setup and harness
@@ -16,7 +21,7 @@ markers, or paths. Do not edit production code, host-owned shared files, the
16
21
  connector-owned harness, or another slice.
17
22
 
18
23
  After every owned edit, run
19
- `npx -y --loglevel=error unitbob@0.4.4 run-local <branch>` and inspect the machine
24
+ `npx -y --loglevel=error unitbob@0.4.5 run-local <branch>` and inspect the machine
20
25
  report. Look only at examples or scenarios matching your owned paths or case
21
26
  markers. Do not require a green exit code from the whole branch: foreign failures
22
27
  and an already-confirmed product red do not widen your scope. Repeat the bounded
@@ -24,14 +29,21 @@ and an already-confirmed product red do not widen your scope. Repeat the bounded
24
29
  as a product defect. Do not run the project's suite directly, boot a dev server,
25
30
  or invoke an arbitrary runner command.
26
31
 
32
+ `run-local` exits non-zero when the branch comes back with exactly the failures
33
+ it came back with last time. That is not your slice being red — it is the branch
34
+ as a whole having stopped moving. Stop the loop, hand the packet back with what
35
+ you have, and say so; do not run it again unchanged.
36
+
27
37
  A product-defect diagnosis must briefly name the violated business contract, the
28
38
  reason, and production source references. Never delete a planned case, marker,
29
39
  capability binding, or assertion; never add `skip`, `pending`, `todo`, or weaken a
30
40
  business promise for green. You may correct a generated expectation only when
31
41
  the business promise remains intact and source confirms the correction. If the
32
42
  harness is still wrong, continue the loop. If the outcome is ambiguous, the
33
- runner is unusable, or the turn ceiling stops unfinished work, leave an honest
34
- branch `build_error`, never a product red. No strict JSON handoff is required.
43
+ runner is unusable, or the emergency fuse stops unfinished work, leave an honest
44
+ branch `build_error`, never a product red. That fuse sits far above the work one
45
+ packet takes: reaching it means the run is broken, not that the packet was big.
46
+ No strict JSON handoff is required.
35
47
 
36
48
  Update the same checkpoint as promises complete. Keep facts compact and
37
49
  source-referenced. The normative JSON shape of one facts entry is:
@@ -41,11 +53,11 @@ source-referenced. The normative JSON shape of one facts entry is:
41
53
  Every facts entry is an object in that shape, never a string. Before handoff,
42
54
  make one final read of the checkpoint and confirm every `facts` entry is an
43
55
  object in the normative shape above. Do not delegate repair or auto-resume after
44
- the ceiling. Preserve files and checkpoint for the coordinator's existing
56
+ the fuse. Preserve files and checkpoint for the coordinator's existing
45
57
  `Continue once / Stop` choice; record unfinished work in `unresolved_promises`.
46
58
  '''
47
59
 
48
60
  [features.rollout_budget]
49
61
  enabled = true
50
- limit_tokens = 40000
51
- reminder_at_remaining_tokens = [3000]
62
+ limit_tokens = 100000
63
+ reminder_at_remaining_tokens = [8000]
@@ -7,27 +7,34 @@ You receive exactly one worker-plan item and the request paths it references.
7
7
  That item is your complete scope. Do not add capabilities, promises, examples,
8
8
  or scenarios after fan-out.
9
9
 
10
- On the initial incarnation, create the checkpoint before reading application
11
- source, at the path prescribed by the workflow. Copy the exact request and plan
12
- digests, your branch and worker id, put every assigned promise in
13
- `unresolved_promises`, and start with empty `completed_promises`, `written_paths`,
14
- `facts`, and `decisions`. On an explicitly approved fresh incarnation after a
15
- native budget stop, preserve the supplied checkpoint and completed files and
16
- continue only its `unresolved_promises`; never initialize that checkpoint again.
17
- Update the checkpoint after every completed promise. Also keep `known_problems` as a compact
18
- array of precise unresolved harness problems (empty when none are known). Facts are short statements with
19
- source references. The normative JSON shape of one facts entry is:
10
+ Your checkpoint already exists: the coordinator wrote it before fan-out, at the
11
+ path the workflow prescribes, with the exact request and plan digests, your
12
+ branch and worker id, your promises in `unresolved_promises`, and the facts it
13
+ had already verified. Never initialize it again — not on the first incarnation,
14
+ and not on an explicitly approved fresh incarnation after a native budget stop,
15
+ where you preserve the supplied checkpoint and completed files and continue only
16
+ its `unresolved_promises`. Update it after every completed promise. Also keep
17
+ `known_problems` as a compact array of precise unresolved harness problems
18
+ (empty when none are known). Facts are short statements with source references.
19
+ The normative JSON shape of one facts entry is:
20
20
  ```json
21
21
  {"fact":"The route creates an order.","source_refs":["app/orders.rb:12"]}
22
22
  ```
23
23
  Every facts entry is an object in that shape, never a string. Never embed source
24
24
  files, suite copies, or transcript.
25
25
 
26
- Read only the initial `source_paths` and dependencies needed for the finite
27
- planned cases. Ask closed questions with the files to look in. For a closed
28
- missing fact, use the named `fact-finder`
29
- agent and respect the plan's lookup limit. A lookup may confirm implementation
30
- facts but may not expand the plan.
26
+ Write first, then find out. Start with the planned cases your seeded facts
27
+ already support and get them onto disk; go reading only for what you still lack
28
+ after that. The opposite order — survey the sources, then write — is what spent
29
+ seven of eight workers' entire ceilings on a2time, 2026-08-10, and produced no
30
+ file at all. A fact already in your checkpoint is settled: do not establish it a
31
+ second time. Nothing mechanical enforces that rule; it holds because you keep
32
+ it.
33
+
34
+ Read only the `source_paths` and dependencies your finite planned cases need.
35
+ Ask closed questions with the files to look in. For a closed missing fact, use
36
+ the named `fact-finder` agent, as often as the work genuinely needs. A lookup
37
+ may confirm implementation facts but may not expand the plan.
31
38
 
32
39
  Write only the plan item's `owned_paths` and its checkpoint. Never edit the
33
40
  connector-owned harness, another worker's file, the user's own tests, manifests,
@@ -39,12 +46,16 @@ marker, metadata, or surface validation. You may make one final read of your
39
46
  owned files before handoff. During that final read, confirm every `facts` entry
40
47
  is an object in the normative shape above and correct the checkpoint if it is
41
48
  not. Do not create temporary self-validation scripts or
42
- loop over repeated rereads. If the turn ceiling arrives, leave partial files and
43
- an accurate checkpoint; the coordinator will rotate unresolved work into one
44
- fresh repair task.
49
+ loop over repeated rereads.
50
+
51
+ Your ceiling is an emergency fuse, not a budget to spend. It sits far above the
52
+ work one plan item takes, so reaching it means this run is broken rather than
53
+ large. If it arrives, leave partial files and an accurate checkpoint; the
54
+ coordinator rotates unresolved work into one fresh repair task and reports the
55
+ fuse as a fault of the run, never as an outcome.
45
56
  '''
46
57
 
47
58
  [features.rollout_budget]
48
59
  enabled = true
49
- limit_tokens = 40000
50
- reminder_at_remaining_tokens = [8000, 4000]
60
+ limit_tokens = 100000
61
+ reminder_at_remaining_tokens = [20000, 10000]
@@ -1,74 +0,0 @@
1
- import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
2
- import { dirname, join } from 'node:path';
3
- export const RUN_BUDGET = { workers: 4, review_rounds: 2, repair_rounds: 8 };
4
- // Only two of the three have a counter behind them. The connector cannot see
5
- // the host launch a subagent, so `workers` is a number it states and cannot
6
- // check. It travels in the same block anyway, and the recipe is told not to sort
7
- // the fields into checked and unchecked: an agent that knows which half is
8
- // watched has been handed a reason to treat the other half as advice.
9
- const SPENT_FILE = 'budget-spent.json';
10
- function spentPath(projectRoot) {
11
- return join(projectRoot, '.unitbob', 'suite-build', SPENT_FILE);
12
- }
13
- // On disk, not in memory, because the loop these bound spans separate processes:
14
- // every `run-local` and every `suite-review-prepare` is a fresh `npx`. A count
15
- // held in a running command would reset on each one and bound nothing.
16
- export function spend(projectRoot, key) {
17
- const path = spentPath(projectRoot);
18
- const spent = readSpent(path);
19
- const count = (spent[key] ?? 0) + 1;
20
- spent[key] = count;
21
- mkdirSync(dirname(path), { recursive: true });
22
- // Written whole and moved into place, never written in place. A plain write
23
- // that is interrupted leaves truncated JSON, which `readSpent` then reads as
24
- // nothing spent — so the count would reset exactly when a run is being killed
25
- // and restarted, which is the loop this exists to bound.
26
- const staging = `${path}.tmp`;
27
- writeFileSync(staging, `${JSON.stringify(spent, null, 2)}\n`);
28
- renameSync(staging, path);
29
- return count;
30
- }
31
- // A new build request starts on a fresh budget. Without this a project Unitbob
32
- // ran a month ago opens today already over its ceiling, and every command
33
- // announces the last round — noise, and advice that is wrong besides.
34
- //
35
- // True when there was something to clear, so the caller can say so out loud.
36
- // Re-running `suite-prepare` is a documented step of the loop, and it resets
37
- // both counters; a reset nobody is told about is a ceiling that quietly is not
38
- // one.
39
- export function clearSpending(projectRoot) {
40
- const path = spentPath(projectRoot);
41
- const had = existsSync(path);
42
- rmSync(path, { force: true });
43
- return had;
44
- }
45
- // A damaged or hand-edited file counts as nothing spent. The alternative is
46
- // refusing to run over a bookkeeping file, which would make a counter that
47
- // deliberately never blocks into the one thing that does.
48
- function readSpent(path) {
49
- if (!existsSync(path))
50
- return {};
51
- try {
52
- const parsed = JSON.parse(readFileSync(path, 'utf8'));
53
- if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
54
- return {};
55
- return Object.fromEntries(Object.entries(parsed)
56
- .filter(([, value]) => typeof value === 'number' && Number.isFinite(value)));
57
- }
58
- catch {
59
- return {};
60
- }
61
- }
62
- // A request written by an older connector carries no budget, and that is not an
63
- // error: there is no ceiling to enforce, so every counter goes quiet and the run
64
- // works as it did before (spec 34-2, edge cases).
65
- export function readBudget(value) {
66
- if (!value || typeof value !== 'object')
67
- return undefined;
68
- const block = value;
69
- const fields = ['workers', 'review_rounds', 'repair_rounds']
70
- .map((field) => [field, block[field]]);
71
- if (fields.some(([, number]) => typeof number !== 'number' || !Number.isFinite(number)))
72
- return undefined;
73
- return Object.fromEntries(fields);
74
- }