unitbob 0.6.0 → 0.6.2

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,133 @@
1
+ // What to say when a wire call fails on a machine that reaches the network
2
+ // through a proxy.
3
+ //
4
+ // a2time, 2026-08-17. The Unitbob host was added to the sandbox's allowlist,
5
+ // `curl` started answering 200, and the connector went on failing with 403 for
6
+ // another round of debugging. Both facts were true at once: `curl` reads
7
+ // `HTTPS_PROXY` from the environment, and Node's `fetch` does not, so the two
8
+ // were not talking to the same place. Nothing in the failure said so — the
9
+ // connector printed the status it got and left the reader to guess that the
10
+ // variable sitting in their own environment was being ignored.
11
+ //
12
+ // This module is one sentence, and deliberately not a fix. Routing our requests
13
+ // through the proxy ourselves would mean either a runtime dependency (this
14
+ // package has none, on purpose — `npx unitbob` installs in a second) or
15
+ // re-spawning ourselves with the variable set, which works on some Node versions
16
+ // and silently does not on others. Naming the cause costs nothing and cannot
17
+ // break a working machine.
18
+ //
19
+ // What it *can* do is send somebody to break one, which is why so much of this
20
+ // file is about staying quiet. Measured on Node 25.2.1 against a CONNECT-logging
21
+ // proxy: with `NODE_USE_ENV_PROXY=1` set, Node tunnels `http://127.0.0.1:19999`
22
+ // through the proxy too — it does not exempt loopback. So this sentence, said to
23
+ // somebody whose brain runs on `http://localhost:3000` and is merely down, would
24
+ // talk them into breaking the one setup that works.
25
+ // The variables that mean "this machine has a proxy", in the order they answer
26
+ // for an `https://` server. The lowercase spellings are not a nicety: on Unix
27
+ // they are the older convention and plenty of environments still set only those.
28
+ const PROXY_VARS = ['HTTPS_PROXY', 'https_proxy', 'HTTP_PROXY', 'http_proxy'];
29
+ // The first Node that can be told to use the environment's proxy for `fetch`.
30
+ // Below it the variable is inert, so naming it would be a second round of the
31
+ // exact confusion this file exists to end. Held at major-version granularity on
32
+ // purpose: 18 and 20 plainly have nothing, 22 has it (a2time ran 22.22.3 and the
33
+ // variable cured it), and pinning a patch number this file cannot verify would
34
+ // be a guess dressed as a fact.
35
+ const FIRST_NODE_WITH_ENV_PROXY = 22;
36
+ // The sentence to add to a message that has already failed, or null when there
37
+ // is nothing worth saying: no proxy configured, one Node is already using, or a
38
+ // target that would not go through it anyway.
39
+ //
40
+ // `target` is the server the failed call was aimed at. It is required rather
41
+ // than optional because every rule below that keeps this quiet needs it, and an
42
+ // optional argument here would be a hint that is silently louder at half its
43
+ // call sites.
44
+ export function proxyHint(target, options = {}) {
45
+ const env = options.env ?? process.env;
46
+ const nodeVersion = options.nodeVersion ?? process.version;
47
+ if (nodeAlreadyUsesTheProxy(env))
48
+ return null;
49
+ const found = PROXY_VARS.map((name) => ({ name, value: (env[name] ?? '').trim() })).find((candidate) => candidate.value.length > 0);
50
+ if (!found)
51
+ return null;
52
+ if (!goesThroughTheProxy(target, env))
53
+ return null;
54
+ const shown = withoutCredentials(found.value);
55
+ const where = shown ? `\`${found.name}=${shown}\`` : `\`${found.name}\``;
56
+ const cause = `This machine reaches the network through a proxy (${where}), and Node does not send its own ` +
57
+ 'requests through it unless it is told to — which is why `curl` can reach a host that this command cannot.';
58
+ return majorVersion(nodeVersion) < FIRST_NODE_WITH_ENV_PROXY
59
+ ? `${cause} This Node (${nodeVersion}) has no way to be told: run the command under Node ` +
60
+ `${FIRST_NODE_WITH_ENV_PROXY} or newer.`
61
+ : `${cause} Re-run the same command with \`NODE_USE_ENV_PROXY=1\` in front of it.`;
62
+ }
63
+ // Whether Node is already routing `fetch` through the environment's proxy, in
64
+ // either of the two ways it can be asked to. The words that mean "off" are
65
+ // spelled out rather than "anything but 0", so that `NODE_USE_ENV_PROXY=false`
66
+ // does not quietly buy silence from a machine that needs the sentence.
67
+ function nodeAlreadyUsesTheProxy(env) {
68
+ const flag = (env.NODE_USE_ENV_PROXY ?? '').trim().toLowerCase();
69
+ if (flag.length > 0 && flag !== '0' && flag !== 'false' && flag !== 'no')
70
+ return true;
71
+ return (env.NODE_OPTIONS ?? '').includes('--use-env-proxy');
72
+ }
73
+ // Whether this target would travel through the proxy at all — the two exemptions
74
+ // Node itself applies, checked here so the advice matches what setting the
75
+ // variable would actually do.
76
+ //
77
+ // `NO_PROXY` is matched the way every tool that reads it matches: `*` exempts
78
+ // everything, and an entry is a host or a domain suffix, with an optional
79
+ // leading dot that means the same thing.
80
+ function goesThroughTheProxy(target, env) {
81
+ let host;
82
+ try {
83
+ host = new URL(target).hostname.toLowerCase();
84
+ }
85
+ catch {
86
+ // A server we cannot parse is one we cannot reason about. Silence is the
87
+ // safe half of that, since the message it would join is already printed.
88
+ return false;
89
+ }
90
+ // Measured, not assumed: Node tunnels loopback through the proxy like anything
91
+ // else. Nobody's proxy has a route back to their own machine, so this is the
92
+ // one place where following the advice makes a working setup fail.
93
+ if (host === 'localhost' || host === '::1' || host === '[::1]' || /^127\./.test(host))
94
+ return false;
95
+ const noProxy = (env.NO_PROXY ?? env.no_proxy ?? '').trim();
96
+ if (noProxy === '*')
97
+ return false;
98
+ return !noProxy
99
+ .split(',')
100
+ .map((entry) => entry.trim().toLowerCase().replace(/^\./, ''))
101
+ .filter((entry) => entry.length > 0)
102
+ .some((entry) => host === entry || host.endsWith(`.${entry}`));
103
+ }
104
+ // The major of a `v25.2.1`, or 0 when it is not a version string we can read —
105
+ // which reports "too old" and sends nobody to set a variable that may be inert.
106
+ function majorVersion(version) {
107
+ const major = /^v?(\d+)\./.exec(version.trim());
108
+ return major ? Number(major[1]) : 0;
109
+ }
110
+ // The proxy as it is safe to print: the address, never the credentials.
111
+ //
112
+ // A proxy URL is one of the last places a password is still written in plain
113
+ // text, and this sentence ends up in terminals, transcripts and bug reports. The
114
+ // address is the part that helps somebody recognise which proxy this is; the
115
+ // credentials never are.
116
+ //
117
+ // Null when the value does not parse as a URL. Then it still means "this machine
118
+ // has a proxy" — worth saying — but nothing in it can be echoed, because a
119
+ // password cannot be found in a string whose shape we could not read.
120
+ function withoutCredentials(value) {
121
+ let url;
122
+ try {
123
+ url = new URL(value);
124
+ }
125
+ catch {
126
+ return null;
127
+ }
128
+ if (url.username.length === 0 && url.password.length === 0)
129
+ return value;
130
+ url.username = '';
131
+ url.password = '';
132
+ return url.toString();
133
+ }
@@ -52,6 +52,7 @@ export async function validateWorkerCheckpoints(config, _args = [], deps = { std
52
52
  errors.push(`${label}: written path ${pathValue} is not an owned path`);
53
53
  }
54
54
  validateCompactFacts(checkpoint.facts, label, errors);
55
+ validateSurfaceCoverage(checkpoint.surface_coverage, item, label, errors);
55
56
  stringArray(checkpoint.decisions, `${label}: decisions`, errors);
56
57
  stringArray(checkpoint.known_problems, `${label}: known_problems`, errors);
57
58
  }
