@bluewombat/slots 0.9.0 → 0.10.0

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
@@ -53,23 +53,32 @@ these all use it. How Host wires a slot into a run:
53
53
 
54
54
  ## Catalogue
55
55
 
56
- | Slot | Kind | Answers |
57
- | ----------------------------- | --------- | ------------------------------------------------------------------------------------------ |
58
- | `agents/cursor.mjs` | Agent | Runs Cursor CLI with a filled prompt |
59
- | `agents/claude.mjs` | Agent | Runs Claude Code with a filled prompt |
60
- | `builders/producer.mjs` | Builder | First pass of the unit of work: fill `{{task}}`, then spawn the agent |
61
- | `builders/repair.mjs` | Builder | A Gate refused the unit: fill `{{task}}` and `{{report}}` |
62
- | `assembly/fix.mjs` | Builder | A judgement refused the assembled work: fill `{{task}}` and `{{report}}` |
63
- | `assembly/validate.mjs` | Builder | Read-only review of the assembled work: fill `{{task}}`; answers `MASON_VERDICT: VALIDATED` or `MASON_VERDICT: REFUSED: …` |
64
- | `gates/workspace-changed.mjs` | Gate | The Attempt left an uncommitted change |
65
- | `gates/parent-clean.mjs` | Gate | Isolator's Parent working files did not move |
66
- | `gates/sensitive-path.mjs` | Gate | No path matching a glob was touched |
67
- | `gates/gitignore-leak.mjs` | Gate | The ticket's commits add nothing its own `.gitignore` excludes |
68
- | `gates/ci-green.mjs` | Gate | The work line's own checks came back green |
69
- | `publishers/git.mjs` | Publisher | Pushes the ticket's branch to the work line's own remote |
70
- | `refreshers/git.mjs` | Refresher | Fast-forwards the work line copy onto what the Authority holds |
71
- | `messages/conventional.mjs` | Message | `<type>: <title>` for the ticket's fold, no agent; `--scope`, `--type` |
72
- | `messages/git-producer.mjs` | Describer | An agent reads the git diff and writes the ticket's subject and body under `--rules-file` |
56
+ | Slot | Kind | Answers |
57
+ | ------------------------------- | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
58
+ | `agents/cursor.mjs` | Agent | Runs Cursor CLI with a filled prompt |
59
+ | `agents/claude.mjs` | Agent | Runs Claude Code with a filled prompt |
60
+ | `builders/tests.mjs` | Builder | The tests of the task, written before its code: one test per case of its "Tests" section (or per criterion when it has none), stubs for a missing interface, each test proved passable by a throwaway implementation in the stubs, then left failing. |
61
+ | `builders/e2e.mjs` | Builder | The Ticket that holds a Feature's e2e acceptance suite: one test per scenario of the task, named after its id, and the manifest `e2e/scenarios.json`; nothing outside the suite folder, the suite never run. One command for the first pass and a refused one |
62
+ | `builders/producer.mjs` | Builder | First pass of the unit of work: fill `{{task}}`, then spawn the agent |
63
+ | `builders/repair.mjs` | Builder | A Gate refused the unit: fill `{{task}}` and `{{report}}` |
64
+ | `ticket/fix.mjs` | Builder | A judgement refused the assembled ticket: fill `{{task}}` and `{{report}}` |
65
+ | `feature/validate.mjs` | Builder | Read-only review of the whole feature against its request: fill `{{task}}`; same verdict line as `ticket/validate.mjs` |
66
+ | `feature/fix.mjs` | Builder | A judgement refused the whole feature: fill `{{task}}` and `{{report}}` |
67
+ | `ticket/validate.mjs` | Builder | Read-only review of the assembled work: fill `{{task}}`; answers `MASON_VERDICT: VALIDATED` or `MASON_VERDICT: REFUSED: …` |
68
+ | `gates/workspace-changed.mjs` | Gate | The Attempt left an uncommitted change |
69
+ | `gates/parent-clean.mjs` | Gate | Isolator's Parent working files did not move |
70
+ | `gates/sensitive-path.mjs` | Gate | No path matching a glob was touched |
71
+ | `gates/workspace-unchanged.mjs` | Gate | A read-only pass changed nothing at all (a file modified, deleted or added) |
72
+ | `gates/gitignore-leak.mjs` | Gate | The ticket's commits add nothing its own `.gitignore` excludes |
73
+ | `gates/project-checks.mjs` | Gate | One command of the Project's own (typecheck, tests, lint, build) exits 0 in the workspace |
74
+ | `gates/tests-red.mjs` | Gate | The tests just written fail, and not on a missing import or a syntax error (`--cmd` with `{files}`, `--test-glob`, `--not-red-when`). |
75
+ | `gates/tests-frozen.mjs` | Gate | A test file held by the "Tests: " checkpoint was not modified, deleted or renamed (`--test-glob`, `--subject-prefix`) |
76
+ | `gates/e2e.mjs` | Gate | The Feature's acceptance suite, run once on the assembled line: the Project's `--up` / `--ready` / `--run` / `--down`, a JUnit report compared with the manifest of scenario ids, each failing test retried once. |
77
+ | `gates/ci-green.mjs` | Gate | The work line's own checks came back green |
78
+ | `publishers/git.mjs` | Publisher | Pushes the ticket's branch to the work line's own remote |
79
+ | `refreshers/git.mjs` | Refresher | Fast-forwards the work line copy onto what the Authority holds |
80
+ | `messages/conventional.mjs` | Message | `<type>: <title>` for the ticket's fold, no agent; `--scope`, `--type` |
81
+ | `messages/git-producer.mjs` | Describer | An agent reads the git diff and writes the ticket's subject and body under `--rules-file` |
73
82
 
