unitbob 0.7.8 → 0.7.13

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
@@ -1,134 +1,142 @@
1
1
  # Unitbob
2
2
 
3
- A living map of your app's business parts. Each important seam gets an automatic
4
- test — a "lamp" on the map. Green means fine, red means something broke.
3
+ Unitbob draws a map of what your app does for people signing in, paying,
4
+ sending messages and puts a lamp on each part. Behind every lamp is an
5
+ automatic check. Green means that part still works. Red means something you
6
+ changed broke it.
5
7
 
6
- Works with Ruby on Rails (RSpec), JavaScript/TypeScript (Vitest), and Python
7
- (pytest) projects the guardrail tests are generated in your project's own
8
- language and run with its native test runner.
8
+ You do not write the checks. Your coding assistant — Claude Code or Codex —
9
+ does, and Unitbob keeps them honest. Your code stays on your computer; only
10
+ the checks and their results go to Unitbob.
9
11
 
10
- You work through a coding agent Claude Code or Codex both work. You need:
11
- Node 18+, Python 3.10+.
12
+ Works with apps written in Ruby on Rails, JavaScript/TypeScript and Python.
13
+ You need Node 18 or newer and Python 3.10 or newer installed.
12
14
 
13
15
  ---
14
16
 
15
17
  ## Install (once)
16
18
 
17
- **With a prompt:**
19
+ Tell your assistant:
20
+
18
21
  ```
19
22
  Add the Unitbob plugin marketplace: sergeygershun/unitbob-connector
20
23
  Install the unitbob plugin
21
24
  ```
22
25
 
23
- **Claude Code (in the terminal):**
26
+ Or, if you prefer the terminal — Claude Code:
27
+
24
28
  ```
25
29
  claude plugin marketplace add sergeygershun/unitbob-connector
26
30
  claude plugin install unitbob@unitbob
27
31
  ```
28
32
 
29
- **Codex (in the terminal):**
33
+ Codex:
34
+
30
35
  ```
31
36
  codex plugin marketplace add sergeygershun/unitbob-connector
32
37
  codex plugin add unitbob@unitbob
33
- npx -y unitbob@0.4.4 codex-install
38
+ npx -y unitbob@0.7.13 codex-install
34
39
  ```
35
40
 
36
- Start a new Claude Code or Codex thread so the installed skill and named agents
37
- load. After setup, the phrasings and Unitbob flow below are the same on both
38
- hosts.
41
+ Then start a new chat. A chat opened before the install does not know about
42
+ Unitbob yet. Everything below works the same in Claude Code and Codex.
39
43
 
40
- Codex compatibility: version 0.145.0 accepts the Unitbob custom-agent TOML files,
41
- but its experimental rollout budget is shared by the root and subagents rather
42
- than enforced separately for each named agent. No Codex version is currently
43
- qualified by Unitbob for a native per-agent ceiling. Before the first bounded
44
- role, Unitbob therefore asks whether to continue this invocation without that
45
- mechanical ceiling; approval is never persisted. The definitions keep the native
46
- budget values so a future Codex release can be qualified without introducing a
47
- Unitbob supervisor.
44
+ On Codex, the assistant may ask once whether it can continue without a spending
45
+ limit for its helpers. Answer yes; Codex cannot enforce that limit yet.
48
46
 
49
47
  ---
50
48
 
51
- ## Full cycle
49
+ ## Protect what your app already does
52
50
 
53
- Just type it in the chat. There is nothing to memorise and no command to get right.
51
+ Just say it in the chat. Nothing to memorise, no command to get right.
54
52
 
55
53
  | Step | Say this |
56
54
  |------|----------|
57
- | 1. Build the map | `Build my Unitbob map` |
58
- | 2. Generate the tests | `Generate the guardrail tests` |
59
- | 3. Fix a red lamp | `Fix guardrail <id>` |
60
- | 4. Open the map | `Open my Unitbob map` |
55
+ | 1. Draw the map | `Build my Unitbob map` |
56
+ | 2. Write the checks | `Generate the guardrail tests` |
57
+ | 3. Look at the map | `Open my Unitbob map` |
58
+ | 4. Fix a red lamp | `Fix guardrail <id>` the id is written on the lamp |
61
59
 
62
- Step 2 lights the lamps by itself: generating the tests also runs them and sends
63
- the results. You do not have to ask for a run to see the first result.
60
+ Step 2 also runs the checks, so the lamps light up right away. Later, whenever
61
+ you want to know whether your latest changes broke anything, say `Run the
62
+ checks`.
64
63
 