@@ -68,6 +69,16 @@ function stringArray(value, label, errors) {
68
69
  }
69
70
  return value;
70
71
  }
72
+ // A fact says how it was established, and the vocabulary is two words wide:
73
+ // `read` when the `source_refs` are what establishes it, `ran: <command>` when
74
+ // something was executed and its result observed.
75
+ //
76
+ // a2time, 2026-08-17. A seeded fact claimed a dismissed employee cannot sign in.
77
+ // It came from reading one method and remembering another, it reached sixteen
78
+ // workers marked as verified, and it was false. Every fact that run established
79
+ // by running the application held; the one that was not, did not — and nothing in
80
+ // the checkpoint told the two apart, so no reader could weigh them differently.
81
+ const ESTABLISHED_BY = /^(read|ran: \S.*)$/;
71
82
  function validateCompactFacts(value, label, errors) {
72
83
  if (!Array.isArray(value)) {
73
84
  errors.push(`${label}: facts must be an array`);
@@ -75,7 +86,7 @@ function validateCompactFacts(value, label, errors) {
75
86
  }
76
87
  for (const [index, entry] of value.entries()) {
77
88
  if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
78
- errors.push(`${label}: $.facts[${index}] must be an object with fact and source_refs; got ${jsonType(entry)}`);
89
+ errors.push(`${label}: $.facts[${index}] must be an object with fact, source_refs and established_by; got ${jsonType(entry)}`);
79
90
  continue;
80
91
  }
81
92
  const fact = entry;
@@ -84,11 +95,50 @@ function validateCompactFacts(value, label, errors) {
84
95
  if (!Array.isArray(fact.source_refs) || fact.source_refs.some((ref) => typeof ref !== 'string' || !ref.trim())) {
85
96
  errors.push(`${label}: facts[${index}].source_refs must be compact source references`);
86
97
  }
98
+ if (typeof fact.established_by !== 'string' || !ESTABLISHED_BY.test(fact.established_by)) {
99
+ errors.push(`${label}: facts[${index}].established_by must be "read" or "ran: <command>"`);
100
+ }
87
101
  if ('source' in fact || 'transcript' in fact || 'suite' in fact) {
88
102
  errors.push(`${label}: facts[${index}] may not embed source, transcript, or suite copies`);
89
103
  }
90
104
  }
91
105
  }
106
+ // Which addresses a Scenario drives is knowable in one place — the step file the
107
+ // worker just wrote — and until now it travelled nowhere. The coordinator owes
108
+ // the server one `surface_coverage` entry per Scenario, so on a2time, 2026-08-17,
109
+ // it assembled that join out of the workers' closing prose and its own plan. The
110
+ // independent reviewer read the step code instead, the two disagreed on six
111
+ // Scenarios, and the server refused the publication. The join now rides with the
112
+ // work that produced it, and the coordinator copies it instead of interpreting.
113
+ //
114
+ // Behavioral only: this is a join between Gherkin Scenarios and surfaces, and the
115
+ // structural branch has neither. Requiring the key there would refuse honest
116
+ // slices over a field that would mean nothing if they filled it in.
117
+ function validateSurfaceCoverage(value, item, label, errors) {
118
+ if (value === undefined && item.branch !== 'behavioral')
119
+ return;
120
+ if (!Array.isArray(value)) {
121
+ errors.push(`${label}: surface_coverage must be an array of {capability_id, scenario, surfaces} entries, one per Scenario written`);
122
+ return;
123
+ }
124
+ for (const [index, entry] of value.entries()) {
125
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
126
+ errors.push(`${label}: surface_coverage[${index}] must be an object with capability_id, scenario and surfaces; got ${jsonType(entry)}`);
127
+ continue;
128
+ }
129
+ const record = entry;
130
+ if (typeof record.capability_id !== 'string' || !item.capability_ids.includes(record.capability_id)) {
131
+ errors.push(`${label}: surface_coverage[${index}].capability_id ${String(record.capability_id)} is not in this plan item`);
132
+ }
133
+ if (typeof record.scenario !== 'string' || !record.scenario.trim()) {
134
+ errors.push(`${label}: surface_coverage[${index}].scenario must name the exact Scenario it covers`);
135
+ }
136
+ if (!Array.isArray(record.surfaces) || record.surfaces.length === 0
137
+ || record.surfaces.some((surface) => typeof surface !== 'string' || !surface.trim())) {
138
+ errors.push(`${label}: surface_coverage[${index}].surfaces must name at least one surface the Scenario drives`);
139
+ }
140
+ }
141
+ }
92
142
  function jsonType(value) {
93
143
  if (value === null)
94
144
  return 'null';
package/dist/wire.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { proxyHint } from "./proxyHint.js";
1
2
  // Raised when the server cannot be reached or answers with an error status.
2
3
  // Verbs surface its message and exit non-zero; they never fabricate a result.
3
4
  //
@@ -32,8 +33,8 @@ export async function registerRepo(server, name) {
32
33
  });
