unitbob 0.3.2 → 0.3.4

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/README.md CHANGED
@@ -55,6 +55,22 @@ they work only in a terminal session started after the plugin was installed —
55
55
  a browser or desktop window they are not recognised at all. The phrasings above
56
56
  work everywhere, so they are the ones documented here.
57
57
 
58
+ ### If the assistant says it cannot find the Unitbob instructions
59
+
60
+ A session that started *before* the plugin was installed does not pick up the
61
+ skill, so the assistant has nothing to follow. Restarting the session is the
62
+ clean fix. If that is inconvenient, the instructions are ordinary files on disk
63
+ and the assistant can read them directly — tell it:
64
+
65
+ ```
66
+ Read ~/.claude/plugins/cache/unitbob/unitbob/<version>/skills/unitbob/SKILL.md
67
+ and follow the workflow it names for this job.
68
+ ```
69
+
70
+ `<version>` is whatever `claude plugin list` reports (for example `0.3.2`). The
71
+ workflow files sit next to it under `workflows/`, one per job, and each is
72
+ self-contained — that is what they are designed for.
73
+
58
74
  ---
59
75
 
60
76
  ## How to read it
package/dist/cli.js CHANGED
@@ -14,6 +14,7 @@ import { ensureLinked } from "./link.js";
14
14
  import { recipe } from "./verbs/recipe.js";
15
15
  import { show } from "./verbs/show.js";
16
16
  import { run, runOnly } from "./verbs/run.js";
17
+ import { runLocal } from "./verbs/runLocal.js";
17
18
  import { init } from "./verbs/init.js";
18
19
  import { mapPrepare } from "./verbs/mapPrepare.js";
19
20
  import { extractSurfaces } from "./verbs/extractSurfaces.js";
@@ -48,6 +49,8 @@ Verbs:
48
49
  uploading. Reports every problem at once; put-suite-build runs it too.
49
50
  put-suite-build Internal: upload the host-built guardrail suite (whole spec file + test_metadata),
50
51
  then run every branch it published and report the server's results.
52
+ run-local [branch] Internal: run the suite you just wrote, before publishing it, with the same runner
53
+ that will run it afterwards. No argument runs every branch the build asked for.
51
54
  fix-prepare <id> Internal: fetch the per-capability repair packet for one red guard (by interface_id).
52
55
  contract-prompt <digest> <test_id> [fix|accept]
53
56
  Internal: fetch the fix/accept brief for one red check on either map.