65
- Say `Run the checks` later, whenever you want the lamps refreshed against your
66
- current code or to finish the job if a generation was interrupted after the
67
- tests were saved but before they ran.
64
+ Your project connects itself to Unitbob the first time. There is nothing to
65
+ set up by hand.
68
66
 
69
- In Claude Code there are also `/unitbob:map`, `/unitbob:suite` and friends, but
70
- they work only in a terminal session started after the plugin was installed — in
71
- a browser or desktop window they are not recognised at all. The phrasings above
72
- work everywhere, so they are the ones documented here.
67
+ ---
68
+
69
+ ## Build something new
70
+
71
+ The steps above protect what your app does today. When you want to add
72
+ something new, Unitbob can protect the new part too — before it exists. Then
73
+ "done" is not a feeling; it is the checks turning green.
74
+
75
+ Do steps 1 and 2 above first. Then, in the chat, in this order:
76
+
77
+ | Step | Say this | What happens |
78
+ |------|----------|--------------|
79
+ | 1. Name it | `I want to add <feature>` | Unitbob writes down what you want and which parts of the map it may touch. You get a link to the feature's page. |
80
+ | 2. Talk it through | `Let's talk through <feature>` | The assistant asks a few numbered questions about how the feature should behave — in plain words, with options and a recommendation. Answer with a letter, a word, or `do as you recommend`. Then it shows you the list of things that will be true when the feature is done and asks: is this it? Say `yes`. |
81
+ | 3. Write the checks | `Write the checks for <feature>` | Each item from that list becomes a check. They are all red now — on purpose, the feature is not built yet. |
82
+ | 4. Build it | `Build <feature>` | Ordinary work in the same chat. Say `Run the checks` at any time to see how many of the feature's checks already pass, and whether anything else broke. |
83
+ | 5. Wrap up | `I'm done with <feature>` | The checks run once more, a second assistant reads them over, and you get the link to the feature's page. |
84
+ | 6. Finish | Press **Finish this feature** on that page | Only you can press it, and only when everything is green. The feature gets its own lamp on the map, already green. |
85
+
86
+ Good to know:
87
+
88
+ - **If the button is grey, the page says why** — a check of the feature still
89
+ fails, the checks have not been read over yet, or something you said must
90
+ keep working is red.
91
+ - **A red lamp you expected is fine.** If in step 2 you said a part would
92
+ change, its row on the feature page is amber, not red, with a button
93
+ `Accept the new behaviour`. Press it and it is green again. A red lamp you
94
+ did not expect is shown too, with the usual `Fix this`.
95
+ - **The checks say exactly what you agreed to in step 2.** Nobody can quietly
96
+ bend them to fit the code. If you change your mind about the feature, say
97
+ `Let's talk through <feature>` again and the checks are rewritten.
98
+ - **Work in your project's main folder.** Unitbob keeps the checks in a folder
99
+ called `.unitbob` next to your code. A copy of the project in another folder
100
+ does not have it.
101
+ - **After Finish, nothing more to do.** From now on the feature's checks run
102
+ with all the others.
73
103
 
74
104
  ### If the assistant says it cannot find the Unitbob instructions
75
105
 
76
- A session that started *before* the plugin was installed does not pick up the
77
- skill, so the assistant has nothing to follow. Restarting the session is the
78
- clean fix. If that is inconvenient, the instructions are ordinary files on disk
79
- and the assistant can read them directly — tell it:
106
+ The chat was started before the plugin was installed. Start a new chat. If you
107
+ would rather not, tell the assistant:
80
108
 
81
109
  ```
82
110
  Read ~/.claude/plugins/cache/unitbob/unitbob/<version>/skills/unitbob/SKILL.md
83
111
  and follow the workflow it names for this job.
84
112
  ```
85
113
 
86
- `<version>` is whatever `claude plugin list` reports (for example `0.3.2`). The
87
- workflow files sit next to it under `workflows/`, one per job, and each is
88
- self-contained — that is what they are designed for.
89
-
90
- ---
91
-
92
- ## How to read it
93
-
94
- - **Green lamp** — the behavior works.
95
- - **Red lamp** — something the structure relied on broke. Copy its `id` and run
96
- step 3.
97
- - The project links itself by folder name — nothing to set up by hand.
114
+ `<version>` is the number that `claude plugin list` shows.
98
115
 
