unitbob 0.3.3 → 0.3.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.
@@ -0,0 +1,74 @@
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
+ }
@@ -2,6 +2,7 @@ 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";
5
6
  export function requestPath(projectRoot) {
6
7
  return join(projectRoot, '.unitbob', 'suite-build', 'request.json');
7
8
  }
@@ -172,6 +173,7 @@ export function writeSuiteBuildRequest(projectRoot, branches, knownDefectContext
172
173
  output_path: outputPath(projectRoot),
173
174
  branches,
174
175
  known_defect_context: knownDefectContext,
176
+ budget: RUN_BUDGET,
175
177
  };
176
178
  const path = requestPath(projectRoot);
177
179
  mkdirSync(dirname(path), { recursive: true });
@@ -193,6 +195,7 @@ export function readSuiteBuildRequest(projectRoot) {
193
195
  return {
194
196
  ...request,
195
197
  known_defect_context: readKnownDefectContext(request.known_defect_context, path),
198
+ budget: readBudget(request.budget),
196
199
  };
197
200
  }
198
201
  function readKnownDefectContext(value, path) {
@@ -1,4 +1,5 @@
1
1
  import { branchRunner, readHostSuiteOutputsPerBranch, readSuiteBuildRequest, } from "../files/suiteBuild.js";
2
+ import { spend } from "../files/budget.js";
2
3
  import { validateStack } from "../runner/precheck.js";
3
4
  import { runBddSuite } from "../runner/bdd.js";
4
5
  import { runStructuralByRunner } from "./run.js";
@@ -25,9 +26,31 @@ export async function runLocal(config, args = [], deps) {
25
26
  d.stdout.write(`Cannot run this branch — its entry in your answer could not be read: ${broken.message}\n`);
26
27
  continue;
27
28
  }
28
- await runOneBranch(config, d, suiteKind, outputs.find((entry) => entry.suite_kind === suiteKind));
29
+ 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.
34
+ if (!ran)
35
+ continue;
36
+ const spent = spend(config.projectRoot, `run-local:${suiteKind}`);
37
+ if (request.budget && spent > request.budget.repair_rounds) {
38
+ d.stdout.write(polishedEnoughNotice(suiteKind, spent, request.budget.repair_rounds));
39
+ }
29
40
  }
30
41
  }
42
+ // Not a refusal, and deliberately not about the budget either. After eight
43
+ // rounds of repair the interesting fact is not that a number ran out — it is
44
+ // what the remaining reds most likely are. Both real logs show 3-5 runs of a
45
+ // branch as ordinary work, so a branch on its ninth has already been repaired
46
+ // past the point where the harness is the usual explanation.
47
+ function polishedEnoughNotice(suiteKind, spent, allowed) {
48
+ return (`\nThat was run ${spent} of the ${suiteKind} branch; this build budgeted ${allowed}. ` +
49
+ 'Reds that survive this many rounds of repair are far more likely to be defects of your product ' +
50
+ 'than of the harness around it.\n' +
51
+ 'Publish the suite as it stands rather than keep polishing. A first suite that comes out red is ' +
52
+ 'a finding, not a failure — finding those reds is what it was written to do.\n');
53
+ }
31
54
  // Which branches to run. No argument runs every branch the request asked for —
32
55
  // the same "one suite, one run" shape both recipes insist on, so the default
33
56
  // never teaches the habit the recipes forbid. A named branch is for the repair
@@ -43,17 +66,19 @@ function selectBranches(request, args) {
43
66
  }
44
67
  return named;
45
68
  }