33
34
  }
34
35
  catch (err) {
35
- throw new WireError(`Cannot reach the Unitbob server at ${server} (${err.message}). ` +
36
- 'Check that the server is running.');
36
+ throw new WireError(withProxyHint(`Cannot reach the Unitbob server at ${server} (${err.message}). ` +
37
+ 'Check that the server is running.', server));
37
38
  }
38
39
  if (!res.ok) {
39
40
  let detail = '';
@@ -43,7 +44,7 @@ export async function registerRepo(server, name) {
43
44
  catch {
44
45
  // ignore — the status alone is actionable enough
45
46
  }
46
- throw new WireError(`POST ${url} failed: ${res.status} ${res.statusText}${detail ? ` — ${detail}` : ''}`);
47
+ throw new WireError(statusRefusal(`POST ${url}`, res, detail, server));
47
48
  }
48
49
  const payload = (await res.json());
49
50
  if (typeof payload.id !== 'number' || !Number.isInteger(payload.id)) {
@@ -229,9 +230,9 @@ export class Wire {
229
230
  });
230
231
  }
231
232
  catch (err) {
232
- throw new WireError(`Cannot reach the Unitbob server at ${this.config.server} ` +
233
+ throw new WireError(withProxyHint(`Cannot reach the Unitbob server at ${this.config.server} ` +
233
234
  `(${err.message}). Check that the server is running and that ` +
234
- `"server" in .unitbob.json is correct.`, { unreachable: true });
235
+ `"server" in .unitbob.json is correct.`, this.config.server), { unreachable: true });
235
236
  }