99
116
  ---
100
117
 
101
- ## If your tests only run inside Docker
118
+ ## If your app only runs inside Docker
102
119
 
103
- Some projects keep the code on this machine and everything that runs it — the
104
- interpreter, the packages, the database inside a container. Name that
105
- container in `.unitbob.json` and Unitbob starts the project's own commands in
106
- there:
120
+ Some projects keep the code on your computer but run it inside a Docker
121
+ container. Tell Unitbob the container's name in the file `.unitbob.json` in
122
+ your project folder:
107
123
 
108
124
  ```json
109
125
  { "server": "…", "repo_id": 3, "token": "…",
110
126
  "exec": { "docker": { "container": "myapp-web-1" } } }
111
127
  ```
112
128
 
113
- Nothing else changes. Files are still read and written here, and the path inside
114
- the container is worked out from the container's own mounts, so there is nothing
115
- else to configure. Leave the field out and everything runs on this machine,
116
- exactly as before.
117
-
118
- The project folder has to be **mounted** into the container rather than copied
119
- into the image — which it already is in any setup where you can edit a file and
120
- see the change. If it is not, Unitbob says so and stops before writing anything.
121
-
122
- Known limits of running in a container, all of them deliberate for now:
123
-
124
- - **A run that times out can leave a process alive inside the container.** The
125
- timeout stops the `docker exec`, not necessarily what it started. A report
126
- such a process writes afterwards is never counted as a later run's result.
127
- - **On a Linux host, files the container writes belong to `root`.** Unitbob does
128
- not map users: guessing there breaks images that installed their packages as a
129
- user of their own.
130
- - **Only a container that is already running.** A project whose tests go through
131
- `docker compose run --rm` is not supported yet.
132
- - **Review at a fixed revision is not supported with a container.** It works in
133
- a git worktree under the system's temporary directory, which is outside the
134
- mount. Ordinary review works in the project itself and is unaffected.
129
+ Keep the other three values as they are; Unitbob wrote them. Now the checks
130
+ run inside the container. Without that line everything runs on your computer,
131
+ as before.
132
+
133
+ This works only when the project folder is shared with the container — which
134
+ it already is if editing a file on your computer changes the running app. If
135
+ it is not, Unitbob tells you and stops.
136
+
137
+ A few things Unitbob does not do in a container yet:
138
+
139
+ - The container has to be running already; Unitbob does not start it.
140
+ - On Linux, files written from inside the container belong to `root`.
141
+ - If a check takes too long and is stopped, the container may keep running it
142
+ in the background for a while.
package/dist/cli.js CHANGED
@@ -25,6 +25,13 @@ import { suitePrepare } from "./verbs/suitePrepare.js";
25
25
  import { classifyPublication, putSuiteBuild } from "./verbs/putSuiteBuild.js";
26
26
  import { validateBuild } from "./verbs/validateBuild.js";
27
27
  import { fixPrepare } from "./verbs/fixPrepare.js";
28
+ import { featurePrepare } from "./verbs/featurePrepare.js";
29
+ import { knowledgePrepare } from "./verbs/knowledgePrepare.js";
30
+ import { putKnowledge } from "./verbs/putKnowledge.js";
31
+ import { testsPrepare } from "./verbs/testsPrepare.js";
32
+ import { putTests } from "./verbs/putTests.js";
33
+ import { testsReviewPrepare } from "./verbs/testsReviewPrepare.js";
34
+ import { putFeature } from "./verbs/putFeature.js";
28
35
  import { contractPrompt } from "./verbs/contractPrompt.js";
29
36
  import { suiteReviewPrepare } from "./verbs/suiteReviewPrepare.js";
30
37
  import { acceptWorkerPlan } from "./verbs/acceptWorkerPlan.js";
@@ -62,11 +69,30 @@ Verbs:
62
69
  then run every branch it published and report the server's results. Name a branch
63
70
  to publish that one alone, as soon as it is finished; with no argument both are
64
71
  expected, and one the answer never mentions is reported.
