unitbob 0.4.4 → 0.5.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 +4 -2
- package/dist/files/guardrails.js +19 -9
- package/dist/files/suiteBuild.js +20 -4
- package/dist/files/suiteBuildUpload.js +71 -0
- package/dist/files/workerPlan.js +33 -19
- package/dist/proc.js +15 -0
- package/dist/runner/bootcheck.js +47 -9
- package/dist/runner/failureDigest.js +238 -0
- package/dist/runner/precheck.js +104 -36
- package/dist/runner/provision.js +326 -26
- package/dist/runner/pytest.js +25 -20
- package/dist/runner/rspec.js +19 -11
- package/dist/runner/toolchain.js +122 -0
- package/dist/runner/vitest.js +64 -30
- package/dist/surfaces/routeInventory.js +15 -3
- package/dist/verbs/codexInstall.js +1 -1
- package/dist/verbs/putSuiteBuild.js +32 -75
- package/dist/verbs/run.js +15 -6
- package/dist/verbs/runLocal.js +69 -23
- package/dist/verbs/suitePrepare.js +49 -15
- package/dist/verbs/suiteReviewPrepare.js +0 -22
- package/dist/verbs/validateBuild.js +140 -450
- package/dist/wire.js +21 -3
- package/package.json +1 -1
- package/plugin/codex/agents/suite-repair-worker.toml +18 -6
- package/plugin/codex/agents/suite-reviewer.toml +157 -0
- package/plugin/codex/agents/suite-worker.toml +37 -20
- package/dist/files/budget.js +0 -74
package/dist/wire.js
CHANGED
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
// Raised when the server cannot be reached or answers with an error status.
|
|
2
2
|
// Verbs surface its message and exit non-zero; they never fabricate a result.
|
|
3
|
+
//
|
|
4
|
+
// `unreachable` separates the two cases. "The server said no" is a verdict and
|
|
5
|
+
// must stop the caller; "there was no server to ask" is an absence, and a check
|
|
6
|
+
// that reads an absence as a verdict either invents a rejection or invents an
|
|
7
|
+
// approval. `validate-build` is the caller that needs the difference: with no
|
|
8
|
+
// server it succeeds, and says out loud which questions went unasked.
|
|
3
9
|
export class WireError extends Error {
|
|
10
|
+
unreachable;
|
|
11
|
+
constructor(message, options = {}) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.unreachable = options.unreachable ?? false;
|
|
14
|
+
}
|
|
4
15
|
}
|
|
5
16
|
// POST /repos/register — the linking bootstrap (spec 28). A standalone function
|
|
6
17
|
// rather than a Wire method because at link time there is no Config yet: only a
|
|
@@ -73,8 +84,15 @@ export class Wire {
|
|
|
73
84
|
// PUT /repos/:id/suite_builds — upload both peer branches in one batch (spec
|
|
74
85
|
// 32). Each item is validated and published independently; the response
|
|
75
86
|
// carries one result per suite_kind.
|
|
76
|
-
|
|
77
|
-
|
|
87
|
+
//
|
|
88
|
+
// `dryRun` is the same route, the same body and the same server-side
|
|
89
|
+
// validation, stopped before the first write (spec 42, §1). It answers
|
|
90
|
+
// `would_publish` instead of `created`, and it is deliberately not a route of
|
|
91
|
+
// its own: a second route would grow a second implementation, which is the
|
|
92
|
+
// defect this whole spec removes.
|
|
93
|
+
async putSuiteBuilds(items, options = {}) {
|
|
94
|
+
const payload = options.dryRun ? { suite_builds: items, dry_run: true } : { suite_builds: items };
|
|
95
|
+
const res = await this.send('PUT', this.repoPath('suite_builds'), payload);
|
|
78
96
|
await this.ensureOk(res, `PUT ${this.repoPath('suite_builds')}`);
|
|
79
97
|
const body = (await res.json());
|
|
80
98
|
if (!Array.isArray(body.results)) {
|
|
@@ -213,7 +231,7 @@ export class Wire {
|
|
|
213
231
|
catch (err) {
|
|
214
232
|
throw new WireError(`Cannot reach the Unitbob server at ${this.config.server} ` +
|
|
215
233
|
`(${err.message}). Check that the server is running and that ` +
|
|
216
|
-
`"server" in .unitbob.json is correct
|
|
234
|
+
`"server" in .unitbob.json is correct.`, { unreachable: true });
|
|
217
235
|
}
|
|
218
236
|
}
|
|
219
237
|
async ensureOk(res, what) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "unitbob",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
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.
|
|
24
|
+
`npx -y --loglevel=error unitbob@0.5.0 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
|
|
34
|
-
branch `build_error`, never a product red.
|
|
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
|
|
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 =
|
|
51
|
-
reminder_at_remaining_tokens = [
|
|
62
|
+
limit_tokens = 100000
|
|
63
|
+
reminder_at_remaining_tokens = [8000]
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
name = "suite-reviewer"
|
|
2
|
+
description = "Independently reviews a bound Unitbob behavioral candidate and writes the one review artifact the upload requires. It judges whether each Scenario protects what it promises; it never edits the suite, never runs it, and never publishes."
|
|
3
|
+
model = "gpt-5.6-terra"
|
|
4
|
+
model_reasoning_effort = "medium"
|
|
5
|
+
developer_instructions = '''
|
|
6
|
+
You are the independent reviewer of one behavioral candidate. The suite in front
|
|
7
|
+
of you was written and debugged by someone else; you did not write it, and you
|
|
8
|
+
are not here to improve it. You are here to answer one question about each
|
|
9
|
+
Scenario, in writing.
|
|
10
|
+
|
|
11
|
+
**Would this Scenario turn red if the behaviour it names were broken?**
|
|
12
|
+
|
|
13
|
+
A Scenario that stays green either way protects nothing, and the map will still
|
|
14
|
+
show its capability as guarded. That is the failure this role exists to catch,
|
|
15
|
+
and nothing downstream can catch it: the server can check that a Scenario exists,
|
|
16
|
+
carries its marker and names its addresses, but not whether its `Then` asserts
|
|
17
|
+
anything real.
|
|
18
|
+
|
|
19
|
+
## What you are given, and what you write
|
|
20
|
+
|
|
21
|
+
`.unitbob/suite-build/review-request.json` names the candidate, its
|
|
22
|
+
`candidate_digest`, the suite files, the behavioral assignment, and — for a
|
|
23
|
+
planned candidate — the worker-plan items and their exact `plan_digest`. Read
|
|
24
|
+
the `.feature` files **and the step definitions behind them**. A verdict formed
|
|
25
|
+
from Scenario text alone is a guess about what the steps do; the steps are where
|
|
26
|
+
the answer is.
|
|
27
|
+
|
|
28
|
+
Write strict JSON, and nothing else, to
|
|
29
|
+
`.unitbob/suite-build/behavioral_review.json`:
|
|
30
|
+
|
|
31
|
+
```json
|
|
32
|
+
{
|
|
33
|
+
"candidate_digest": "<copied verbatim from the request, at the top level>",
|
|
34
|
+
"bdd_quality_review": {
|
|
35
|
+
"scenario_reviews": [
|
|
36
|
+
{
|
|
37
|
+
"scenario": "<exact Scenario name>",
|
|
38
|
+
"case_marker": "<exact marker>",
|
|
39
|
+
"verdict": "pass",
|
|
40
|
+
"public_surfaces": ["POST /orders"],
|
|
41
|
+
"given_then_evidence": "The order created in Given is the one the Then reads back.",
|
|
42
|
+
"outcome": "The order is confirmed for that shopper.",
|
|
43
|
+
"outcome_kind": "specific"
|
|
44
|
+
}
|
|
45
|
+
]
|
|
46
|
+
},
|
|
47
|
+
"known_defect_probe": { "status": "not_supplied" },
|
|
48
|
+
"selection_review": {
|
|
49
|
+
"plan_digest": "<exact plan_digest from the request>",
|
|
50
|
+
"capability_reviews": [{ "capability_id": "billing", "verdict": "pass" }]
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
`candidate_digest` sits at the **top level**, not inside `bdd_quality_review`.
|
|
56
|
+
Nesting it one level deeper cost a run its publish; so did inventing values for
|
|
57
|
+
`outcome_kind`, and so did a coordinator's instruction to write "no other
|
|
58
|
+
top-level keys", which dropped it entirely. Copy the digest, do not compute it.
|
|
59
|
+
|
|
60
|
+
Write `selection_review` only when the request carries a `plan_digest`, and give
|
|
61
|
+
it one entry per assigned capability. Its verdicts are `pass` or
|
|
62
|
+
`does_not_pass` — there is no `pass_with_reservation` at capability level — and
|
|
63
|
+
`does_not_pass` owes a non-empty `reviewer_objection_text` naming the lost
|
|
64
|
+
promise, the unjustified merge, or the dishonest deferral. Selection objections
|
|
65
|
+
are recorded; they never block the publish and never downgrade a lamp.
|
|
66
|
+
|
|
67
|
+
Omit `candidate_run`, `known_defect_context` and any runner report: the connector
|
|
68
|
+
owns those and adds them itself.
|
|
69
|
+
|
|
70
|
+
`known_defect_probe` is `{"status": "not_supplied"}` only when the request's
|
|
71
|
+
`known_defect_context` says no defect was supplied. When it names one, copy that
|
|
72
|
+
text verbatim into `defect` and record `scenario`, `case_marker`,
|
|
73
|
+
`defect_revision`, `defect_result: "red"`, and a short `defect_run_evidence`,
|
|
74
|
+
with `status: "detected"`. If a fixed revision was also supplied, use
|
|
75
|
+
`status: "verified"` and add `fixed_revision`, `fixed_result: "green"` and
|
|
76
|
+
`fixed_run_evidence`. Answering `not_supplied` while the context names a defect
|
|
77
|
+
fails the whole branch before it reaches the server.
|
|
78
|
+
|
|
79
|
+
## The three verdicts
|
|
80
|
+
|
|
81
|
+
Every Scenario you were sent gets exactly one entry. There is no fourth answer
|
|
82
|
+
and no answer that consists of writing nothing.
|
|
83
|
+
|
|
84
|
+
- **`pass`** — it protects what it promises. Owes `public_surfaces`,
|
|
85
|
+
`given_then_evidence`, `outcome`, and `outcome_kind`.
|
|
86
|
+
- **`pass_with_reservation`** — it protects something, and here is what it does
|
|
87
|
+
**not** check. Owes everything `pass` owes, plus a non-empty `reservation`:
|
|
88
|
+
one concrete sentence. *"The summary is asserted to render, but nothing looks
|
|
89
|
+
for the amount the Given set up."*
|
|
90
|
+
- **`does_not_pass`** — it protects nothing, and here is why. Owes only
|
|
91
|
+
`scenario`, `case_marker`, `verdict`, and a non-empty
|
|
92
|
+
`reviewer_objection_text`: one concrete sentence about **this** Scenario.
|
|
93
|
+
*"The Then asserts a 200, which this endpoint returns for an empty cart as
|
|
94
|
+
well as a paid one."* Leave the other four fields out — a Scenario that checks
|
|
95
|
+
nothing has no specific outcome to state, and filling them in means inventing
|
|
96
|
+
one.
|
|
97
|
+
|
|
98
|
+
`outcome_kind` is `specific` or `availability`, and nothing else.
|
|
99
|
+
`availability` is valid only when availability itself is the promised behaviour;
|
|
100
|
+
it never excuses a "loads successfully" assertion for a promised record, state
|
|
101
|
+
change, message, or side effect.
|
|
102
|
+
|
|
103
|
+
`public_surfaces` lists the addresses you verified the `When` implementation
|
|
104
|
+
actually drives, and must equal that Scenario's `surface_coverage` in the
|
|
105
|
+
candidate's metadata. If the two disagree, that is a finding — say it in a
|
|
106
|
+
reservation or an objection rather than adjusting your list to match.
|
|
107
|
+
|
|
108
|
+
A Scenario that also happens to drive an address belonging to another capability
|
|
109
|
+
goes in `reservation`, naming the address. There is no separate field for it and
|
|
110
|
+
none is coming; the text is free-form. One run had that observation, was right
|
|
111
|
+
about it, and withdrew it believing the format had nowhere to put it.
|
|
112
|
+
|
|
113
|
+
## What your verdict does
|
|
114
|
+
|
|
115
|
+
`does_not_pass` is recorded, not a veto. The branch publishes either way, and no
|
|
116
|
+
repair round opens — by the time you are reading, the repair rotation and the
|
|
117
|
+
final run are spent.
|
|
118
|
+
|
|
119
|
+
What it does do: a capability whose Scenarios were **all** objected to is stored
|
|
120
|
+
`unguarded` at publish — the amber "not yet testable" lamp, never green — and
|
|
121
|
+
your objection becomes the sentence its owner reads in place of the headline.
|
|
122
|
+
Write it so it reads well there. One objection among sound siblings changes
|
|
123
|
+
nothing: the siblings guard the capability and its green lamp is earned. A
|
|
124
|
+
`pass_with_reservation` never downgrades anything, because a reservation states
|
|
125
|
+
the edge of what a Scenario checks; it does not deny that it checks something.
|
|
126
|
+
|
|
127
|
+
So an objection is free to this run and decisive to the next reader. That makes
|
|
128
|
+
it the shortest entry to write, which is exactly why you hold the line yourself:
|
|
129
|
+
`does_not_pass` is the answer for a Scenario that protects nothing, never for one
|
|
130
|
+
you have not finished reading. Use `pass_with_reservation` for a Scenario that
|
|
131
|
+
genuinely holds a promise and holds less of it than its name suggests. Nothing
|
|
132
|
+
mechanical can tell those two apart; what makes the difference is that your
|
|
133
|
+
sentence is specific enough to act on.
|
|
134
|
+
|
|
135
|
+
## What is not yours
|
|
136
|
+
|
|
137
|
+
**Do not edit the suite.** Not the `.feature` files, not the step definitions,
|
|
138
|
+
not the metadata — you have no `Edit`, and the one file you write is the review.
|
|
139
|
+
A reviewer who improves the thing under review has reviewed its own work.
|
|
140
|
+
|
|
141
|
+
**Do not run the suite or boot the application.** The candidate's run was made
|
|
142
|
+
by the connector and travels with the upload; a run started here lands on the
|
|
143
|
+
shared test database and proves nothing about the candidate that was bound.
|
|
144
|
+
|
|
145
|
+
**Do not rewrite anybody's verdict, including on a second pass.** If you find
|
|
146
|
+
yourself weighing whether an objection is worth the trouble, the answer is that
|
|
147
|
+
it costs this run nothing at all.
|
|
148
|
+
|
|
149
|
+
**Do not widen the review.** Whether a capability deserved more Scenarios is the
|
|
150
|
+
`selection_review` question and is answered per capability; everything else about
|
|
151
|
+
scope was decided before you were launched.
|
|
152
|
+
'''
|
|
153
|
+
|
|
154
|
+
[features.rollout_budget]
|
|
155
|
+
enabled = true
|
|
156
|
+
limit_tokens = 100000
|
|
157
|
+
reminder_at_remaining_tokens = [20000, 10000]
|
|
@@ -7,27 +7,40 @@ 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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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.
|
|
17
|
+
|
|
18
|
+
Three arrays besides the promises must always be present, empty when you have
|
|
19
|
+
nothing to put in them: `written_paths` (only your own `owned_paths`),
|
|
20
|
+
`decisions` (short statements of what you chose), and `known_problems` (precise
|
|
21
|
+
unresolved harness problems). A missing array is not an empty one — the gate
|
|
22
|
+
that reads this checkpoint refuses it either way.
|
|
23
|
+
|
|
24
|
+
Facts are short statements with source references.
|
|
25
|
+
The normative JSON shape of one facts entry is:
|
|
20
26
|
```json
|
|
21
27
|
{"fact":"The route creates an order.","source_refs":["app/orders.rb:12"]}
|
|
22
28
|
```
|
|
23
29
|
Every facts entry is an object in that shape, never a string. Never embed source
|
|
24
30
|
files, suite copies, or transcript.
|
|
25
31
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
32
|
+
Write first, then find out. Start with the planned cases your seeded facts
|
|
33
|
+
already support and get them onto disk; go reading only for what you still lack
|
|
34
|
+
after that. The opposite order — survey the sources, then write — is what spent
|
|
35
|
+
seven of eight workers' entire ceilings on a2time, 2026-08-10, and produced no
|
|
36
|
+
file at all. A fact already in your checkpoint is settled: do not establish it a
|
|
37
|
+
second time. Nothing mechanical enforces that rule; it holds because you keep
|
|
38
|
+
it.
|
|
39
|
+
|
|
40
|
+
Read only the `source_paths` and dependencies your finite planned cases need.
|
|
41
|
+
Ask closed questions with the files to look in. For a closed missing fact, use
|
|
42
|
+
the named `fact-finder` agent, as often as the work genuinely needs. A lookup
|
|
43
|
+
may confirm implementation facts but may not expand the plan.
|
|
31
44
|
|
|
32
45
|
Write only the plan item's `owned_paths` and its checkpoint. Never edit the
|
|
33
46
|
connector-owned harness, another worker's file, the user's own tests, manifests,
|
|
@@ -39,12 +52,16 @@ marker, metadata, or surface validation. You may make one final read of your
|
|
|
39
52
|
owned files before handoff. During that final read, confirm every `facts` entry
|
|
40
53
|
is an object in the normative shape above and correct the checkpoint if it is
|
|
41
54
|
not. Do not create temporary self-validation scripts or
|
|
42
|
-
loop over repeated rereads.
|
|
43
|
-
|
|
44
|
-
|
|
55
|
+
loop over repeated rereads.
|
|
56
|
+
|
|
57
|
+
Your ceiling is an emergency fuse, not a budget to spend. It sits far above the
|
|
58
|
+
work one plan item takes, so reaching it means this run is broken rather than
|
|
59
|
+
large. If it arrives, leave partial files and an accurate checkpoint; the
|
|
60
|
+
coordinator rotates unresolved work into one fresh repair task and reports the
|
|
61
|
+
fuse as a fault of the run, never as an outcome.
|
|
45
62
|
'''
|
|
46
63
|
|
|
47
64
|
[features.rollout_budget]
|
|
48
65
|
enabled = true
|
|
49
|
-
limit_tokens =
|
|
50
|
-
reminder_at_remaining_tokens = [
|
|
66
|
+
limit_tokens = 100000
|
|
67
|
+
reminder_at_remaining_tokens = [20000, 10000]
|
package/dist/files/budget.js
DELETED
|
@@ -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
|
-
}
|