74
83
  ## Agents
75
84
 
@@ -100,26 +109,26 @@ lowers a risk and confines nothing (RESERVATIONS F32). `claude.mjs`
100
109
  needs `claude`, signed in once, and refuses those two flags: Claude is not
101
110
  isolated yet, and starting it would keep the operator's own config.
102
111
 
103
- | Option | Meaning |
104
- | ---------------------------------- | -------------------------------------------------------------------------------- |
105
- | `--prompt TEXT` | The filled prompt. Mutually exclusive with `--prompt-file` |
106
- | `--prompt-file PATH` | The same text, from a file. No interpolation |
107
- | `--bin PATH` | The CLI to run. Default: the vendor's own name, on PATH |
108
- | `--model NAME` | Model for that run |
109
- | `--output-format FMT` | `stream-json` (default), `json`, or `text`. Skill extraction needs `stream-json` |
110
- | `--permission-mode MODE` | `claude.mjs` only. Default `bypassPermissions` |
111
- | `--transcript-dir PATH` | Write one file per turn under here. Off unless given |
112
- | `--transcript-part NAME` | `prompt`, `stdout`, `stderr`, `timing`. Repeatable. Default: all |
113
- | `--agent-arg VALUE` | Appended to the CLI argv, before the prompt. Repeatable |
114
- | `--agent-home DIR` | Isolate the vendor process: its `HOME` is this directory. Cursor only; Claude refuses |
115
- | `--api-key-env NAME` | With `--agent-home`, read `process.env[NAME]` for the vendor key. Never the value itself |
116
- | `--pass-env NAME` | With `--agent-home`, also hand the vendor this environment variable. Repeatable |
117
- | `--deny-shell NAME` | With `--agent-home`, also refuse this command to the vendor (`gh`, `vercel`…). `ssh` and `scp` always are. Repeatable |
118
- | `--plugin-dir DIR` | A plugin folder of the operator's (a Mason home's `ai-plugins/core/*`). Repeatable, loaded first |
119
- | `--profile NAME` | The project's deck profile for this role, read in the working directory. Cursor only; Claude refuses |
120
- | `--plugins-out DIR` | Where `--profile` is built, one folder per profile. Required with `--profile`; never the workspace |
121
- | `--deck-bin PATH` | The deck CLI. Default: the `@pinkpuma/deck` this package depends on |
122
- | `--id` / `--attempt` / `--context` | How a transcript is filed. Forwarded by the role |
112
+ | Option | Meaning |
113
+ | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
114
+ | `--prompt TEXT` | The filled prompt. Mutually exclusive with `--prompt-file` |
115
+ | `--prompt-file PATH` | The same text, from a file. No interpolation |
116
+ | `--bin PATH` | The CLI to run. Default: the vendor's own name, on PATH |
117
+ | `--model NAME` | Model for that run |
118
+ | `--output-format FMT` | `stream-json` (default), `json`, or `text`. Skill extraction needs `stream-json` |
119
+ | `--permission-mode MODE` | `claude.mjs` only. Default `bypassPermissions` |
120
+ | `--transcript-dir PATH` | Write one file per turn under here. Off unless given |
121
+ | `--transcript-part NAME` | `prompt`, `stdout`, `stderr`, `timing`. Repeatable. Default: all |
122
+ | `--agent-arg VALUE` | Appended to the CLI argv, before the prompt. Repeatable |
123
+ | `--agent-home DIR` | Isolate the vendor process: its `HOME` is this directory. Cursor only; Claude refuses |
124
+ | `--api-key-env NAME` | With `--agent-home`, read `process.env[NAME]` for the vendor key. Never the value itself |
125
+ | `--pass-env NAME` | With `--agent-home`, also hand the vendor this environment variable. Repeatable |
126
+ | `--deny-shell NAME` | With `--agent-home`, also refuse this command to the vendor (`gh`, `vercel`…). `ssh` and `scp` always are. Repeatable |
127
+ | `--plugin-dir DIR` | A plugin folder of the operator's (a Mason home's `ai-plugins/core/*`). Repeatable, loaded first |
128
+ | `--app-deck-profile PATH` | The app's deck profile for this role, as a path in the checkout: `[<app dir>/].ai/profiles/<name>`. Cursor only; Claude refuses |
129
+ | `--plugins-out DIR` | Where the profile is built, one folder per profile. Required with `--app-deck-profile`; never the workspace |
130
+ | `--deck-bin PATH` | The deck CLI. Default: the `@pinkpuma/deck` this package depends on |
131
+ | `--id` / `--attempt` / `--context` | How a transcript is filed. Forwarded by the role |
123
132
 