65
- run-local [branch] Internal: run the suite you just wrote, before publishing it, with the same runner
66
- that will run it afterwards. No argument runs every branch the build asked for.
67
- fix-prepare <id> Internal: fetch the per-capability repair packet for one red guard (by interface_id).
68
- contract-prompt <digest> <test_id> [fix|accept]
69
- Internal: fetch the fix/accept brief for one red check on either map.
72
+ run-local [branch] | --feature <id>
73
+ Internal: run the suite you just wrote, before publishing it, with the same runner
74
+ that will run it afterwards. No argument runs every branch the build asked for;
75
+ --feature runs one feature's checks alone, by their tag.
76
+ fix-prepare [<id>] Without an id: every red guard on both maps, with its suite digest and history — the
77
+ list a fix starts from. With an id: the per-capability repair packet for one
78
+ structural guard (spec 26).
79
+ contract-prompt <digest>|feature:<id> <test_id> [fix|accept]
80
+ Internal: fetch the fix/accept brief for one red check on either map — or, with
81
+ feature:<id>, the fix brief for a feature's own failing checks.
82
+ feature-prepare Internal: fetch the recipe and the product capabilities, write the host
83
+ feature-start request — for naming what a change may touch, before it is made.
84
+ put-feature Internal: record the host's feature answer and print the link to its page.
85
+ knowledge-prepare Internal: without an id, list the features that can be talked through; with
86
+ one, fetch the recipe and the feature's packet and write the host request.
87
+ put-knowledge Internal: send the feature's knowledge.md and print the link to its page.
88
+ tests-prepare <id> Internal: fetch the feature's assignment and the recipe, put the suites on disk,
89
+ and write the host request for its checks.
90
+ put-tests <id> Internal: run the feature's checks and save the harness with that run as its
91
+ proof (all red at first); with a review file beside the answer — publishes the review.
92
+ Files the run under the saved version and prints the link to its page.
93
+ tests-review-prepare <id>
94
+ Internal: write the independent reviewer's request for the feature's checks, once
95
+ they all pass; put-tests then publishes the review it writes.
70
96
  check Run every Unitbob contract suite locally and report.
71
97
  run Alias for check.
72
98
 