69
+ // True when the runner actually executed the branch — which is what a repair
70
+ // round is, and the only thing the caller charges the budget for.
46
71
  async function runOneBranch(config, d, suiteKind, output) {
47
72
  // Nothing written for this branch yet. That is the ordinary state halfway
48
73
  // through a build, not an error — say what is missing and move to the peer.
49
74
  if (!output) {
50
75
  d.stdout.write(`Nothing to run: your answer has no entry for this branch yet. Write its suite under ` +
51
76
  `${branchRoot(config, suiteKind)} and add its entry to the answer, then run this again.\n`);
52
- return;
77
+ return false;
53
78
  }
54
79
  if (output.build_error) {
55
80
  d.stdout.write(`Not built, by your own answer: ${output.build_error.message}\n`);
56
- return;
81
+ return false;
57
82
  }
58
83
  let runner;
59
84
  let suitePath;
@@ -63,12 +88,12 @@ async function runOneBranch(config, d, suiteKind, output) {
63
88
  }
64
89
  catch (err) {
65
90
  d.stdout.write(`Cannot run this branch: ${err.message}\n`);
66
- return;
91
+ return false;
67
92
  }
68
93
  const check = d.validateStack(config.projectRoot, runner);
69
94
  if (!check.ok) {
70
95
  d.stdout.write(`Cannot run this branch: ${check.message ?? `this project does not match "${runner}".`}\n`);
71
- return;
96
+ return false;
72
97
  }
73
98
  let result;
74
99
  try {
@@ -79,9 +104,10 @@ async function runOneBranch(config, d, suiteKind, output) {
79
104
  }
80
105
  catch (err) {
81
106
  d.stdout.write(`The runner could not start: ${err.message}\n`);
82
- return;
107
+ return false;
83
108
  }
84
109
  d.stdout.write(report(result));
110
+ return true;
85
111
  }
86
112
  // The command first, and always — including on a green run. It is the answer to
87
113
  // "how do I run just this one file again", which is the question the whole
@@ -1,3 +1,4 @@
1
+ import { clearSpending } from "../files/budget.js";
1
2
  import { materializeHelper } from "../files/guardrails.js";
2
3
  import { recipeNameFor, writeSuiteBuildRequest, } from "../files/suiteBuild.js";
3
4
  import { bootCheck, SIGNAL_STRENGTH } from "../runner/bootcheck.js";
@@ -141,6 +142,13 @@ export async function suitePrepare(config, args = [], deps) {
141
142
  throw new Error(`No suite branch can be built this run:\n${blockedNotices.join('\n')}\nNothing was written and nothing was uploaded.`);
142
143
  }
143
144
  const request = writeSuiteBuildRequest(config.projectRoot, branches, defectContext);
145
+ // A new request is a new build, and a new build starts on the whole budget
146
+ // (spec 34-2). Said out loud, because re-running this verb is a documented
147
+ // step of the loop — the fixable-runner path below ends by asking for it — so
148
+ // a reset that happened silently would be a ceiling that quietly is not one.
149
+ if (clearSpending(config.projectRoot)) {
150
+ actual.stdout.write('Starting a fresh build: the review and run counts from the previous one are cleared.\n');
151
+ }
144
152
  const kinds = branches.map((branch) => branch.suite_kind).join(' and ');
145
153
  const nextCommand = branches.some((branch) => branch.suite_kind === 'behavioral')
146
154
  ? '`unitbob suite-review-prepare` before upload'
@@ -6,6 +6,7 @@ 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";
9
10
  export async function suiteReviewPrepare(config, _args = [], deps) {
10
11
  const actual = {
11
12
  runCandidate: runCandidate,
@@ -37,6 +38,27 @@ export async function suiteReviewPrepare(config, _args = [], deps) {
37
38
  const request = writeBehavioralReviewRequest(config.projectRoot, behavioral, candidateRun, buildRequest.known_defect_context, fixedCandidateRun);
38
39
  actual.stdout.write(`Behavioral review request written to ${reviewRequestPath(config.projectRoot)}\n`);
39
40
  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');
40
62
  }
41
63
  async function runCandidate(projectRoot, output, revision) {
42
64
  if (revision)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unitbob",
3
- "version": "0.3.3",
3
+ "version": "0.3.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": {
@@ -16,8 +16,7 @@
16
16
  "build": "rm -rf dist && tsc -p tsconfig.json && chmod +x dist/bin.js",
17
17
  "prepublishOnly": "npm run build",
18
18
  "test": "node --test test/*.test.ts",
19
- "check:release": "node scripts/check-release.mjs",
20
- "hooks:install": "git config core.hooksPath hooks"
19
+ "check:release": "node scripts/check-release.mjs"
21
20
  },
22
21
  "publishConfig": {
23
22
  "access": "public"