unitbob 0.7.7 → 0.7.12
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 +52 -4
- package/dist/files/behavioral.js +72 -5
- package/dist/files/featureStart.js +65 -0
- package/dist/files/features.js +156 -0
- package/dist/files/mapBuild.js +2 -1
- package/dist/files/suiteBuild.js +9 -2
- package/dist/runner/bdd.js +51 -16
- package/dist/runner/failureDigest.js +59 -0
- package/dist/runner/gitRevision.js +20 -0
- package/dist/runner/manifest.js +3 -1
- package/dist/runner/outputTail.js +13 -0
- package/dist/runner/pytestBddPlugin.js +15 -0
- package/dist/verbs/contractPrompt.js +23 -4
- package/dist/verbs/featurePrepare.js +49 -0
- package/dist/verbs/knowledgePrepare.js +39 -0
- package/dist/verbs/mapPrepare.js +29 -3
- package/dist/verbs/putFeature.js +21 -0
- package/dist/verbs/putKnowledge.js +21 -0
- package/dist/verbs/putTests.js +133 -0
- package/dist/verbs/run.js +72 -30
- package/dist/verbs/runLocal.js +56 -14
- package/dist/verbs/suitePrepare.js +3 -1
- package/dist/verbs/suiteReviewPrepare.js +45 -29
- package/dist/verbs/testsPrepare.js +77 -0
- package/dist/verbs/testsReviewPrepare.js +40 -0
- package/dist/wire.js +125 -5
- package/package.json +1 -1
- package/plugin/codex/agents/suite-repair-worker.toml +1 -1
- package/plugin/codex/agents/suite-reviewer.toml +53 -12
package/dist/wire.js
CHANGED
|
@@ -7,11 +7,17 @@ import { proxyHint } from "./proxyHint.js";
|
|
|
7
7
|
// that reads an absence as a verdict either invents a rejection or invents an
|
|
8
8
|
// approval. `validate-build` is the caller that needs the difference: with no
|
|
9
9
|
// server it succeeds, and says out loud which questions went unasked.
|
|
10
|
+
//
|
|
11
|
+
// `status` is the HTTP status the server answered with, when there was one:
|
|
12
|
+
// for the one caller that treats a 404 — a server older than the route — as
|
|
13
|
+
// an absence rather than a verdict (`map-prepare`, spec 52-4).
|
|
10
14
|
export class WireError extends Error {
|
|
11
15
|
unreachable;
|
|
16
|
+
status;
|
|
12
17
|
constructor(message, options = {}) {
|
|
13
18
|
super(message);
|
|
14
19
|
this.unreachable = options.unreachable ?? false;
|
|
20
|
+
this.status = options.status ?? null;
|
|
15
21
|
}
|
|
16
22
|
}
|
|
17
23
|
// POST /repos/register — the linking bootstrap (spec 28). A standalone function
|
|
@@ -102,15 +108,19 @@ export class Wire {
|
|
|
102
108
|
return body.results;
|
|
103
109
|
}
|
|
104
110
|
// GET /repos/:id/suites — both current suites (spec 32), exactly two peer
|
|
105
|
-
// items
|
|
106
|
-
|
|
111
|
+
// items, and since spec 52-3 the checks of every red feature beside them. A
|
|
112
|
+
// `ready` item carries its blob; a `not_built` item is skipped.
|
|
113
|
+
async getSuiteIndex() {
|
|
107
114
|
const res = await this.send('GET', this.repoPath('suites'));
|
|
108
115
|
await this.ensureOk(res, `GET ${this.repoPath('suites')}`);
|
|
109
116
|
const body = (await res.json());
|
|
110
117
|
if (!Array.isArray(body.suites)) {
|
|
111
118
|
throw new WireError(`GET ${this.repoPath('suites')} returned no suites array.`);
|
|
112
119
|
}
|
|
113
|
-
return
|
|
120
|
+
return {
|
|
121
|
+
suites: body.suites,
|
|
122
|
+
feature_suites: Array.isArray(body.feature_suites) ? body.feature_suites : [],
|
|
123
|
+
};
|
|
114
124
|
}
|
|
115
125
|
// POST /repos/:id/runs/batch — ship each branch's raw report (or suite error)
|
|
116
126
|
// in one batch; the server parses each against the exact stored version and
|
|
@@ -158,6 +168,69 @@ export class Wire {
|
|
|
158
168
|
await this.ensureOk(res, `GET ${url}`);
|
|
159
169
|
return (await res.json());
|
|
160
170
|
}
|
|
171
|
+
// POST /repos/:id/features — record a feature (spec 52-1). A 409 (no current
|
|
172
|
+
// map) and a 422 (an id not on the map, with both sides named in the body)
|
|
173
|
+
// surface as a WireError carrying the server's text, so the host reads what
|
|
174
|
+
// the map knows and corrects its file.
|
|
175
|
+
async postFeature(payload) {
|
|
176
|
+
const res = await this.send('POST', this.repoPath('features'), payload);
|
|
177
|
+
if (res.status === 422)
|
|
178
|
+
throw new WireError(await unknownCapabilitiesRefusal(res));
|
|
179
|
+
await this.ensureOk(res, `POST ${this.repoPath('features')}`);
|
|
180
|
+
return (await res.json());
|
|
181
|
+
}
|
|
182
|
+
// GET /repos/:id/features — every feature of the project, newest first, and
|
|
183
|
+
// the server's words for an empty list (spec 52-2, AC 1.2).
|
|
184
|
+
async listFeatures() {
|
|
185
|
+
const res = await this.send('GET', this.repoPath('features'));
|
|
186
|
+
await this.ensureOk(res, `GET ${this.repoPath('features')}`);
|
|
187
|
+
return (await res.json());
|
|
188
|
+
}
|
|
189
|
+
// GET /repos/:id/features/:feature_id/knowledge_packet (spec 52-2, AC 1.3).
|
|
190
|
+
async getKnowledgePacket(featureId) {
|
|
191
|
+
const path = this.repoPath(`features/${encodeURIComponent(String(featureId))}/knowledge_packet`);
|
|
192
|
+
const res = await this.send('GET', path);
|
|
193
|
+
await this.ensureOk(res, `GET ${path}`);
|
|
194
|
+
return (await res.json());
|
|
195
|
+
}
|
|
196
|
+
// PUT /repos/:id/features/:feature_id/knowledge (spec 52-2, AC 1.4). The
|
|
197
|
+
// server checks the file's shape; a 422 carries `problems`, and they are
|
|
198
|
+
// relaid whole, one line per problem with both sides — the host fixes the
|
|
199
|
+
// file from those lines, and cutting them at 500 characters would hide the
|
|
200
|
+
// ones at the end.
|
|
201
|
+
async putKnowledge(featureId, knowledge) {
|
|
202
|
+
const path = this.repoPath(`features/${encodeURIComponent(String(featureId))}/knowledge`);
|
|
203
|
+
const res = await this.send('PUT', path, { knowledge });
|
|
204
|
+
if (res.status === 422)
|
|
205
|
+
throw new WireError(await problemsRefusal(res, 'PUT knowledge failed: 422'));
|
|
206
|
+
await this.ensureOk(res, `PUT ${path}`);
|
|
207
|
+
return (await res.json());
|
|
208
|
+
}
|
|
209
|
+
// GET /repos/:id/features/:feature_id/tests_packet (spec 52-3, AC 2.1). A
|
|
210
|
+
// 409 is the server's own sentence (talk the feature through first) and is
|
|
211
|
+
// relaid as it is.
|
|
212
|
+
async getTestsPacket(featureId) {
|
|
213
|
+
const path = this.repoPath(`features/${encodeURIComponent(String(featureId))}/tests_packet`);
|
|
214
|
+
const res = await this.send('GET', path);
|
|
215
|
+
if (res.status === 409)
|
|
216
|
+
throw new WireError(await wordedRefusal(res, `GET tests_packet failed: 409`));
|
|
217
|
+
await this.ensureOk(res, `GET ${path}`);
|
|
218
|
+
return (await res.json());
|
|
219
|
+
}
|
|
220
|
+
// PUT /repos/:id/features/:feature_id/suite (spec 52-3, AC 2.2). Every
|
|
221
|
+
// refusal is worded by the server: a 409 in one sentence, a 422 in one
|
|
222
|
+
// sentence or, for a broken seal, with one problem per difference — relaid
|
|
223
|
+
// whole, both sides per line, like the knowledge file's.
|
|
224
|
+
async putFeatureSuite(featureId, upload) {
|
|
225
|
+
const path = this.repoPath(`features/${encodeURIComponent(String(featureId))}/suite`);
|
|
226
|
+
const res = await this.send('PUT', path, upload);
|
|
227
|
+
if (res.status === 409)
|
|
228
|
+
throw new WireError(await wordedRefusal(res, 'PUT suite failed: 409'));
|
|
229
|
+
if (res.status === 422)
|
|
230
|
+
throw new WireError(await problemsRefusal(res, 'PUT suite failed: 422'));
|
|
231
|
+
await this.ensureOk(res, `PUT ${path}`);
|
|
232
|
+
return (await res.json());
|
|
233
|
+
}
|
|
161
234
|
// GET /recipes/:name — fetch a recipe at call time. Recipes live on Rails so
|
|
162
235
|
// the connector and Skill carry no recipe text (spec 15, acceptance criteria).
|
|
163
236
|
async getRecipe(name) {
|
|
@@ -244,7 +317,7 @@ export class Wire {
|
|
|
244
317
|
if (res.status === 404) {
|
|
245
318
|
throw new WireError(`This project is linked to a repository the server at ${this.config.server} does not have, ` +
|
|
246
319
|
'or the token in .unitbob.json does not open it. Delete .unitbob.json to link again ' +
|
|
247
|
-
'(the old project, along with its map and checks, stays where it is).');
|
|
320
|
+
'(the old project, along with its map and checks, stays where it is).', { status: 404 });
|
|
248
321
|
}
|
|
249
322
|
let detail = '';
|
|
250
323
|
try {
|
|
@@ -253,8 +326,55 @@ export class Wire {
|
|
|
253
326
|
catch {
|
|
254
327
|
// ignore — the status alone is actionable enough
|
|
255
328
|
}
|
|
256
|
-
throw new WireError(statusRefusal(what, res, detail, this.config.server));
|
|
329
|
+
throw new WireError(statusRefusal(what, res, detail, this.config.server), { status: res.status });
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
// The 422 of POST /features carries two lists, and the host corrects its file by
|
|
333
|
+
// reading both (spec 52-1, AC 1.3). `ensureOk` keeps 500 characters of a body,
|
|
334
|
+
// which is a line of context for every other refusal and, on a map of twenty
|
|
335
|
+
// capabilities or more, cuts `known_ids` in half — the list the correction is
|
|
336
|
+
// made from, on exactly the projects that have the most ids to get wrong. So
|
|
337
|
+
// the two lists are relaid whole, one per line; any other 422 body keeps the
|
|
338
|
+
// ordinary shape.
|
|
339
|
+
async function unknownCapabilitiesRefusal(res) {
|
|
340
|
+
const { text, body } = await readBody(res);
|
|
341
|
+
if (!Array.isArray(body.unknown_ids) || !Array.isArray(body.known_ids)) {
|
|
342
|
+
return `POST features failed: 422 — ${text.slice(0, 500)}`;
|
|
343
|
+
}
|
|
344
|
+
return (`POST features failed: 422 — ${String(body.error ?? 'These capabilities are not on the current map.')}\n` +
|
|
345
|
+
`unknown_ids: ${JSON.stringify(body.unknown_ids)}\n` +
|
|
346
|
+
`known_ids: ${JSON.stringify(body.known_ids)}`);
|
|
347
|
+
}
|
|
348
|
+
// A refusal the server worded in one sentence: that sentence, whole.
|
|
349
|
+
async function wordedRefusal(res, prefix) {
|
|
350
|
+
const { text, body } = await readBody(res);
|
|
351
|
+
return `${prefix} — ${typeof body.error === 'string' ? body.error : text.slice(0, 500)}`;
|
|
352
|
+
}
|
|
353
|
+
// A refusal that may carry `problems` — the knowledge file's shape (spec 52-2,
|
|
354
|
+
// AC 5.3) or a broken seal (spec 52-3): the sentence, then one problem per line
|
|
355
|
+
// with both sides, relaid whole because the host fixes the file from all of
|
|
356
|
+
// them; without problems, the sentence.
|
|
357
|
+
async function problemsRefusal(res, prefix) {
|
|
358
|
+
const { text, body } = await readBody(res);
|
|
359
|
+
const head = `${prefix} — ${typeof body.error === 'string' ? body.error : text.slice(0, 500)}`;
|
|
360
|
+
if (!Array.isArray(body.problems))
|
|
361
|
+
return head;
|
|
362
|
+
const lines = body.problems.map((problem) => `expected: ${String(problem.expected)}\n got: ${String(problem.got)}`);
|
|
363
|
+
return [head, ...lines].join('\n');
|
|
364
|
+
}
|
|
365
|
+
// A refusal body as text and, when it is JSON, as an object; when it is not,
|
|
366
|
+
// the text itself is the detail.
|
|
367
|
+
async function readBody(res) {
|
|
368
|
+
let text = '';
|
|
369
|
+
let body = {};
|
|
370
|
+
try {
|
|
371
|
+
text = await res.text();
|
|
372
|
+
body = JSON.parse(text);
|
|
373
|
+
}
|
|
374
|
+
catch {
|
|
375
|
+
// not JSON — the text itself is the detail
|
|
257
376
|
}
|
|
377
|
+
return { text, body };
|
|
258
378
|
}
|
|
259
379
|
// The two statuses that prove somebody else answered.
|
|
260
380
|
//
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "unitbob",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.12",
|
|
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": {
|
|
@@ -21,7 +21,7 @@ markers, or paths. Do not edit production code, host-owned shared files, the
|
|
|
21
21
|
connector-owned harness, or another slice.
|
|
22
22
|
|
|
23
23
|
After every owned edit, run
|
|
24
|
-
`npx -y --loglevel=error unitbob@0.7.
|
|
24
|
+
`npx -y --loglevel=error unitbob@0.7.12 run-local <branch>` and inspect the machine
|
|
25
25
|
report. Look only at examples or scenarios matching your owned paths or case
|
|
26
26
|
markers. Do not require a green exit code from the whole branch: foreign failures
|
|
27
27
|
and an already-confirmed product red do not widen your scope. Repeat the bounded
|
|
@@ -47,7 +47,10 @@ Write strict JSON, and nothing else, to
|
|
|
47
47
|
"known_defect_probe": { "status": "not_supplied" },
|
|
48
48
|
"selection_review": {
|
|
49
49
|
"plan_digest": "<exact plan_digest from the request>",
|
|
50
|
-
"capability_reviews": [
|
|
50
|
+
"capability_reviews": [
|
|
51
|
+
{ "capability_id": "billing", "verdict": "pass" },
|
|
52
|
+
{ "capability_id": "reporting", "verdict": "pass" }
|
|
53
|
+
]
|
|
51
54
|
}
|
|
52
55
|
}
|
|
53
56
|
```
|
|
@@ -58,11 +61,19 @@ Nesting it one level deeper cost a run its publish; so did inventing values for
|
|
|
58
61
|
top-level keys", which dropped it entirely. Copy the digest, do not compute it.
|
|
59
62
|
|
|
60
63
|
Write `selection_review` only when the request carries a `plan_digest`, and give
|
|
61
|
-
it one entry
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
64
|
+
it one entry for **every capability in the request's `behavioral_assignment`** —
|
|
65
|
+
the map's whole list, not the `worker_plan`'s. The plan takes a few capabilities
|
|
66
|
+
and leaves the rest for a later build; a capability it left out still gets a
|
|
67
|
+
verdict, about the deferral: `pass` when the candidate's `capabilities` mark it
|
|
68
|
+
`unguarded` with an honest reason, `does_not_pass` when a promise was dropped or
|
|
69
|
+
merged away. In the example above, `billing` was planned and `reporting` was
|
|
70
|
+
deferred, and both are there. On soul, 2026-09-11, the reviewer wrote one entry
|
|
71
|
+
per plan item and the publish was refused for every capability it left out.
|
|
72
|
+
Its verdicts are `pass` or `does_not_pass` — there is no `pass_with_reservation`
|
|
73
|
+
at capability level — and `does_not_pass` owes a non-empty
|
|
74
|
+
`reviewer_objection_text` naming the lost promise, the unjustified merge, or the
|
|
75
|
+
dishonest deferral. Selection objections are recorded; they never block the
|
|
76
|
+
publish and never downgrade a lamp.
|
|
66
77
|
|
|
67
78
|
Omit `candidate_run`, `known_defect_context` and any runner report: the connector
|
|
68
79
|
owns those and adds them itself.
|
|
@@ -100,12 +111,21 @@ and no answer that consists of writing nothing.
|
|
|
100
111
|
it never excuses a "loads successfully" assertion for a promised record, state
|
|
101
112
|
change, message, or side effect.
|
|
102
113
|
|
|
103
|
-
`public_surfaces`
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
114
|
+
`public_surfaces` is your check of the worker's claim, not a list of your
|
|
115
|
+
own. Read the steps behind the Scenario and confirm every address in its
|
|
116
|
+
`surface_coverage` is really driven by some step; if every one is, copy that
|
|
117
|
+
list into `public_surfaces` verbatim — the two must be equal, and the server
|
|
118
|
+
refuses a Scenario where they are not. If the manifest names an address **no
|
|
119
|
+
step drives at all**, the verdict is `does_not_pass`, naming the address
|
|
120
|
+
(a2time, 2026-08-17: six Scenarios claimed addresses their steps never
|
|
121
|
+
touched). An address a step drives that the manifest does **not** name never
|
|
122
|
+
goes into the field: a `When` reaching another capability's address goes in
|
|
123
|
+
`reservation` (below); a `Then` re-reading state, a `Given` arriving, an `After`
|
|
124
|
+
leaving go nowhere. A claimed address that a `Then` drives rather than the
|
|
125
|
+
`When` is real — it stays in the list; say which step drove it in `reservation`
|
|
126
|
+
if it matters. On microblog, 2026-09-11, that finding was right and the list
|
|
127
|
+
was shortened to show it, and the publish was refused for the shortened list.
|
|
107
128
|
|
|
108
|
-
The `When`, and only the `When` — the same rule the worker wrote its list by.
|
|
109
129
|
What a `Given` does to arrive (sign in, create the table the Scenario needs) and
|
|
110
130
|
what an `After` does to leave are not the behaviour under test, so an address
|
|
111
131
|
they touch is not missing from `surface_coverage` and not a finding. On soul,
|
|
@@ -140,6 +160,24 @@ genuinely holds a promise and holds less of it than its name suggests. Nothing
|
|
|
140
160
|
mechanical can tell those two apart; what makes the difference is that your
|
|
141
161
|
sentence is specific enough to act on.
|
|
142
162
|
|
|
163
|
+
## A feature's checks
|
|
164
|
+
|
|
165
|
+
The same review, for a smaller candidate: the checks of one feature being
|
|
166
|
+
built (spec 52-4). Then the request is
|
|
167
|
+
`.unitbob/features/<id>/tests-review-request.json`, with the same keys as the
|
|
168
|
+
main suite's plus two — `knowledge_path` and `scenarios` — and the review goes
|
|
169
|
+
to the `output_path` it names, in the same form: `candidate_digest` at the top
|
|
170
|
+
level, `bdd_quality_review` with one entry per Scenario by `case_marker` and
|
|
171
|
+
name. There is no `known_defect_probe` and no `selection_review` here; write
|
|
172
|
+
neither.
|
|
173
|
+
|
|
174
|
+
When the request carries `knowledge_path`, the promise each Scenario protects
|
|
175
|
+
is written in that `knowledge.md`, in its `Scenarios` section — read it there,
|
|
176
|
+
not from the capability description, which for a feature is one line. The
|
|
177
|
+
Scenario text is the user's and sealed; the steps behind it are what you
|
|
178
|
+
judge, exactly as above. Do not read the feature's implementation: whether the
|
|
179
|
+
code is right is the run's question, and the run was made by the connector.
|
|
180
|
+
|
|
143
181
|
## What is not yours
|
|
144
182
|
|
|
145
183
|
**Do not edit the suite.** Not the `.feature` files, not the step definitions,
|
|
@@ -152,7 +190,10 @@ shared test database and proves nothing about the candidate that was bound.
|
|
|
152
190
|
|
|
153
191
|
**Do not rewrite anybody's verdict, including on a second pass.** If you find
|
|
154
192
|
yourself weighing whether an objection is worth the trouble, the answer is that
|
|
155
|
-
it costs this run nothing at all.
|
|
193
|
+
it costs this run nothing at all. A second pass that arrives with a
|
|
194
|
+
`validate-build` refusal corrects the field it names — `public_surfaces` brought
|
|
195
|
+
back to the manifest, a missing `selection_review` entry added — and leaves
|
|
196
|
+
every verdict as it was.
|
|
156
197
|
|
|
157
198
|
**Do not widen the review.** Whether a capability deserved more Scenarios is the
|
|
158
199
|
`selection_review` question and is answered per capability; everything else about
|