@@ -138,6 +164,30 @@ export async function main(argv, deps = { ensureLinked }) {
138
164
  case 'contract-prompt':
139
165
  await contractPrompt(await linked(), args);
140
166
  return 0;
167
+ case 'feature-prepare':
168
+ await featurePrepare(await linked(), args);
169
+ return 0;
170
+ case 'put-feature':
171
+ await putFeature(await linked(), args);
172
+ return 0;
173
+ case 'knowledge-prepare':
174
+ await knowledgePrepare(await linked(), args);
175
+ return 0;
176
+ case 'put-knowledge':
177
+ await putKnowledge(await linked(), args);
178
+ return 0;
179
+ case 'tests-prepare':
180
+ await testsPrepare(await linked(), args);
181
+ return 0;
182
+ case 'put-tests':
183
+ // Non-zero when the runner could not start or produced no report:
184
+ // nothing was sent, and the host has to read why (spec 52-3, AC 3.5).
185
+ // And when a review lies beside a run that is not all green (spec
186
+ // 52-4, AC 1.10) — nothing was sent then either.
187
+ return await putTests(await linked(), args);
188
+ case 'tests-review-prepare':
189
+ await testsReviewPrepare(await linked(), args);
190
+ return 0;
141
191
  case 'run-local':
142
192
  // The one verb whose non-zero exit is not an error: a branch that failed
143
193
  // the same set of cases twice in a row (spec 34-6, criterion 3). Red
@@ -1,4 +1,4 @@
1
- import { cpSync, existsSync, lstatSync, mkdirSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
1
+ import { cpSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
2
2
  import { dirname, join } from 'node:path';
3
3
  import { assertUnitbobPath } from "./artifactPath.js";
4
4
  import { BDD_RUN_ARTIFACTS } from "../runner/bdd.js";
@@ -290,14 +290,27 @@ export function materializeBehavioralWorld(projectRoot, runner = 'cucumber') {
290
290
  // behavioral root, after checking every path is safe. Stale suite artifacts are
291
291
  // removed while the separately provisioned runner environment is preserved.
292
292
  // Returns the absolute path of the materialized main file.
293
- export function materializeBehavioral(projectRoot, artifact, runner) {
294
- const files = [artifact, ...(artifact.support_files ?? [])];
293
+ //
294
+ // `extras` are further envelopes written in the same pass (spec 52-3, AC 3.1):
295
+ // the checks of every red feature, beside the main suite. One clearing, then
296
+ // every file of every envelope; a path two envelopes both hold is refused by
297
+ // name before anything is written — the server does not issue such a union,
298
+ // and the disk could not carry it if it did.
299
+ export function materializeBehavioral(projectRoot, artifact, runner, extras = []) {
300
+ const files = [artifact, ...extras].flatMap((envelope) => [envelope, ...(envelope.support_files ?? [])]);
295
301
  for (const file of files)
296
302
  assertUnitbobPath(file.path, BEHAVIORAL_DIR);
297
303
  const world = behavioralWorldFor(runner);
298
304
  if (world && files.some((file) => file.path === world.path)) {
299
305
  throw new Error(`${world.path} is the connector-owned World and cannot be supplied by the host artifact.`);
300
306
  }
307
+ const seen = new Set();
308
+ for (const file of files) {
309
+ if (seen.has(file.path)) {
310
+ throw new Error(`${file.path} is listed twice across the suites to materialize — one path, one file.`);
311
+ }
312
+ seen.add(file.path);
313
+ }
301
314
  const behavioralRoot = join(projectRoot, BEHAVIORAL_DIR);
302
315
  const runnerEntries = RUNNER_ENVIRONMENT_ENTRIES[runner] ?? EMPTY_ENTRIES;
303
316
  mkdirSync(behavioralRoot, { recursive: true });
@@ -317,6 +330,60 @@ export function materializeBehavioral(projectRoot, artifact, runner) {
317
330
  materializeBehavioralWorld(projectRoot, runner);
318
331
  return { mainPath };
319
332
  }
333
+ // The main suite and the checks of every red feature, as one union on disk
334
+ // (spec 52-3, AC 3.1), and the tags the ordinary run leaves out (3.2). The
335
+ // main file is the main suite's when it is built, else the first feature's —
336
+ // a union of checks alone, on a machine where the main suite is not built, is
337
+ // legal. Null when there is nothing at all to write.
338
+ //
339
+ // Before anything is cleared, the feature files on disk are compared with the
340
+ // ones the server holds (spec 52-4, AC 1.8): while a feature is being built
341
+ // the host rewires its steps against the real code, and a union that wrote
342
+ // the saved version over that work would lose it without a word. A difference
343
+ // stops here, disk untouched, with the one command that saves it.
344
+ export function materializeBehavioralUnion(projectRoot, index, runner) {
345
+ const changed = changedFeatureFiles(projectRoot, index.feature_suites);
346
+ if (changed.length > 0)
347
+ throw new FeatureFilesChangedError(changed[0].feature_id, changed[0].title);
348
+ const main = index.suites.find((item) => item.suite_kind === 'behavioral' && item.status === 'ready' && item.suite_file);
349
+ const envelopes = [...(main?.suite_file ? [main.suite_file] : []), ...index.feature_suites.map((item) => item.suite_file)];
350
+ if (envelopes.length === 0)
351
+ return null;
352
+ const [first, ...rest] = envelopes;
353
+ const { mainPath } = materializeBehavioral(projectRoot, first, runner, rest);
354
+ return { mainPath, excludeTags: index.feature_suites.map((item) => item.feature_tag) };
355
+ }
356
+ // The features whose checks on disk differ from the saved ones, with the
357
+ // paths that differ: every file of the feature's envelope that is on disk,
358
+ // compared by content. A file that is not on disk is not a change — the union
359
+ // writes it, as it always has — and a feature with nothing on disk is not
360
+ // listed at all.
361
+ export function changedFeatureFiles(projectRoot, items) {
362
+ return items.flatMap((item) => {
363
+ const paths = [item.suite_file, ...(item.suite_file.support_files ?? [])]
364
+ .filter((file) => {
365
+ const onDisk = join(projectRoot, file.path);
366
+ return existsSync(onDisk) && readFileSync(onDisk, 'utf8') !== file.content;
367
+ })
368
+ .map((file) => file.path);
369
+ return paths.length > 0 ? [{ feature_id: item.feature_id, title: featureTitle(item), paths }] : [];
370
+ });
371
+ }
372
+ // The one sentence of AC 1.8, worded here and printed wherever the union is
373
+ // materialised — `check`, `tests-prepare` — by the shared catch in `cli.ts`.
374
+ export class FeatureFilesChangedError extends Error {
375
+ featureId;
376
+ constructor(featureId, title) {
377
+ super(`The checks for “${title}” changed on disk since they were saved. ` +
378
+ `Run \`npx unitbob put-tests ${featureId}\` to save them, then try again.`);
379
+ this.featureId = featureId;
380
+ }
381
+ }
382
+ // A server from spec 52-3 sends the item without a title; the id still names
383
+ // the feature the command below takes.
384
+ function featureTitle(item) {
385
+ return item.title ?? `feature ${item.feature_id}`;
386
+ }
320
387
  // Everything under the behavioral root that the next materialization will
321
388
  // delete: it wipes every top-level entry outside the runner environment and
322
389
  // writes back only the files the answer listed, so a step file the answer forgot
@@ -327,11 +394,11 @@ export function materializeBehavioral(projectRoot, artifact, runner) {
327
394
  // The whole root is walked, not just the directories the answer happens to use:
328
395
  // the file most likely to be forgotten is the one in a directory the answer
329
396
  // never mentions — `features/support/env.rb` is exactly that shape.
330
- export function filesLostOnMaterialize(projectRoot, artifact, runner) {
397
+ export function filesLostOnMaterialize(projectRoot, artifact, runner, extras = []) {
331
398
  const behavioralRoot = join(projectRoot, BEHAVIORAL_DIR);
332
399
  if (!existsSync(behavioralRoot))
333
400
  return [];
334
- const listed = new Set([artifact.path, ...(artifact.support_files ?? []).map((file) => file.path)]);
401
+ const listed = new Set([artifact, ...extras].flatMap((envelope) => [envelope.path, ...(envelope.support_files ?? []).map((file) => file.path)]));
335
402
  const kept = behavioralKeptByConnector(runner);
336
403
  return readdirSync(behavioralRoot)
337
404
  .filter((entry) => !kept.has(entry))
@@ -0,0 +1,65 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ export function requestPath(projectRoot) {
4
+ return join(projectRoot, '.unitbob', 'feature-start', 'request.json');
5
+ }
6
+ export function outputPath(projectRoot) {
7
+ return join(projectRoot, '.unitbob', 'feature-start', 'feature.json');
8
+ }
9
+ export function writeFeatureStartRequest(projectRoot, recipe, capabilities) {
10
+ const request = {
11
+ project_root: projectRoot,
12
+ recipe,
13
+ capabilities,
14
+ behavioral_suite_path: presentDir(join(projectRoot, '.unitbob', 'behavioral')),
15
+ map_documents_path: presentDir(join(projectRoot, '.unitbob', 'map-build')),
16
+ output_path: outputPath(projectRoot),
17
+ };
18
+ const path = requestPath(projectRoot);
19
+ if (!existsSync(dirname(path)))
20
+ mkdirSync(dirname(path), { recursive: true });
21
+ writeFileSync(path, `${JSON.stringify(request, null, 2)}\n`);
22
+ return request;
23
+ }
24
+ // The host's answer in the wire shape, and nothing else: a field it added is
25
+ // dropped here rather than sent on. Each check names the field it failed, so
26
+ // the host corrects the file instead of guessing what was wrong with it.
27
+ export function readFeatureAnswer(projectRoot) {
28
+ const path = outputPath(projectRoot);
29
+ if (!existsSync(path)) {
30
+ throw new Error(`No feature answer at ${path}. Write it as the recipe describes, then run \`unitbob put-feature\`.`);
31
+ }
32
+ let raw;
33
+ try {
34
+ raw = JSON.parse(readFileSync(path, 'utf8'));
35
+ }
36
+ catch (err) {
37
+ throw new Error(`${path} is not valid JSON: ${err.message}`);
38
+ }
39
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
40
+ throw new Error(`${path} must hold a JSON object with "title", "intent" and "affected".`);
41
+ }
42
+ const body = raw;
43
+ const title = nonEmptyString(body.title, 'title', path);
44
+ const intent = nonEmptyString(body.intent, 'intent', path);
45
+ if (!Array.isArray(body.affected)) {
46
+ throw new Error(`${path}: "affected" must be an array of { id, why } (it may be empty).`);
47
+ }
48
+ const affected = body.affected.map((entry, index) => {
49
+ const item = (typeof entry === 'object' && entry !== null ? entry : {});
50
+ return {
51
+ id: nonEmptyString(item.id, `affected[${index}].id`, path),
52
+ why: typeof item.why === 'string' ? item.why : '',
53
+ };
54
+ });
55
+ return { title, intent, affected };
56
+ }
57
+ function nonEmptyString(value, field, path) {
58
+ if (typeof value !== 'string' || value.trim() === '') {
59
+ throw new Error(`${path}: "${field}" must be a non-empty string.`);
60
+ }
61
+ return value;
62
+ }
63
+ function presentDir(path) {
64
+ return existsSync(path) ? path : null;
65
+ }
@@ -0,0 +1,156 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { resolveSuiteFile } from "./suiteBuild.js";
4
+ // A feature's folder on disk (spec 52-2): `.unitbob/features/<id>/`. This is
5
+ // the first spec that gives a feature a folder; the talk's request and the
6
+ // file it folds into live here, and spec 52-3 puts its own request beside them.
7
+ export function featureDir(projectRoot, featureId) {
8
+ return join(projectRoot, '.unitbob', 'features', String(featureId));
9
+ }
10
+ // The id as the list printed it and the folder is named: a number. A word
11
+ // ("refunds") is the person's, not the server's, and the verb says so.
12
+ export function parseFeatureId(raw, verb) {
13
+ if (raw === undefined)
14
+ throw new Error(`Usage: unitbob ${verb} <feature_id>`);
15
+ if (!/^\d+$/.test(raw))
16
+ throw new Error(`unitbob ${verb}: the feature id must be a number, got "${raw}".`);
17
+ return Number(raw);
18
+ }
19
+ export function knowledgeRequestPath(projectRoot, featureId) {
20
+ return join(featureDir(projectRoot, featureId), 'request.json');
21
+ }
22
+ export function knowledgePath(projectRoot, featureId) {
23
+ return join(featureDir(projectRoot, featureId), 'knowledge.md');
24
+ }
25
+ export function writeKnowledgeRequest(projectRoot, featureId, recipe, packet) {
26
+ const request = {
27
+ project_root: projectRoot,
28
+ recipe,
29
+ feature: packet.feature,
30
+ affected: packet.affected,
31
+ knowledge: packet.knowledge,
32
+ behavioral_suite_path: presentDir(join(projectRoot, '.unitbob', 'behavioral')),
33
+ map_documents_path: presentDir(join(projectRoot, '.unitbob', 'map-build')),
34
+ output_path: knowledgePath(projectRoot, featureId),
35
+ };
36
+ const path = knowledgeRequestPath(projectRoot, featureId);
37
+ if (!existsSync(dirname(path)))
38
+ mkdirSync(dirname(path), { recursive: true });
39
+ writeFileSync(path, `${JSON.stringify(request, null, 2)}\n`);
40
+ return request;
41
+ }
42
+ // The file as text, and nothing else: the server is the one place that checks
43
+ // its shape (spec 52-2, Non-Goals), so the connector only refuses to send
44
+ // nothing.
45
+ export function readKnowledge(projectRoot, featureId) {
46
+ const path = knowledgePath(projectRoot, featureId);
47
+ if (!existsSync(path)) {
48
+ throw new Error(`No knowledge file at ${path}. Write it as the recipe describes, then run \`unitbob put-knowledge\`.`);
49
+ }
50
+ const text = readFileSync(path, 'utf8');
51
+ if (text.trim() === '')
52
+ throw new Error(`${path} is empty. Write it as the recipe describes, then run \`unitbob put-knowledge\`.`);
53
+ return text;
54
+ }
55
+ function presentDir(path) {
56
+ return existsSync(path) ? path : null;
57
+ }
58
+ // --- the checks (spec 52-3) -------------------------------------------------
59
+ export function testsRequestPath(projectRoot, featureId) {
60
+ return join(featureDir(projectRoot, featureId), 'tests-request.json');
61
+ }
62
+ export function testsOutputPath(projectRoot, featureId) {
63
+ return join(featureDir(projectRoot, featureId), 'tests-output.json');
64
+ }
65
+ // Where the checks go, beside the main suite (spec 52-3, AC 3.4): one
66
+ // `.feature` and one step file, both named after the feature so that they can
67
+ // never take a path of the main suite's.
68
+ export function featureFeaturePath(featureId) {
69
+ return `.unitbob/behavioral/features/feature_${featureId}.feature`;
70
+ }
71
+ export function featureStepsPath(featureId, runner) {
72
+ return `.unitbob/behavioral/step_definitions/${stepsFileName(featureId, runner)}`;
73
+ }
74
+ // Named as the runner collects it: pytest only picks up `test_*.py`, and a
75
+ // file named otherwise loads nothing (`bdd.ts` says so at length).
76
+ function stepsFileName(featureId, runner) {
77
+ switch (runner) {
78
+ case 'pytest-bdd': return `test_feature_${featureId}_steps.py`;
79
+ case 'cucumber-js': return `feature_${featureId}_steps.js`;
80
+ default: return `feature_${featureId}_steps.rb`;
81
+ }
82
+ }
83
+ export function writeTestsRequest(projectRoot, featureId, request) {
84
+ const path = testsRequestPath(projectRoot, featureId);
85
+ if (!existsSync(dirname(path)))
86
+ mkdirSync(dirname(path), { recursive: true });
87
+ writeFileSync(path, `${JSON.stringify(request, null, 2)}\n`);
88
+ return request;
89
+ }
90
+ export function readTestsRequest(projectRoot, featureId) {
91
+ const path = testsRequestPath(projectRoot, featureId);
92
+ if (!existsSync(path)) {
93
+ throw new Error(`${path} not found — run \`npx unitbob tests-prepare ${featureId}\` first.`);
94
+ }
95
+ const request = JSON.parse(readFileSync(path, 'utf8'));
96
+ if (!request || typeof request.feature_tag !== 'string' || typeof request.runner !== 'string' ||
97
+ typeof request.feature_path !== 'string' || typeof request.output_path !== 'string') {
98
+ throw new Error(`${path} is malformed — run \`npx unitbob tests-prepare ${featureId}\` again.`);
99
+ }
100
+ return request;
101
+ }
102
+ export function readTestsOutput(projectRoot, featureId) {
103
+ const path = testsOutputPath(projectRoot, featureId);
104
+ if (!existsSync(path)) {
105
+ throw new Error(`${path} not found — write the answer as the recipe describes, then run \`unitbob put-tests\`.`);
106
+ }
107
+ const answer = JSON.parse(readFileSync(path, 'utf8'));
108
+ if (!answer || typeof answer !== 'object')
109
+ throw new Error(`${path} is malformed: expected an object.`);
110
+ const manifest = answer.runner_manifest;
111
+ if (!manifest || typeof manifest !== 'object' || typeof manifest.runner !== 'string') {
112
+ throw new Error(`${path} is missing runner_manifest.`);
113
+ }
114
+ if (!answer.test_metadata || typeof answer.test_metadata !== 'object') {
115
+ throw new Error(`${path} is missing test_metadata.`);
116
+ }
117
+ return {
118
+ suite_file: resolveSuiteFile(answer.suite_file, '.unitbob/behavioral/', path, 'feature', projectRoot),
119
+ runner_manifest: manifest,
120
+ test_metadata: answer.test_metadata,
121
+ };
122
+ }
123
+ // --- the review of the checks (spec 52-4, AC 1.11) ---------------------------
124
+ export function testsReviewRequestPath(projectRoot, featureId) {
125
+ return join(featureDir(projectRoot, featureId), 'tests-review-request.json');
126
+ }
127
+ export function testsReviewOutputPath(projectRoot, featureId) {
128
+ return join(featureDir(projectRoot, featureId), 'tests-review-output.json');
129
+ }
130
+ export function writeTestsReviewRequest(projectRoot, featureId, request) {
131
+ const path = testsReviewRequestPath(projectRoot, featureId);
132
+ if (!existsSync(dirname(path)))
133
+ mkdirSync(dirname(path), { recursive: true });
134
+ writeFileSync(path, `${JSON.stringify(request, null, 2)}\n`);
135
+ return request;
136
+ }
137
+ export function readTestsReviewOutput(projectRoot, featureId) {
138
+ const path = testsReviewOutputPath(projectRoot, featureId);
139
+ if (!existsSync(path))
140
+ return null;
141
+ let review;
142
+ try {
143
+ review = JSON.parse(readFileSync(path, 'utf8'));
144
+ }
145
+ catch (err) {
146
+ throw new Error(`${path} is not valid JSON (${err.message})`);
147
+ }
148
+ const record = review;
149
+ if (!record || typeof record !== 'object' || typeof record.candidate_digest !== 'string') {
150
+ throw new Error(`${path} must carry candidate_digest at the top level, copied from tests-review-request.json.`);
151
+ }
152
+ if (!record.bdd_quality_review || typeof record.bdd_quality_review !== 'object') {
153
+ throw new Error(`${path} must contain a bdd_quality_review object.`);
154
+ }
155
+ return { candidate_digest: record.candidate_digest, bdd_quality_review: record.bdd_quality_review };
156
+ }