236
237
  }
237
238
  async ensureOk(res, what) {
@@ -252,6 +253,44 @@ export class Wire {
252
253
  catch {
253
254
  // ignore — the status alone is actionable enough
254
255
  }
255
- throw new WireError(`${what} failed: ${res.status} ${res.statusText}${detail ? ` — ${detail}` : ''}`);
256
+ throw new WireError(statusRefusal(what, res, detail, this.config.server));
256
257
  }
257
258
  }
259
+ // The two statuses that prove somebody else answered.
260
+ //
261
+ // a2time, 2026-08-17: a sandbox refused the host with `403 Forbidden — Host not
262
+ // in allowlist`, and the run read the 403 as Unitbob's own verdict. It cannot be
263
+ // one. The brain answers 404 even to a caller holding the wrong token — a 403
264
+ // would confirm the project exists — so 403 never comes from it, and 407 is a
265
+ // proxy demanding credentials, which the brain has never heard of.
266
+ const NOT_FROM_THE_BRAIN = {
267
+ 403: 'The Unitbob server never answers 403 — it answers 404 even to a caller holding the wrong token — so ' +
268
+ 'this was written by something between this machine and it: a proxy, a gateway, or a sandbox that has ' +
269
+ 'not been told this host is allowed.',
270
+ 407: 'A 407 is a proxy asking this machine to authenticate. It did not come from the Unitbob server.',
271
+ };
272
+ // One failing status, written the same way wherever it was collected.
273
+ //
274
+ // Registration and every tokened call compose this identically on purpose: they
275
+ // used to disagree, and the disagreement was invisible — a 422 from `register`
276
+ // carried network advice while the same 422 from a repo call did not, so which
277
+ // sentence a person got depended on which endpoint they happened to hit.
278
+ function statusRefusal(what, res, detail, server) {
279
+ const line = `${what} failed: ${res.status} ${res.statusText}${detail ? ` — ${detail}` : ''}`;
280
+ // A status the brain cannot have written. Relaying it as it stands reads as
281
+ // "Unitbob refused you" and sends the reader to their token; the refusal came
282
+ // from the network in between, and only they can clear it. Every other status
283
+ // is the server's own verdict and keeps the message it has always had — a
284
+ // proxy sentence on a 422 sends somebody to their network settings over a
285
+ // validation error.
286
+ const notOurs = NOT_FROM_THE_BRAIN[res.status];
287
+ return notOurs ? withProxyHint(`${line}\n${notOurs}`, server) : line;
288
+ }
289
+ // Every wire failure that the network could explain ends with the same sentence
290
+ // about it — see `proxyHint`, which decides whether there is one to say. Here
291
+ // rather than inside `WireError` so that a message composed for some other
292
+ // reason never picks it up by accident.
293
+ function withProxyHint(message, server) {
294
+ const hint = proxyHint(server);
295
+ return hint ? `${message}\n${hint}` : message;
296
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unitbob",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
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.6.0 run-local <branch>` and inspect the machine
24
+ `npx -y --loglevel=error unitbob@0.6.2 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
@@ -48,9 +48,13 @@ No strict JSON handoff is required.
48
48
  Update the same checkpoint as promises complete. Keep facts compact and
49
49
  source-referenced. The normative JSON shape of one facts entry is:
50
50
  ```json
51
- {"fact":"The route creates an order.","source_refs":["app/orders.rb:12"]}
51
+ {"fact":"The route creates an order.","source_refs":["app/orders.rb:12"],"established_by":"read"}
52
52
  ```
53
- Every facts entry is an object in that shape, never a string. Before handoff,
53
+ Every facts entry is an object in that shape, never a string; `established_by` is
54
+ `read` or `ran: <command>`, and a failure you reproduced is the second kind. On
55
+ the behavioral branch, when you rename a Scenario or change what its steps drive,
56
+ update that Scenario's `surface_coverage` entry in the same breath — the
57
+ coordinator publishes those entries and does not reread your steps. Before handoff,
54
58
  make one final read of the checkpoint and confirm every `facts` entry is an
55
59
  object in the normative shape above. Do not delegate repair or auto-resume after
56
60
  the fuse. Preserve files and checkpoint for the coordinator's existing
@@ -21,21 +21,46 @@ nothing to put in them: `written_paths` (only your own `owned_paths`),
21
21
  unresolved harness problems). A missing array is not an empty one — the gate