124
133
  ### Plugins
125
134
 
@@ -127,7 +136,7 @@ An agent starts with plugin folders, each passed to the vendor as
127
136
  `--plugin-dir`: first the `--plugin-dir` folders, in order, then what the
128
137
  project's [deck](https://github.com/tilap/pinkpuma-deck) profile resolves to.
129
138
 
130
- `--profile builder` runs `deck materialize -C <cwd> --profile builder --out
139
+ `--app-deck-profile .ai/profiles/builder` runs `deck materialize -C <app dir> --profile builder --out
131
140
  <plugins-out>/builder --json` before the vendor starts. The working directory
132
141
  is the checkout the agent works on, so a role gets what the project committed
133
142
  at that commit — the base `ai-plugin.json` and `.ai/profiles/builder/`, as their
@@ -137,11 +146,11 @@ no committed lock pins, and the wrapper then refuses before the vendor starts
137
146
  role refuses the same way until the project is fixed; `mason doctor` says so
138
147
  first.
139
148
 
140
- | The project has | The agent gets |
141
- | --------------------------------------- | ------------------------------------ |
142
- | `.ai/profiles/<name>/ai-plugin.json` | the base and that profile |
143
- | only a base `ai-plugin.json` | the base, and a note on stderr |
144
- | no deck file at all | the `--plugin-dir` folders only |
149
+ | The project has | The agent gets |
150
+ | ------------------------------------ | ------------------------------- |
151
+ | `.ai/profiles/<name>/ai-plugin.json` | the base and that profile |
152
+ | only a base `ai-plugin.json` | the base, and a note on stderr |
153
+ | no deck file at all | the `--plugin-dir` folders only |
145
154
 
146
155
  A non-empty `.ai/profiles/<name>/prompt.md` is appended to the filled prompt
147
156
  under `## Project instructions for this role`: what the project wants from that
@@ -156,7 +165,7 @@ deck's cache (`~/.cache/pinkpuma-deck`, or `DECK_CACHE`) has no lock, and
156
165
  `--plugins-out` holds one folder per profile: both rely on a Mason run starting
157
166
  one agent at a time, which it does.
158
167
 
159
- `claude.mjs` takes `--plugin-dir` but refuses `--profile`: not isolated, Claude
168
+ `claude.mjs` takes `--plugin-dir` but refuses `--app-deck-profile`: not isolated, Claude
160
169
  also loads the operator's plugins and the project's `.claude/settings.json`, so
161
170
  a role's profile would not be all it gets.
162
171
 
@@ -164,12 +173,12 @@ The agent CLI prints **one JSON line** — a `SerializedRun` from
164
173
  `@bluewombat/slot-kit` — with the vendor's raw streams plus the extras the
165
174
  wrapper fills when it can:
166
175
 
