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/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. A `ready` item carries its blob; a `not_built` item is skipped.
106
- async getSuites() {
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 body.suites;
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.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.7 run-local <branch>` and inspect the machine
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": [{ "capability_id": "billing", "verdict": "pass" }]
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 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.
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` 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.
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