22
22
  that reads this checkpoint refuses it either way.
23
23
 
24
- Facts are short statements with source references.
25
- The normative JSON shape of one facts entry is:
24
+ Facts are short statements with source references, and each one says how it was
25
+ established. The normative JSON shape of one facts entry is:
26
26
  ```json
27
- {"fact":"The route creates an order.","source_refs":["app/orders.rb:12"]}
27
+ {"fact":"The route creates an order.","source_refs":["app/orders.rb:12"],"established_by":"read"}
28
28
  ```
29
- Every facts entry is an object in that shape, never a string. Never embed source
30
- files, suite copies, or transcript.
29
+ Every facts entry is an object in that shape, never a string. `established_by` is
30
+ `read` when the references are what establishes it, or `ran: <command>` when
31
+ something was executed and its result observed. You run nothing, so every fact
32
+ you add yourself is `read`; a `ran:` fact is one the coordinator established
33
+ before fan-out, and that is exactly what makes it worth more than a fact anybody
34
+ read. Never embed source files, suite copies, or transcript.
35
+
36
+ On the behavioral branch your checkpoint also carries `surface_coverage`: one
37
+ entry per Scenario you write, recorded as you write it.
38
+ ```json
39
+ {"capability_id":"<one of your plan item's ids>","scenario":"<exact Scenario name>","surfaces":["POST /orders"]}
40
+ ```
41
+ `surfaces` names the addresses and jobs the Scenario's `When` really reaches — not
42
+ the ones its capability was assigned, and not the ones you meant to reach. Only
43
+ you can know this: the coordinator publishes this join and never reopens your step
44
+ files. On a2time, 2026-08-17, it had to reconstruct the join from what the workers
45
+ said about their work; the independent reviewer read the steps instead, six
46
+ Scenarios claimed addresses their steps never drove, and the server refused the
47
+ publication.
31
48
 
32
49
  Write first, then find out. Start with the planned cases your seeded facts
33
50
  already support and get them onto disk; go reading only for what you still lack
34
51
  after that. The opposite order — survey the sources, then write — is what spent
35
52
  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.
53
+ file at all.
54
+
55
+ A fact already in your checkpoint is settled: do not establish it a second time.
56
+ A `read` fact is settled the same way — until a file you had to open anyway says
57
+ otherwise. Then check that one fact against its own `source_refs`, which is two
58
+ or three lines and not a fresh survey; if it is wrong, correct the entry and say
59
+ so in `known_problems`. On a2time, 2026-08-17, a seeded fact said a dismissed
60
+ employee cannot sign in — one method read, another remembered — and sixteen
61
+ workers got it as verified. One of them looked, disagreed, and kept its scenario
62
+ honest, which is the only reason that access hole came back red instead of green.
63
+ Nothing mechanical enforces any of this; it holds because you keep it.
39
64
 
40
65
  Read only the `source_paths` and dependencies your finite planned cases need.
41
66
  Ask closed questions with the files to look in. For a closed missing fact, use