167
- | Field | Meaning |
168
- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
169
- | `skills` | `null` = unknown (not stream-json, or unparseable). `[]` = looked, none used. Otherwise skill names in order |
170
- | `usage` | `null` = unknown. Otherwise token counts (`input`, `output`, `cacheRead`, `cacheWrite`) and `costUsd` (`null` when the vendor did not report a cost) |
171
- | `plugins` | Absent when no plugin folder was loaded. Otherwise each folder: `id`, `path`, `from` (`local` or `deck`), and `integrity` (deck's, `null` for a local one) |
172
- | `tools` | `null` = unknown (not stream-json, or unparseable). Otherwise what the agent did with its tools: `calls` per tool name, `busyMs` (time with a tool running; `null` when the stream has no times, as Claude's), and the lists `web` (pages fetched, searches, URLs a shell command named), `mcp` (`server/tool`, plus `getMcpTools` when the agent only listed a server's tools), `subagents` (by description), `outside` (paths named outside the working directory, skill files excepted) |
176
+ | Field | Meaning |
177
+ | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
178
+ | `skills` | `null` = unknown (not stream-json, or unparseable). `[]` = looked, none used. Otherwise skill names in order |
179
+ | `usage` | `null` = unknown. Otherwise token counts (`input`, `output`, `cacheRead`, `cacheWrite`) and `costUsd` (`null` when the vendor did not report a cost) |
180
+ | `plugins` | Absent when no plugin folder was loaded. Otherwise each folder: `id`, `path`, `from` (`local` or `deck`), and `integrity` (deck's, `null` for a local one) |
181
+ | `tools` | `null` = unknown (not stream-json, or unparseable). Otherwise what the agent did with its tools: `calls` per tool name, `busyMs` (time with a tool running; `null` when the stream has no times, as Claude's), and the lists `web` (pages fetched, searches, URLs a shell command named), `mcp` (`server/tool`, plus `getMcpTools` when the agent only listed a server's tools), `subagents` (by description), `outside` (paths named outside the working directory, skill files excepted) |
173
182
 
174
183
  Cursor extracts skills from `stream-json` `readToolCall` paths ending in
175
184
  `/skills/<name>/SKILL.md`. Claude extracts them from `tool_use` blocks named
@@ -238,12 +247,14 @@ committed in the Project travels with it.
238
247
  Each role owns a closed set. An unknown name, or a required name the template
239
248
  never places, is `fail-blocking`.
240
249
 
241
- | Role | Required | Also fills |
242
- | ------------------- | ------------------------------------- | ---------------------------------------------------------- |
243
- | `builders/producer` | `{{task}}`, `{{rules}}` | `{{history}}`, `{{id}}`, `{{attempt}}` |
244
- | `builders/repair` | `{{task}}`, `{{report}}`, `{{rules}}` | `{{history}}`, `{{refused_by}}`, `{{id}}`, `{{attempt}}` |
245
- | `assembly/fix` | `{{task}}`, `{{report}}`, `{{rules}}` | `{{history}}`, `{{refused_by}}`, `{{id}}`, `{{attempt}}` |
246
- | `assembly/validate` | `{{task}}`, `{{rules}}` | `{{id}}`, `{{attempt}}` |
250
+ | Role | Required | Also fills |
251
+ | ------------------- | ------------------------------------- | -------------------------------------------------------- |
252
+ | `builders/producer` | `{{task}}`, `{{rules}}` | `{{history}}`, `{{id}}`, `{{attempt}}` |
253
+ | `builders/repair` | `{{task}}`, `{{report}}`, `{{rules}}` | `{{history}}`, `{{refused_by}}`, `{{id}}`, `{{attempt}}` |
254
+ | `ticket/fix` | `{{task}}`, `{{report}}`, `{{rules}}` | `{{history}}`, `{{refused_by}}`, `{{id}}`, `{{attempt}}` |
255
+ | `feature/validate` | `{{task}}`, `{{rules}}` | `{{id}}`, `{{attempt}}` |
256
+ | `feature/fix` | `{{task}}`, `{{report}}`, `{{rules}}` | `{{history}}`, `{{refused_by}}`, `{{id}}`, `{{attempt}}` |
257
+ | `ticket/validate` | `{{task}}`, `{{rules}}` | `{{id}}`, `{{attempt}}` |
247
258
 
248
259
  A first-pass command that receives `--report` is `fail-blocking`: that report
249
260
  belongs to the repair command. A repair or fix command without `--report` is the same.
@@ -260,7 +271,7 @@ given is not enough to act on, it changes nothing and ends its final message wit
260
271
  one line, `MASON_BLOCKED: <what is missing>`. The slot reads the last such line
261
272
  in the agent's final message, and answers `fail-blocking` with that reason, so the
262
273
  Task stops as `escalated` and a person reads it instead of another Attempt
263
- inventing a change. This is the same kind of signal as `assembly/validate`'s
274
+ inventing a change. This is the same kind of signal as `ticket/validate`'s
264
275
  `MASON_VERDICT:` line, and it is not tied to any refuser. A clean exit prints
265
276
  `{ "said": "<the agent's final message, clipped>" }` on stdout, which the Task
266
277
  keeps for its History. A Project's own template should keep a paragraph that
@@ -279,12 +290,12 @@ a run spent on nothing. A command that arrives with a report and no
279
290
  ### The fix prompt
280
291
 
281
292
  A judgement of the assembled work that refused it is not a retry of the unit.
282
- The unit already passed. `assembly/fix.mjs` tells the agent to change what the
293
+ The unit already passed. `ticket/fix.mjs` tells the agent to change what the
283
294
  refusal names and leave the rest. Same placeholders as repair.
284
295
 
285
296
  ### The validate prompt
286
297
 
287
- `assembly/validate.mjs` is a different kind of Builder: it never writes. It
298
+ `ticket/validate.mjs` is a different kind of Builder: it never writes. It
288
299
  fills only `{{task}}` and `{{rules}}` — there is nothing to fix yet, so no
289
300
  `{{report}}` — and asks the agent to end its final message with one line:
290
301
  `MASON_VERDICT: VALIDATED` or `MASON_VERDICT: REFUSED: <one paragraph>`. Its
@@ -384,7 +395,7 @@ builder:
384
395
  `workspace-changed` fail-retries when the workspace has no uncommitted change —
385
396
  the Attempt produced nothing, or committed and hid it. It takes no option. Put
386
397
  it first: every Gate after it then judges work that exists. It passes at
387
- `--stage assembly`, where "nothing changed" is a correct outcome rather than a
398
+ `--stage ticket` (or `feature`), where "nothing changed" is a correct outcome rather than a
388
399
  producer to send back.
389
400
 
390
401
  `parent-clean` fail-blocks if Isolator's Parent working files changed
@@ -396,13 +407,56 @@ the workspace — tracked paths, and new ones `.gitignore` does not exclude —
396
407
  what an install left under an ignored directory (a dependency's own `.github/`)
397
408
  is not a change to guard.
398
409
 
410
+ `project-checks --cmd COMMAND` runs one command of the Project's own in the workspace
411
+ and answers on its exit code. It is the Gate that makes a machine, not the Builder,
412
+ say that the code typechecks and the tests pass. The command goes through the shell,
413
+ so it is written as the Project writes it; it knows no stack.
414
+ The report carries the end of the output, cleaned as it arrives: escape codes removed, a line redrawn with `\r` reduced to its last state, and spinner or progress lines (`[⣷] Pulling schema...`) dropped, so thousands of redraws cannot push the real error out of the tail. `tests-red` reads its output the same way.
415
+
416
+ ```yaml
417
+ - id: typecheck
418
+ timeoutMs: 300000
419
+ argv: [node, ./node_modules/@bluewombat/slots/gates/project-checks.mjs, --cmd, "pnpm typecheck"]
420
+ - id: tests
421
+ timeoutMs: 600000
422
+ argv: [node, ./node_modules/@bluewombat/slots/gates/project-checks.mjs, --cmd, "pnpm test"]
423
+ ```
424
+
425
+ One command per entry: the report then names the check that failed, and the first red
426
+ one stops the sequence before a slower one starts. Put the cheap ones first.
427
+
428
+ - `0` passes.
429
+ - A non-zero exit is `fail-retryable`: the producer gets the end of the output
430
+ (the last ~12 KB, where the error is), not the start.
431
+ - Exit 126 or 127 (the shell cannot find or run the command) is `fail-blocking`: no
432
+ change to the work fixes it.
433
+ - A run that outlives the Gate's ceiling is stopped, with its whole process group, a few
434
+ seconds before the ceiling (`--timeout-ms`, which Implementer passes), and is
435
+ `fail-blocking` too. A hung check and a ceiling that is too short look the same from
436
+ here, so a person decides. Give a test suite a `timeoutMs` of its own.
437
+ - It sets `CI=true` so a watcher does not wait for a terminal.
438
+ - It runs at both stages. In `ticket.gates` it is the local answer on the assembled
439
+ ticket, so a Project with no CI of its own is still judged by a machine, and one with
440
+ CI gets the answer before waiting for it.
441
+
442
+ The workspace needs its dependencies, which `workLine.warm` installs once on the Feature.
443
+
444
+ `workspace-unchanged` fail-blocks when the workspace holds any change: a file modified,
445
+ deleted, or added that `.gitignore` does not exclude. It is for a pass that must not
446
+ write (`ticket.validate`), whose other Gates each look at a part: `sensitive-path` at
447
+ some globs, `parent-clean` at the Parent. It is meant to run where everything is
448
+ committed (after the align), so whatever git shows was written since. `--allow GLOB`,
449
+ repeatable, lets a path through: use it for what an earlier check leaves behind and the
450
+ Project does not ignore (a coverage report). Blocking, not retryable: what the pass
451
+ wrote would be committed by the next align, so a person looks.
452
+
399
453
  `gitignore-leak --base REF` fail-retries if the ticket's commits add a path the
400
454
  workspace's own `.gitignore` excludes — a Builder's `node_modules/`, a build's
401
455
  `dist/`, a generated file. `REF` is the work line the ticket is offered to
402
456
  (`main`): only what the ticket adds since it left that line is judged, and
403
457
  only what is committed, so a path tracked on purpose before the ticket began
404
458
  is not blamed on it, and a removal staged but not committed does not pass. The
405
- report is written for `assembly.fix`: untrack the paths, or change `.gitignore`
459
+ report is written for `ticket.fix`: untrack the paths, or change `.gitignore`
406
460
  if tracking one is the point. It passes at `--stage unit`, where no commit
407
461
  holds a the unit's work yet.
408
462
 
@@ -413,7 +467,7 @@ assembly Gate, with an Authority it judges what was published, so it stops the
413
467
  fold into the work line, not the push.
414
468
 
415
469
  ```yaml
416
- assembly:
470
+ ticket:
417
471
  gates:
418
472
  defaultTimeoutMs: 900000
419
473
  gates:
@@ -456,6 +510,64 @@ without the other.
456
510
  is what Isolator makes when WorkLineStable is a git tree. They fail-block on a
457
511
  workspace Isolator had to copy.
458
512
 
513
+ ### The e2e acceptance suite
514
+
515
+ `e2e.mjs` runs a Feature's acceptance suite on the assembled Feature line, from a Feature Gate
516
+ (`feature.validate.gates`). It knows no stack: the Project supplies four commands, run through
517
+ the shell in the workspace, and the Gate starts, waits, runs, compares and always stops.
518
+
519
+ | Option | Meaning |
520
+ | --- | --- |
521
+ | `--up CMD` | Required. Starts the application. It may return once started or keep running; the Gate does not wait for it, it waits for `--ready`. An `--up` that exits non-zero before that is a start failure |
522
+ | `--ready CMD` | Required. Exit 0 when the application answers. Polled every second until `--start-timeout-ms` |
523
+ | `--run CMD` | Required. Runs the suite and writes a JUnit XML report to `--junit` |
524
+ | `--rerun CMD` | Runs the failed scenarios again; `{ids}` is their ids joined with `\|` (a regular expression, `pnpm e2e --grep "{ids}"`). Absent: `--run` again, whole |
525
+ | `--down CMD` | Required. Stops the application. Always run, whatever happened; the process group of `--up` is killed after it |
526
+ | `--manifest PATH` | The scenarios the suite must hold. Default `e2e/scenarios.json`: an array of ids, or `{ "scenarios": [ "E1" \| { "id": "E1", "title": "…" } ] }` |
527
+ | `--junit PATH` | Where `--run` writes its report. Default `e2e/results/junit.xml`. Removed before every run, so an old report is never read |
528
+ | `--server-log PATH` | The application's log, whose tail goes in a red report. Default: what `--up` printed |
529
+ | `--max-scenarios N` | The most scenarios a suite may hold. Default 5 |
530
+ | `--max-run-ms N` | The most the whole Gate may take. Default 600000 (10 minutes), and never more than the Gate's own ceiling less 25 s |
531
+ | `--start-timeout-ms N` | The most `--up` and `--ready` may take together. Default 120000 |
532
+ | `--host HOST` | Handed to every command as `E2E_HOST`. Default `127.0.0.1`; any non-loopback address is refused |
533
+ | `--port N` | Handed to every command as `E2E_PORT`. Absent: the application's own choice |
534
+
535
+ Every command also gets `E2E_DATA_DIR`, an empty directory made for the run and removed after it
536
+ (the data is disposable), and `E2E_RESULTS_DIR`, the folder of `--junit`. A secret is not an
537
+ option of the Gate: the commands read it from the environment mason was started in, and it
538
+ belongs in no file of the repository.
539
+
540
+ A scenario is a manifest id, and every test carries its id in its name (`E2 pays by card`). The
541
+ comparison is exact: an id with no test, a test with no manifest id, and a test the runner
542
+ skipped are failures. A failing test is run once more: a pass then is reported `UNSTABLE`, and
543
+ is not a refusal; failing twice is red. The verdict:
544
+
545
+ | Verdict | When |
546
+ | --- | --- |
547
+ | `pass` | Every scenario ran and passed. The report says how many and which, and which were unstable |
548
+ | `fail-retryable` | Red: the report has, per scenario, its id, the test, the step (`at`), the message, the trace path when the runner saved one, then the last 40 lines of the server log. Screenshots and traces stay on disk beside the report. The suite is frozen; the application is the producer's to repair. Also a runner that crashed before writing a report |
549
+ | `fail-blocking` | What no change to the application fixes: a missing, empty or oversized manifest, an application that does not start or is never ready, a run or a retry that outlives the ceiling, a suite that exits 0 and writes no report, a non-loopback `--host` |
550
+
551
+ The Project's `.gitignore` excludes the results folder (`e2e/results/`): a Gate that guards the
552
+ suite folder (`sensitive-path e2e/**` in `feature.fix.gates`) would read the report as a change.
553
+
554
+ ```yaml
555
+ - id: e2e
556
+ skipWhen: skip-test-e2e # Host's: not run for a Feature that carries the label
557
+ timeoutMs: 720000
558
+ argv:
559
+ - node
560
+ - ./node_modules/@bluewombat/slots/gates/e2e.mjs
561
+ - --up
562
+ - pnpm e2e:up
563
+ - --ready
564
+ - pnpm e2e:ready
565
+ - --run
566
+ - pnpm e2e
567
+ - --down
568
+ - pnpm e2e:down
569
+ ```
570
+
459
571
  ## Publishers
460
572
 
461
573
  The slot that puts an assembled ticket where the Authority can read it. It runs
@@ -538,8 +650,9 @@ type in JSDoc.
538
650
  ```
539
651
  README.md this file
540
652
  agents/ vendor CLIs: filled prompt in, serialized run out
541
- builders/ first-pass and repair roles for the unit of work
542
- assembly/ the same two roles for the assembled work
653
+ builders/ first-pass and repair roles for the unit of work, and the e2e Ticket's
654
+ ticket/ fix and validate for an assembled ticket
655
+ feature/ fix for a whole feature
543
656
  lib/ what the shipped templates say, shared by the roles
544
657
  gates/ checks on the workspace, and on the work line
545
658
  messages/ what a commit says: a rule, or an agent under the team's guideline
@@ -1,17 +1,19 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import { existsSync, readFileSync, statSync } from "node:fs";
3
3
  import { createRequire } from "node:module";
4
- import { basename, dirname, join, resolve } from "node:path";
4
+ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
5
5
  import { listOption, textOption } from "@bluewombat/slot-kit";
6
6
 
7
7
  // The plugin folders an agent starts with, shared by every vendor wrapper.
8
8
  //
9
9
  // --plugin-dir DIR a folder of the operator's own (the Mason home's
10
10
  // core plugins). Repeatable, loaded first, in order
11
- // --profile NAME the project's deck profile for this role: the base
12
- // and that profile, as the project's locks pin them
11
+ // --app-deck-profile PATH
12
+ // the app's deck profile for this role, as a path in the
13
+ // checkout: `[<app dir>/].ai/profiles/<name>`. The base
14
+ // and that profile, as the app's locks pin them
13
15
  // --plugins-out DIR where the profile is materialized, one folder per
14
- // profile. Required with --profile: never the
16
+ // profile. Required with --app-deck-profile: never the
15
17
  // workspace, whose tree the Gates read
16
18
  // --deck-bin PATH the deck CLI. Default: the @pinkpuma/deck this
17
19
  // package depends on
@@ -23,6 +25,37 @@ import { listOption, textOption } from "@bluewombat/slot-kit";
23
25
 
24
26
  const PROFILE_NAME = /^[A-Za-z0-9_-][A-Za-z0-9._-]*$/;
25
27
 
28
+ /**
29
+ * Where a deck profile is, from the path the config gives: the app folder that
30
+ * holds `.ai/` (empty for the checkout itself) and the profile's name. deck
31
+ * keeps its profiles in `<app>/.ai/profiles/<name>`, so that is the shape; the
32
+ * app folder is what a Project can place elsewhere.
33
+ *
34
+ * The same rule as the config's own reading of the key (`parseAppDeckProfile`
35
+ * in the runtime), written again because a slot depends on neither.
36
+ *
37
+ * @param {string} given
38
+ * @returns {{ ok: true, root: string, name: string } | { ok: false, reason: string }}
39
+ */
40
+ export function parseAppDeckProfile(given) {
41
+ const matched = /^(?:(.*?)\/)?\.ai\/profiles\/([^/]+)\/?$/.exec(given.trim());
42
+ const name = matched?.[2];
43
+ if (matched === null || name === undefined || !PROFILE_NAME.test(name) || name === "current") {
44
+ return {
45
+ ok: false,
46
+ reason: `The --app-deck-profile "${given}" is not a deck profile path: [<app dir>/].ai/profiles/<name>.`,
47
+ };
48
+ }
49
+ const root = matched[1] ?? ".";
50
+ if (isAbsolute(root) || root.split("/").includes("..")) {
51
+ return {
52
+ ok: false,
53
+ reason: `The --app-deck-profile "${given}" must stay inside the checkout.`,
54
+ };
55
+ }
56
+ return { ok: true, root, name };
57
+ }
58
+
26
59
  /**
27
60
  * @typedef {import("@bluewombat/slot-kit").AgentPlugin} AgentPlugin
28
61
  * @typedef {{ ok: true, plugins: AgentPlugin[], prompt: string | undefined, notes: string[] }
@@ -44,29 +77,34 @@ export function loadPlugins(options, cwd) {
44
77
  }
45
78
  plugins.push({ id: pluginId(path), path, from: "local", integrity: null });
46
79
  }
47
- const profile = textOption(options, "profile");
48
- if (profile === undefined) {
80
+ const given = textOption(options, "appDeckProfile");
81
+ if (given === undefined) {
49
82
  return { ok: true, plugins, prompt: undefined, notes: [] };
50
83
  }
51
- if (!PROFILE_NAME.test(profile) || profile === "current") {
52
- return { ok: false, reason: `The --profile "${profile}" is not a deck profile name.` };
84
+ const located = parseAppDeckProfile(given);
85
+ if (!located.ok) {
86
+ return located;
53
87
  }
88
+ const { root, name: profile } = located;
54
89
  const out = textOption(options, "pluginsOut");
55
90
  if (out === undefined) {
56
91
  return {
57
92
  ok: false,
58
- reason: "--profile needs --plugins-out: the profile is built outside the workspace.",
93
+ reason: "--app-deck-profile needs --plugins-out: the profile is built outside the workspace.",
59
94
  };
60
95
  }
61
- const hasBase = existsSync(join(cwd, "ai-plugin.json"));
96
+ // The app is where deck is run: the checkout, or a folder of it that holds
97
+ // its own `.ai/` (an app of a monorepo).
98
+ const appDir = resolve(cwd, root);
99
+ const hasBase = existsSync(join(appDir, "ai-plugin.json"));
62
100
  // deck's own rule: a profile is a folder of .ai/profiles that holds a request.
63
- const hasProfile = existsSync(join(cwd, ".ai", "profiles", profile, "ai-plugin.json"));
101
+ const hasProfile = existsSync(join(appDir, ".ai", "profiles", profile, "ai-plugin.json"));
64
102
  if (!hasBase && !hasProfile) {
65
103
  return {
66
104
  ok: true,
67
105
  plugins,
68
106
  prompt: undefined,
69
- notes: [`The project has no deck setup: no plugin for profile ${profile}.`],
107
+ notes: [`The app has no deck setup: no plugin for profile ${given}.`],
70
108
  };
71
109
  }
72
110
  const deck = deckBin(options);
@@ -77,7 +115,7 @@ export function loadPlugins(options, cwd) {
77
115
  deck.path,
78
116
  "materialize",
79
117
  "-C",
80
- cwd,
118
+ appDir,
81
119
  "--out",
82
120
  resolve(cwd, out, profile),
83
121
  "--json",
@@ -104,7 +142,7 @@ export function loadPlugins(options, cwd) {
104
142
  ok: true,
105
143
  plugins,
106
144
  prompt: profilePrompt(result.prompt),
107
- notes: hasProfile ? [] : [`The project has no deck profile ${profile}: base plugins only.`],
145
+ notes: hasProfile ? [] : [`The app has no deck profile ${given}: base plugins only.`],
108
146
  };
109
147
  }
110
148
 
package/agents/claude.mjs CHANGED
@@ -30,7 +30,7 @@ import { extrasFromClaude } from "./claude-extras.mjs";
30
30
  // --agent-home / --api-key-env recognised, then refused. Claude is not
31
31
  // isolated yet; starting would keep the operator's config
32
32
  // --plugin-dir DIR a plugin folder, repeatable (see agent-plugins.mjs)
33
- // --profile / --plugins-out / --deck-bin recognised, then refused for the
33
+ // --app-deck-profile / --plugins-out / --deck-bin recognised, then refused for the
34
34
  // same reason: Claude would also load the operator's
35
35
  // and the project's own plugins, so a role's profile
36
36
  // would not be what it gets
@@ -64,12 +64,12 @@ if (
64
64
  );
65
65
  }
66
66
  if (
67
- textOption(options, "profile") !== undefined ||
67
+ textOption(options, "appDeckProfile") !== undefined ||
68
68
  textOption(options, "pluginsOut") !== undefined ||
69
69
  textOption(options, "deckBin") !== undefined
70
70
  ) {
71
71
  refuse(
72
- "Claude Code does not isolate the operator home yet, so a deck profile would not be all it loads. Remove --profile; the vendor was not started.",
72
+ "Claude Code does not isolate the operator home yet, so a deck profile would not be all it loads. Remove --app-deck-profile; the vendor was not started.",
73
73
  );
74
74
  }
75
75
 
package/agents/cursor.mjs CHANGED
@@ -36,7 +36,7 @@ import { extrasFromCursor } from "./cursor-extras.mjs";
36
36
  // --deny-shell NAME with --agent-home: also refuse this command to the
37
37
  // vendor (`gh`, `vercel`…). Repeatable. ssh and scp are
38
38
  // always refused
39
- // --plugin-dir / --profile / --plugins-out / --deck-bin plugin folders,
39
+ // --plugin-dir / --app-deck-profile / --plugins-out / --deck-bin plugin folders,
40
40
  // see agent-plugins.mjs
41
41
  // --id / --attempt / --context how a transcript is filed
42
42