@@ -113,6 +116,9 @@ export async function main(argv, deps = { ensureLinked }) {
113
116
  case 'contract-prompt':
114
117
  await contractPrompt(await linked(), args);
115
118
  return 0;
119
+ case 'run-local':
120
+ await runLocal(await linked(), args);
121
+ return 0;
116
122
  case 'run':
117
123
  case 'check':
118
124
  await run(await linked(), args);
@@ -403,7 +403,7 @@ export function inventoryProblems(inventory, surfaces) {
403
403
  // either printed by the router or copied out of the graph. A blank is not a gap
404
404
  // to be helpfully filled — an invented `source_file` is the same failure as an
405
405
  // invented address, one field over, and a `handler_symbol` swapped for another
406
- // node that happens to exist passes the host's check while sending spec 35's
406
+ // node that happens to exist passes the host's check while sending spec 36's
407
407
  // trace into the wrong code.
408
408
  const LINK_FIELDS = ['source_file', 'handler_symbol'];
409
409
  // Not a link and nothing downstream reads it — but spec 32-7 took the authoring
@@ -46,11 +46,12 @@ export async function putSuiteBuild(config, _args = [], deps) {
46
46
  // batch. That also bounds what a false positive in a local check can cost —
47
47
  // one branch, with the peer still going up and the server still the authority.
48
48
  const problemsFor = new Map();
49
- for (const problem of collectBuildProblems(request, outputs)) {
49
+ for (const problem of collectBuildProblems(request, outputs, unreadable)) {
50
50
  problemsFor.set(problem.branch, [...(problemsFor.get(problem.branch) ?? []), problem.message]);
51
51
  }
52
52
  for (const output of outputs) {
53
53
  const failed = problemsFor.get(output.suite_kind);
54
+ problemsFor.delete(output.suite_kind);
54
55
  if (failed) {
55
56
  blocked.push({ suite_kind: output.suite_kind, status: BLOCKED_STATUS, error: formatBranchProblems(failed) });
56
57
  continue;
@@ -80,6 +81,14 @@ export async function putSuiteBuild(config, _args = [], deps) {
80
81
  },
81
82
  });
82
83
  }
84
+ // What is left in `problemsFor` belongs to a branch the loop above never
85
+ // reached, because the answer has no entry for it at all. It has nothing to
86
+ // upload and nothing to roll back, so it costs its peer nothing — but it is
87
+ // exactly the branch that used to leave no trace anywhere, and the one line it
88
+ // prints here is the whole point of noticing it (spec 32-6, a2time 2026-08-04).
89
+ for (const [suiteKind, messages] of problemsFor) {
90
+ blocked.push({ suite_kind: suiteKind, status: BLOCKED_STATUS, error: formatBranchProblems(messages) });
91
+ }
83
92
  // Every branch is blocked, so there is nothing to upload. Asking the server to
84
93
  // publish an empty batch would turn a local, already-explained problem into a
85
94
  // wire error with a worse message.
package/dist/verbs/run.js CHANGED
@@ -118,7 +118,10 @@ async function buildRunPayload(config, d, item) {
118
118
  }
119
119
  return { suite_digest: item.suite_digest, run_result: report };
120
120
  }
121
- function runStructuralByRunner(projectRoot, runner, suitePath) {
121
+ // Exported for `run-local`, which runs these same strategies against the files
122
+ // the host just wrote rather than against a published suite. One dispatch table,
123
+ // so the command the loop iterates on is the command that runs after publishing.
124
+ export function runStructuralByRunner(projectRoot, runner, suitePath) {
122
125
  switch (runner) {
123
126
  case 'rspec':
124
127
  return runRspecSuite(projectRoot, suitePath);
@@ -0,0 +1,129 @@
1
+ import { branchRunner, readHostSuiteOutputsPerBranch, readSuiteBuildRequest, } from "../files/suiteBuild.js";
2
+ import { validateStack } from "../runner/precheck.js";
3
+ import { runBddSuite } from "../runner/bdd.js";
4
+ import { runStructuralByRunner } from "./run.js";
5
+ const OUTPUT_TAIL_CHARS = 4000;
6
+ export async function runLocal(config, args = [], deps) {
7
+ const d = {
8
+ runStructural: runStructuralByRunner,
9
+ runBehavioral: runBddSuite,
10
+ validateStack,
11
+ stdout: process.stdout,
12
+ ...deps,
13
+ };
14
+ const request = readSuiteBuildRequest(config.projectRoot);
15
+ const { outputs, unreadable } = readHostSuiteOutputsPerBranch(request.output_path, request);
16
+ const wanted = selectBranches(request, args);
17
+ for (const suiteKind of wanted) {
18
+ d.stdout.write(`\n── ${suiteKind} ──\n`);
19
+ // An entry that exists but will not parse is a different problem from an
20
+ // entry that is not there, and it is the one worth catching early: the file
21
+ // it names is usually missing from disk, which the runner would otherwise
22
+ // discover as a confusing "no tests" halfway through the loop.
23
+ const broken = unreadable.find((entry) => entry.suite_kind === suiteKind);
24
+ if (broken) {
25
+ d.stdout.write(`Cannot run this branch — its entry in your answer could not be read: ${broken.message}\n`);
26
+ continue;
27
+ }
28
+ await runOneBranch(config, d, suiteKind, outputs.find((entry) => entry.suite_kind === suiteKind));
29
+ }
30
+ }
31
+ // Which branches to run. No argument runs every branch the request asked for —
32
+ // the same "one suite, one run" shape both recipes insist on, so the default
33
+ // never teaches the habit the recipes forbid. A named branch is for the repair
34
+ // loop, where re-running the finished peer is pure cost.
35
+ function selectBranches(request, args) {
36
+ const all = request.branches.map((branch) => branch.suite_kind);
37
+ const named = args.filter((arg) => !arg.startsWith('-'));
38
+ if (named.length === 0)
39
+ return all;
40
+ const unknown = named.filter((name) => !all.includes(name));
41
+ if (unknown.length > 0) {
42
+ throw new Error(`This suite build has no branch called ${unknown.join(', ')}. It asked for: ${all.join(', ')}.`);
43
+ }
44
+ return named;
45
+ }
46
+ async function runOneBranch(config, d, suiteKind, output) {
47
+ // Nothing written for this branch yet. That is the ordinary state halfway
48
+ // through a build, not an error — say what is missing and move to the peer.
49
+ if (!output) {
50
+ d.stdout.write(`Nothing to run: your answer has no entry for this branch yet. Write its suite under ` +
51
+ `${branchRoot(config, suiteKind)} and add its entry to the answer, then run this again.\n`);
52
+ return;
53
+ }
54
+ if (output.build_error) {
55
+ d.stdout.write(`Not built, by your own answer: ${output.build_error.message}\n`);
56
+ return;
57
+ }
58
+ let runner;
59
+ let suitePath;
60
+ try {
61
+ runner = branchRunner(output);
62
+ suitePath = mainPathOf(output);
63
+ }
64
+ catch (err) {
65
+ d.stdout.write(`Cannot run this branch: ${err.message}\n`);
66
+ return;
67
+ }
68
+ const check = d.validateStack(config.projectRoot, runner);
69
+ if (!check.ok) {
70
+ d.stdout.write(`Cannot run this branch: ${check.message ?? `this project does not match "${runner}".`}\n`);
71
+ return;
72
+ }
73
+ let result;
74
+ try {
75
+ result =
76
+ suiteKind === 'behavioral'
77
+ ? await d.runBehavioral(config.projectRoot, runner, suitePath)
78
+ : await d.runStructural(config.projectRoot, runner, suitePath);
79
+ }
80
+ catch (err) {
81
+ d.stdout.write(`The runner could not start: ${err.message}\n`);
82
+ return;
83
+ }
84
+ d.stdout.write(report(result));
85
+ }
86
+ // The command first, and always — including on a green run. It is the answer to
87
+ // "how do I run just this one file again", which is the question the whole
88
+ // iteration loop is made of, and printing it only on failure would hide it at
89
+ // exactly the moment someone starts trusting the loop.
90
+ function report(result) {
91
+ const lines = [
92
+ `ran: ${[result.command, ...result.args].join(' ')}`,
93
+ `exit code: ${result.code}`,
94
+ ];
95
+ if (result.report) {
96
+ lines.push(`machine-readable report: ${result.resultPath}` +
97
+ ' — the whole run in one file, if the console output is too long to read.');
98
+ }
99
+ else {
100
+ lines.push(`no report at ${result.resultPath} — the run produced none, which usually means it died before` +
101
+ ' the first test rather than that the tests failed.');
102
+ }
103
+ const tail = outputTail(result);
104
+ if (tail)
105
+ lines.push('', tail);
106
+ return `${lines.join('\n')}\n`;
107
+ }
108
+ function outputTail(result) {
109
+ const bits = [];
110
+ if (result.stderr.trim())
111
+ bits.push(result.stderr.trim());
112
+ if (result.stdout.trim())
113
+ bits.push(result.stdout.trim());
114
+ const joined = bits.join('\n');
115
+ return joined.length > OUTPUT_TAIL_CHARS ? joined.slice(-OUTPUT_TAIL_CHARS) : joined;
116
+ }
117
+ // The suite blob's own project-relative path, exactly as the runners expect it.
118
+ function mainPathOf(output) {
119
+ const file = output.suite_file;
120
+ const path = file?.path;
121
+ if (typeof path !== 'string' || !path) {
122
+ throw new Error('this branch names no suite file to run.');
123
+ }
124
+ return path;
125
+ }
126
+ function branchRoot(config, suiteKind) {
127
+ const request = readSuiteBuildRequest(config.projectRoot);
128
+ return request.branches.find((branch) => branch.suite_kind === suiteKind)?.path_root ?? `.unitbob/${suiteKind}/`;
129
+ }
@@ -147,7 +147,9 @@ export async function suitePrepare(config, args = [], deps) {
147
147
  : '`unitbob put-suite-build`';
148
148
  actual.stdout.write(`Suite build request written to ${request.project_root}/.unitbob/suite-build/request.json\n`);
149
149
  actual.stdout.write(`Next: build ${branches.length === 1 ? 'the' : 'both'} peer ${branches.length === 1 ? 'suite' : 'suites'} (${kinds}) following each branch's \`recipe\` and \`assignment\`, ` +
150
- `write your answer to ${request.output_path} as a branches array, run each locally, repair broken harness steps while application failures remain red, ` +
150
+ `write your answer to ${request.output_path} as a branches array one entry per branch named above, and a branch you cannot ` +
151
+ `finish says so in its own entry rather than being left out of the array. Run each locally with \`unitbob run-local\` (the same ` +
152
+ `runner that runs after publishing, so you never have to guess the command), repair broken harness steps while application failures remain red, ` +
151
153
  `then run ${nextCommand}.\n`);
152
154
  // A fixable runner blocker is not a failure: the structural suite still builds this run. Tell the
153
155
  // vibecoder the one command that unblocks the behavioral peer, then re-run suite-prepare.
@@ -4,7 +4,7 @@ import { readHostSuiteOutputsPerBranch, readSuiteBuildRequest, } from "../files/
4
4
  // both sides copy verbatim — so nothing here has to know whether this branch's
5
5
  // ids are called `interface_id` or `capability_id`.
6
6
  const CONTRACT_PREFIX = 'contract:';
7
- export function collectBuildProblems(request, outputs) {
7
+ export function collectBuildProblems(request, outputs, unreadable = []) {
8
8
  const problems = [];
9
9
  const branchFor = new Map(request.branches.map((branch) => [branch.suite_kind, branch]));
10
10
  for (const output of outputs) {
@@ -19,8 +19,51 @@ export function collectBuildProblems(request, outputs) {
19
19
  checkRunnerManifest(branch, output, add);
20
20
  checkAssignment(branch, output, add);
21
21
  }
22
+ problems.push(...unansweredBranches(request, outputs, unreadable));
22
23
  return problems;
23
24
  }
25
+ // The branch that is not there at all. Every check above reads the answer and
26
+ // asks whether it is well-formed; none of them can see a branch the answer never
27
+ // mentions, because there is no entry to walk. So this one walks the request
28
+ // instead — the only list that knows what was asked for.
29
+ //
30
+ // The a2time run of 2026-08-04 is the whole reason. Its behavioral branch was
31
+ // prepared, half-built and abandoned for budget; the answer went up carrying the
32
+ // structural branch alone; this check said "well-formed"; the upload published
33
+ // one branch and said nothing about the other. Nothing anywhere recorded that a
34
+ // second branch had ever been asked for, so the cost of the work already done on
35
+ // it was not merely wasted, it was invisible.
36
+ //
37
+ // ADR 1 names this shape: a pre-check must not be *narrower* than the thing it
38
+ // predicts. The server checks each branch it receives; what it cannot check is a
39
+ // branch nobody sent it. That gap belongs here, where the request is still in
40
+ // hand.
41
+ //
42
+ // `build_error` is the answer for a branch that could not be built, and it is
43
+ // deliberately cheap to give — one line, no suite, never blocks the peer. This
44
+ // does not demand the branch be built. It demands only that its absence be
45
+ // stated rather than left as a silence that reads like success.
46
+ //
47
+ // A branch whose entry existed but could not be parsed is already reported as
48
+ // unreadable by the caller; naming it "missing" too would be two complaints
49
+ // about one mistake, and the second would send the reader looking for a second
50
+ // problem that is not there.
51
+ function unansweredBranches(request, outputs, unreadable) {
52
+ const accounted = new Set([
53
+ ...outputs.map((output) => output.suite_kind),
54
+ ...unreadable.map((entry) => entry.suite_kind),
55
+ ]);
56
+ return request.branches
57
+ .filter((branch) => !accounted.has(branch.suite_kind))
58
+ .map((branch) => ({
59
+ branch: branch.suite_kind,
60
+ message: 'the request asked for this branch and the answer has no entry for it — it is neither built nor ' +
61
+ 'declared unbuildable. Every branch in the request gets one entry: the suite you built, or ' +
62
+ `{ "suite_kind": "${branch.suite_kind}", "build_error": { "message": "why not" } }. ` +
63
+ 'Leaving it out is not the same as declining it: nothing records that this branch was ever asked ' +
64
+ 'for, so the work already spent on it disappears without a trace.',
65
+ }));
66
+ }
24
67
  // After spec 32-5 the envelope comes down from the server inside the request, so
25
68
  // there is nothing here to derive — only to confirm the host copied it. This is
26
69
  // the field most likely to be rejected after all the work is done, which is
@@ -150,11 +193,22 @@ function checkSurfaceCoverage(row, id, expected, suiteText, add) {
150
193
  named.add(scenario);
151
194
  surfaces.filter((s) => typeof s === 'string').forEach((s) => reached.add(s));
152
195
  }
153
- const missed = expected.surfaces.filter((surface) => !reached.has(surface));
196
+ // Spec 34, decision 15: an address the suite genuinely cannot drive — a
197
+ // third-party OAuth callback, a vendor webhook — is declared rather than
198
+ // faked, and satisfies coverage without being claimed as reached. Mirrored
199
+ // here in the server's own shape; refusing it locally would refuse an answer
200
+ // the server takes, which is the one direction of drift that costs a branch.
201
+ const declaredUnreachable = collectUnreachable(row, id, reached, add);
202
+ const missed = expected.surfaces.filter((surface) => !reached.has(surface) && !declaredUnreachable.has(surface));
154
203
  if (missed.length > 0) {
155
- add(`${id} surface_coverage accounts for no scenario at ${missed.join(', ')}.`);
204
+ add(`${id} surface_coverage accounts for no scenario at ${missed.join(', ')}` +
205
+ ' — drive it, or declare it unreachable with a business reason.');
156
206
  }
157
- const foreign = [...reached].filter((surface) => !expected.surfaces.includes(surface));
207
+ // No check here for "declares everything unreachable and drives nothing": the
208
+ // caller already returned when the capability's marker appears in no suite
209
+ // file, so a capability with no Scenario never reaches this function at all.
210
+ // The server refuses that state for the same reason, one rule earlier.
211
+ const foreign = [...reached, ...declaredUnreachable].filter((surface) => !expected.surfaces.includes(surface));
158
212
  if (foreign.length > 0) {
159
213
  add(`${id} surface_coverage names ${foreign.join(', ')}, which this branch's assignment does not carry.`);
160
214
  }
@@ -173,6 +227,41 @@ function checkSurfaceCoverage(row, id, expected, suiteText, add) {
173
227
  add(`${id} surface_coverage does not account for ${unlisted.map((n) => `"${n}"`).join(', ')}.`);
174
228
  }
175
229
  }
230
+ // The addresses this capability says it cannot drive, each with its own reason.
231
+ // A blanket reason covering a list is exactly the boilerplate the rule exists to
232
+ // stop, so the reason is per address and its absence is the whole complaint.
233
+ function collectUnreachable(row, id, reached, add) {
234
+ const declared = row.unreachable_surfaces;
235
+ if (declared === undefined)
236
+ return new Set();
237
+ if (!Array.isArray(declared) || declared.length === 0) {
238
+ add(`${id} unreachable_surfaces must be a non-empty array of {surface, reason} when it is present.`);
239
+ return new Set();
240
+ }
241
+ const surfaces = new Set();
242
+ for (const [index, item] of declared.entries()) {
243
+ const entry = (item ?? {});
244
+ const surface = String(entry.surface ?? '').trim();
245
+ if (!surface) {
246
+ add(`${id} unreachable_surfaces[${index}] names no surface.`);
247
+ continue;
248
+ }
249
+ if (!String(entry.reason ?? '').trim()) {
250
+ add(`${id} declares ${surface} unreachable but gives no business reason for it.`);
251
+ continue;
252
+ }
253
+ if (reached.has(surface)) {
254
+ add(`${id} both drives ${surface} in a scenario and declares it unreachable — it is one or the other.`);
255
+ continue;
256
+ }
257
+ if (surfaces.has(surface)) {
258
+ add(`${id} declares ${surface} unreachable more than once.`);
259
+ continue;
260
+ }
261
+ surfaces.add(surface);
262
+ }
263
+ return surfaces;
264
+ }
176
265
  // Scenario names carrying one marker, by shape rather than by grammar. See the
177
266
  // caller for why this stays deliberately timid.
178
267
  function scenarioNamesTagged(suiteText, marker) {
@@ -301,7 +390,7 @@ export function validateBuildProblems(config) {
301
390
  const { outputs, unreadable } = readHostSuiteOutputsPerBranch(request.output_path, request);
302
391
  return [
303
392
  ...unreadable.map((entry) => ({ branch: entry.suite_kind, message: entry.message })),
304
- ...collectBuildProblems(request, outputs),
393
+ ...collectBuildProblems(request, outputs, unreadable),
305
394
  ];
306
395
  }
307
396
  // One branch's problems, for the line that reports it unpublished alongside its
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unitbob",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
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": {