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/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
- async putSuiteBuilds(items) {
77
- const res = await this.send('PUT', this.repoPath('suite_builds'), { suite_builds: items });
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.4.4",
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.4.4 run-local <branch>` and inspect the machine
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 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]
@@ -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
- 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.
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
- 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.
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. 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.
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 = 40000
50
- reminder_at_remaining_tokens = [8000, 4000]
66
+ limit_tokens = 100000
67
+ 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
- }