@bluewombat/slots 0.10.0 → 0.11.1
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 +152 -63
- package/agents/agent-plugins.mjs +154 -6
- package/agents/claude.mjs +2 -1
- package/agents/cursor.mjs +2 -0
- package/builders/{e2e.mjs → feature-test.mjs} +27 -17
- package/gates/base-diff.mjs +276 -0
- package/gates/ci-green.mjs +83 -3
- package/gates/feature-tests.mjs +280 -0
- package/gates/parent-clean.mjs +46 -8
- package/gates/tests-only.mjs +65 -0
- package/package.json +2 -2
- package/publishers/git.mjs +117 -16
- package/builders/tests.mjs +0 -139
- package/gates/e2e.mjs +0 -538
- package/gates/tests-frozen.mjs +0 -96
- package/gates/tests-red.mjs +0 -227
package/README.md
CHANGED
|
@@ -57,8 +57,7 @@ these all use it. How Host wires a slot into a run:
|
|
|
57
57
|
| ------------------------------- | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
58
58
|
| `agents/cursor.mjs` | Agent | Runs Cursor CLI with a filled prompt |
|
|
59
59
|
| `agents/claude.mjs` | Agent | Runs Claude Code with a filled prompt |
|
|
60
|
-
| `builders/
|
|
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 |
|
|
60
|
+
| `builders/feature-test.mjs` | Builder | The Ticket that holds a Feature's tests: one test per scenario of the task, named after its id, existing tests updated when a scenario changes them, and the manifest `feature-tests/scenarios.json`; tests never run. One command for the first pass and a refused one |
|
|
62
61
|
| `builders/producer.mjs` | Builder | First pass of the unit of work: fill `{{task}}`, then spawn the agent |
|
|
63
62
|
| `builders/repair.mjs` | Builder | A Gate refused the unit: fill `{{task}}` and `{{report}}` |
|
|
64
63
|
| `ticket/fix.mjs` | Builder | A judgement refused the assembled ticket: fill `{{task}}` and `{{report}}` |
|
|
@@ -68,12 +67,12 @@ these all use it. How Host wires a slot into a run:
|
|
|
68
67
|
| `gates/workspace-changed.mjs` | Gate | The Attempt left an uncommitted change |
|
|
69
68
|
| `gates/parent-clean.mjs` | Gate | Isolator's Parent working files did not move |
|
|
70
69
|
| `gates/sensitive-path.mjs` | Gate | No path matching a glob was touched |
|
|
70
|
+
| `gates/base-diff.mjs` | Gate | What the work changed since the line (`--base`): no denied path was touched, or a command holds when a path did |
|
|
71
|
+
| `gates/tests-only.mjs` | Gate | Every path the workspace changed matches a glob: the tests, their helpers and the manifest, nothing else |
|
|
71
72
|
| `gates/workspace-unchanged.mjs` | Gate | A read-only pass changed nothing at all (a file modified, deleted or added) |
|
|
72
73
|
| `gates/gitignore-leak.mjs` | Gate | The ticket's commits add nothing its own `.gitignore` excludes |
|
|
73
74
|
| `gates/project-checks.mjs` | Gate | One command of the Project's own (typecheck, tests, lint, build) exits 0 in the workspace |
|
|
74
|
-
| `gates/tests
|
|
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. |
|
|
75
|
+
| `gates/feature-tests.mjs` | Gate | The Feature's own tests, run once on the assembled line: the Project's `--run` filtered on the ids of the manifest, a JUnit report compared with them. |
|
|
77
76
|
| `gates/ci-green.mjs` | Gate | The work line's own checks came back green |
|
|
78
77
|
| `publishers/git.mjs` | Publisher | Pushes the ticket's branch to the work line's own remote |
|
|
79
78
|
| `refreshers/git.mjs` | Refresher | Fast-forwards the work line copy onto what the Authority holds |
|
|
@@ -105,7 +104,10 @@ others, plus the names given with `--pass-env`), not the operator's whole
|
|
|
105
104
|
shell; git's credential helpers are cleared for it; and `ssh`, `scp` plus the
|
|
106
105
|
commands given with `--deny-shell` are denied in its own `cli-config.json`,
|
|
107
106
|
rewritten every run. That
|
|
108
|
-
lowers a risk and confines nothing
|
|
107
|
+
lowers a risk and confines nothing: the deny list matches command names (`node -e`,
|
|
108
|
+
`env gh` or an absolute path pass), the operator's files stay readable, the login
|
|
109
|
+
keychain is reachable unless `gh` and `security` are denied, and `CURSOR_API_KEY`
|
|
110
|
+
is visible to the agent. Real confinement is issue #36. `claude.mjs`
|
|
109
111
|
needs `claude`, signed in once, and refuses those two flags: Claude is not
|
|
110
112
|
isolated yet, and starting it would keep the operator's own config.
|
|
111
113
|
|
|
@@ -126,7 +128,7 @@ isolated yet, and starting it would keep the operator's own config.
|
|
|
126
128
|
| `--deny-shell NAME` | With `--agent-home`, also refuse this command to the vendor (`gh`, `vercel`…). `ssh` and `scp` always are. Repeatable |
|
|
127
129
|
| `--plugin-dir DIR` | A plugin folder of the operator's (a Mason home's `ai-plugins/core/*`). Repeatable, loaded first |
|
|
128
130
|
| `--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
|
|
131
|
+
| `--plugins-out DIR` | Where the profile is built, one folder per workspace and profile. Required with `--app-deck-profile`; never the workspace |
|
|
130
132
|
| `--deck-bin PATH` | The deck CLI. Default: the `@pinkpuma/deck` this package depends on |
|
|
131
133
|
| `--id` / `--attempt` / `--context` | How a transcript is filed. Forwarded by the role |
|
|
132
134
|
|
|
@@ -137,7 +139,7 @@ An agent starts with plugin folders, each passed to the vendor as
|
|
|
137
139
|
project's [deck](https://github.com/tilap/pinkpuma-deck) profile resolves to.
|
|
138
140
|
|
|
139
141
|
`--app-deck-profile .ai/profiles/builder` runs `deck materialize -C <app dir> --profile builder --out
|
|
140
|
-
<plugins-out>/builder --json` before the vendor starts. The working directory
|
|
142
|
+
<plugins-out>/<workspace>/builder --json` before the vendor starts. The working directory
|
|
141
143
|
is the checkout the agent works on, so a role gets what the project committed
|
|
142
144
|
at that commit — the base `ai-plugin.json` and `.ai/profiles/builder/`, as their
|
|
143
145
|
locks pin them — never what the operator has installed. deck refuses a request
|
|
@@ -161,9 +163,12 @@ author did. `cursor-agent` ran a Claude-format one (`${CLAUDE_PLUGIN_ROOT}`,
|
|
|
161
163
|
`PostToolUse` on edits) on a real run; a hook that runs is part of the agent's
|
|
162
164
|
time and of what it reads back.
|
|
163
165
|
|
|
164
|
-
deck's cache (`~/.cache/pinkpuma-deck`, or `DECK_CACHE`) has no lock,
|
|
165
|
-
|
|
166
|
-
|
|
166
|
+
deck's cache (`~/.cache/pinkpuma-deck`, or `DECK_CACHE`) has no lock, so agents that
|
|
167
|
+
start together (`ticket.maxParallel` above 1) materialize one after the other: the
|
|
168
|
+
wrapper holds a lock in `--plugins-out` while deck runs, and takes it over when its
|
|
169
|
+
holder is gone. Each workspace has its own folder there, so a running agent's plugins
|
|
170
|
+
are never rewritten by another's start. The folder of a workspace that no longer exists
|
|
171
|
+
is removed the next time an agent starts.
|
|
167
172
|
|
|
168
173
|
`claude.mjs` takes `--plugin-dir` but refuses `--app-deck-profile`: not isolated, Claude
|
|
169
174
|
also loads the operator's plugins and the project's `.claude/settings.json`, so
|
|
@@ -341,6 +346,10 @@ values). Files are
|
|
|
341
346
|
filed under the Ticket the Task belongs to, named for the Subtask and the
|
|
342
347
|
Attempt. It belongs on the **agent** command, after `--`.
|
|
343
348
|
|
|
349
|
+
The file is made before the agent starts and grows with its output, then replaced
|
|
350
|
+
by the finished page. An Attempt killed at its ceiling or by an interrupt leaves
|
|
351
|
+
the file as it was: the prompt, the output so far, and `exit: not returned`.
|
|
352
|
+
|
|
344
353
|
```yaml
|
|
345
354
|
builder:
|
|
346
355
|
producer:
|
|
@@ -375,6 +384,23 @@ Every Gate answers with one JSON object on stdout — `{"verdict":"pass"}`, or
|
|
|
375
384
|
in, and what mason appends to its argv, is in
|
|
376
385
|
[`@bluewombat/runtime`](../../host/runtime/README.md).
|
|
377
386
|
|
|
387
|
+
What no Gate can see, by construction:
|
|
388
|
+
|
|
389
|
+
- `sensitive-path` compares the workspace with `HEAD`, so a Builder that commits
|
|
390
|
+
its work makes it blind. `workspace-changed` covers that by refusing a workspace
|
|
391
|
+
with nothing uncommitted; keep both. On the recheck after align the work is
|
|
392
|
+
committed and `sensitive-path` always passes: use `base-diff --deny GLOB`
|
|
393
|
+
where the recheck or the Feature must answer.
|
|
394
|
+
- `workspace-unchanged` on `assembly.validate` runs after the Project's own
|
|
395
|
+
commands: one that writes outside `.gitignore` (coverage, `.tsbuildinfo`, a build
|
|
396
|
+
directory) reads as the review writing. Allow those paths with `--allow GLOB`.
|
|
397
|
+
- `parent-clean` on a Parent with no `.git` takes the workspace's oldest file as
|
|
398
|
+
its baseline time: a file newer than that is a leak. It is the only baseline a
|
|
399
|
+
copy-mode work line has.
|
|
400
|
+
- `workspace-changed` asks whether something changed, not whether the change
|
|
401
|
+
answers the report: a producer that ignores its report and changes something
|
|
402
|
+
else passes.
|
|
403
|
+
|
|
378
404
|
```yaml
|
|
379
405
|
builder:
|
|
380
406
|
gates:
|
|
@@ -399,7 +425,11 @@ it first: every Gate after it then judges work that exists. It passes at
|
|
|
399
425
|
producer to send back.
|
|
400
426
|
|
|
401
427
|
`parent-clean` fail-blocks if Isolator's Parent working files changed
|
|
402
|
-
(`--parent DIR`,
|
|
428
|
+
(`--parent DIR`, plus the main worktree of this Child's repository). Only files written
|
|
429
|
+
after the Child was created count, so an edit that was already there is not a leak. The
|
|
430
|
+
other linked worktrees are sibling Children (parallel tickets, escalated leftovers) and are
|
|
431
|
+
ignored; `--siblings` watches them too, for a serial run. Not covered: an Agent writing
|
|
432
|
+
into a sibling worktree.
|
|
403
433
|
|
|
404
434
|
`sensitive-path` fail-retries if the workspace changed a path matching a glob.
|
|
405
435
|
`*` is one segment, `**` any depth. It looks at what a fold can carry out of
|
|
@@ -407,11 +437,52 @@ the workspace — tracked paths, and new ones `.gitignore` does not exclude —
|
|
|
407
437
|
what an install left under an ignored directory (a dependency's own `.github/`)
|
|
408
438
|
is not a change to guard.
|
|
409
439
|
|
|
440
|
+
`base-diff` is `sensitive-path` against the work line instead of `HEAD`. It takes the
|
|
441
|
+
paths that differ between the base and the workspace — the commits since the workspace
|
|
442
|
+
left it (`BASE...HEAD`) and what is on disk and not committed, an ignored path excluded —
|
|
443
|
+
so it still sees the work after the commit, and answers at the unit, the ticket and the
|
|
444
|
+
recheck (`--stage assembly`). The base is the branch checked out in the **main worktree**
|
|
445
|
+
of the repository the workspace belongs to: Isolation makes every workspace a worktree of
|
|
446
|
+
the work line's copy, and Host keeps that copy on the line the work lands on, a
|
|
447
|
+
Feature's own line while a Feature is open. In a Ticket of a Feature the diff is therefore
|
|
448
|
+
the Ticket's own, not the Feature's so far. `--base REF` names another ref instead; a
|
|
449
|
+
hand-named `main` is the copy's local branch, which Host does not move (the Refresher
|
|
450
|
+
moves the Feature's line), so it can be weeks old. Two uses, alone or together:
|
|
451
|
+
|
|
452
|
+
```yaml
|
|
453
|
+
- id: no-workflow-edit
|
|
454
|
+
argv: [node, ./node_modules/@bluewombat/slots/gates/base-diff.mjs, --deny, ".github/workflows/**"]
|
|
455
|
+
- id: e2e-when-app-changes
|
|
456
|
+
timeoutMs: 600000
|
|
457
|
+
argv:
|
|
458
|
+
- node
|
|
459
|
+
- ./node_modules/@bluewombat/slots/gates/base-diff.mjs
|
|
460
|
+
- --when
|
|
461
|
+
- "src/**"
|
|
462
|
+
- --run
|
|
463
|
+
- "npm run e2e"
|
|
464
|
+
```
|
|
465
|
+
|
|
466
|
+
`--deny GLOB` (repeatable) fail-retries when a changed path matches. `--when GLOB`
|
|
467
|
+
(repeatable) with `--run COMMAND` hands the command to `project-checks` when a changed path
|
|
468
|
+
matches (so it is cleaned, bounded by the Gate's ceiling and classified as every command of
|
|
469
|
+
the Project is) and answers as it does; `MASON_BASE` is the base and `MASON_CHANGED_FILES`
|
|
470
|
+
the changed paths one per line. When nothing matches it passes without running it. It
|
|
471
|
+
fail-blocks where there is no base to derive: the workspace is the main worktree itself,
|
|
472
|
+
the main worktree has no branch (detached), a `--base` the workspace lacks, and
|
|
473
|
+
`--stage feature` without `--base` — the Feature's workspace is its line with the target
|
|
474
|
+
merged in, so the line would show the target's work; name the target there.
|
|
475
|
+
|
|
476
|
+
`tests-only` is its counterpart: it fail-retries if the workspace changed a path that matches
|
|
477
|
+
none of the globs (added, modified, deleted or renamed, tracked or new and not ignored). It is
|
|
478
|
+
the Gate of the feature-test Ticket, whose tests sit among the Project's code
|
|
479
|
+
(`**/*.test.ts`), where a list of denied paths cannot say "everything but tests".
|
|
480
|
+
|
|
410
481
|
`project-checks --cmd COMMAND` runs one command of the Project's own in the workspace
|
|
411
482
|
and answers on its exit code. It is the Gate that makes a machine, not the Builder,
|
|
412
483
|
say that the code typechecks and the tests pass. The command goes through the shell,
|
|
413
484
|
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
|
|
485
|
+
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. `feature-tests` reads its output the same way.
|
|
415
486
|
|
|
416
487
|
```yaml
|
|
417
488
|
- id: typecheck
|
|
@@ -502,82 +573,91 @@ the variable holding a token that can read the checks — the same one the
|
|
|
502
573
|
manager's `tokenEnv` names. It has no default on purpose: `GITHUB_TOKEN` is
|
|
503
574
|
what `gh` reads ahead of its own login, so an operator must not export it,
|
|
504
575
|
and a Gate that fell back to it refused only at the first assembly of a real
|
|
505
|
-
run. `--remote`, `--poll-ms` and `--api-base` (GitHub Enterprise) are there too.
|
|
576
|
+
run. `--remote`, `--poll-ms` and `--api-base` (GitHub Enterprise) are there too.
|
|
577
|
+
`--require-check NAME` (repeatable) names a check that must exist and have succeeded:
|
|
578
|
+
the check run (or status context) called exactly `NAME`. The general rule counts a
|
|
579
|
+
`skipped` or `neutral` run as green, so an e2e job skipped for want of its secrets lets the
|
|
580
|
+
line through; a required one that settled so is `fail-blocking`: the work cannot make it run, so a person reads it, not a fix agent. One not created yet is
|
|
581
|
+
waited for like `--require-checks`, and at the ceiling the CI did not answer
|
|
582
|
+
(`fail-blocking`). A name is exact: a matrix job is one check per cell (`e2e (chrome)`), and each is listed. It talks to GitHub, and knows
|
|
506
583
|
nothing about `@bluewombat/manager-github`: a Project may run either one
|
|
507
584
|
without the other.
|
|
508
585
|
|
|
509
|
-
`sensitive-path`, `workspace-changed`, `gitignore-leak` and `ci-green` need a git workspace, which
|
|
586
|
+
`sensitive-path`, `base-diff`, `workspace-changed`, `gitignore-leak` and `ci-green` need a git workspace, which
|
|
510
587
|
is what Isolator makes when WorkLineStable is a git tree. They fail-block on a
|
|
511
588
|
workspace Isolator had to copy.
|
|
512
589
|
|
|
513
|
-
### The
|
|
590
|
+
### The feature tests
|
|
514
591
|
|
|
515
|
-
`
|
|
516
|
-
(`feature.validate.gates`).
|
|
517
|
-
|
|
592
|
+
`feature-tests.mjs` runs the tests of a Feature on the assembled Feature line, from a Feature Gate
|
|
593
|
+
(`feature.validate.gates`). Only the scenarios of this Feature run, not the Project's whole suite. It
|
|
594
|
+
knows no stack and starts no application: the Project supplies one command, run through the shell
|
|
595
|
+
in the workspace, and the Gate runs it, compares and judges.
|
|
518
596
|
|
|
519
597
|
| Option | Meaning |
|
|
520
598
|
| --- | --- |
|
|
521
|
-
| `--
|
|
522
|
-
| `--
|
|
523
|
-
| `--
|
|
524
|
-
| `--
|
|
525
|
-
|
|
526
|
-
|
|
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
|
|
599
|
+
| `--run CMD` | Required. Runs the tests of the Feature and writes a JUnit XML report. `{ids}` is the ids of the manifest joined with `\|` (a regular expression, `pnpm test --grep "{ids}"`), `{junit}` the path of the report |
|
|
600
|
+
| `--manifest PATH` | The scenarios of the Feature. Default `feature-tests/scenarios.json`: an array of ids, or `{ "scenarios": [ "S1" \| { "id": "S1", "title": "…" } ] }` |
|
|
601
|
+
| `--junit PATH` | Where `--run` writes its report. Default `feature-tests/results/junit.xml`. Removed before every run, so an old report is never read |
|
|
602
|
+
| `--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 5 s |
|
|
603
|
+
|
|
604
|
+
A scenario is a manifest id, and every test carries its id in its name (`S2 pays by card`). The
|
|
541
605
|
comparison is exact: an id with no test, a test with no manifest id, and a test the runner
|
|
542
|
-
skipped are failures.
|
|
543
|
-
|
|
606
|
+
skipped are failures. There is no retry.
|
|
607
|
+
|
|
608
|
+
The report must therefore hold **only** the Feature's scenarios. A name filter is enough only for a
|
|
609
|
+
runner that leaves the filtered-out tests out of its report. A runner that lists them as `skipped`
|
|
610
|
+
(Vitest with `-t` does) puts tests with no manifest id in the report, and the Gate refuses the run
|
|
611
|
+
(`tests that carry no id of the manifest`): restrict the files the command loads as well, for
|
|
612
|
+
example to the files that hold the Feature's tests by a naming convention.
|
|
613
|
+
|
|
614
|
+
```yaml
|
|
615
|
+
# Vitest: only the *.feature.test.ts files are loaded, then only the manifest's scenarios run
|
|
616
|
+
- pnpm exec vitest run .feature.test.ts -t "{ids}" --reporter=junit --outputFile={junit}
|
|
617
|
+
```
|
|
618
|
+
|
|
619
|
+
`{ids}` and `{junit}` are both required in `--run`; `mason doctor` warns when one is missing. The verdict:
|
|
544
620
|
|
|
545
621
|
| Verdict | When |
|
|
546
622
|
| --- | --- |
|
|
547
|
-
| `pass` | Every scenario ran and passed. The report says how many and which
|
|
548
|
-
| `fail-retryable` | Red: the report has, per scenario, its id, the test, the step (`at`)
|
|
549
|
-
| `fail-blocking` | What no change to the application fixes: a missing, empty or
|
|
623
|
+
| `pass` | Every scenario ran and passed. The report says how many and which |
|
|
624
|
+
| `fail-retryable` | Red: the report has, per scenario, its id, the test, the step (`at`) and the message. Whether the test or the application is wrong is for the caller to say: the feature-test Ticket's author reads the report to fix its tests, and the Feature's fix repairs the application with the tests frozen. Also a runner that crashed before writing a report, and tests that carry no id of the manifest |
|
|
625
|
+
| `fail-blocking` | What no change to the application fixes: a missing, empty or malformed manifest, a run that outlives the ceiling, a run that exits 0 and writes no report |
|
|
550
626
|
|
|
551
|
-
The Project's `.gitignore` excludes the results folder (`
|
|
552
|
-
|
|
627
|
+
The Project's `.gitignore` excludes the results folder (`feature-tests/results/`): a Gate that guards
|
|
628
|
+
the folder (`sensitive-path feature-tests/**` in `feature.fix.gates`) would read the report as a change.
|
|
553
629
|
|
|
554
630
|
```yaml
|
|
555
|
-
- id:
|
|
556
|
-
skipWhen: skip-test
|
|
631
|
+
- id: feature-tests
|
|
632
|
+
skipWhen: skip-feature-test # Host's: not run for a Feature that carries the label
|
|
557
633
|
timeoutMs: 720000
|
|
558
634
|
argv:
|
|
559
635
|
- node
|
|
560
|
-
- ./node_modules/@bluewombat/slots/gates/
|
|
561
|
-
- --up
|
|
562
|
-
- pnpm e2e:up
|
|
563
|
-
- --ready
|
|
564
|
-
- pnpm e2e:ready
|
|
636
|
+
- ./node_modules/@bluewombat/slots/gates/feature-tests.mjs
|
|
565
637
|
- --run
|
|
566
|
-
- pnpm
|
|
567
|
-
- --down
|
|
568
|
-
- pnpm e2e:down
|
|
638
|
+
- pnpm test --grep "{ids}" --reporter=junit --outputFile={junit}
|
|
569
639
|
```
|
|
570
640
|
|
|
571
641
|
## Publishers
|
|
572
642
|
|
|
573
643
|
The slot that puts an assembled ticket where the Authority can read it. It runs
|
|
574
644
|
**in the work line**, and mason appends `--id`, `--ref` — the name Isolator gave
|
|
575
|
-
the ticket —
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
645
|
+
the ticket — `--target`, the work line it is offered to, and `--kind` (`ticket`, or
|
|
646
|
+
`line` for a Feature's own line).
|
|
647
|
+
|
|
648
|
+
`git.mjs` pushes the Ticket's result to the work line's own remote under that
|
|
649
|
+
name: one commit, made from the branch's tree on top of the work line's tip
|
|
650
|
+
(`--target`), not the branch's history. The workspace is snapshotted at every
|
|
651
|
+
integration, so a blob an install added and a repair removed would otherwise
|
|
652
|
+
travel with it and make a pack the remote refuses. The work line remembers what
|
|
653
|
+
it published (`refs/published/<ref>`) and a resubmission continues it, so the
|
|
654
|
+
push stays a fast-forward; the same tree on the same line publishes nothing new.
|
|
655
|
+
A plain push, no force of any kind: a rejection means the name moved under us,
|
|
656
|
+
which is the one case worth refusing rather than overwriting.
|
|
657
|
+
|
|
658
|
+
With `--kind line`, which Host passes when a Feature closes, the ref is pushed as
|
|
659
|
+
it is: a Feature's line is the history the Authority built by folding Tickets
|
|
660
|
+
into it, already on the remote, so the push is a fast-forward of it.
|
|
581
661
|
|
|
582
662
|
It takes no option, and the remote is `origin`. After the Authority folds, Host
|
|
583
663
|
reads the work line back from `origin` on its own, with no way to learn what this
|
|
@@ -650,7 +730,7 @@ type in JSDoc.
|
|
|
650
730
|
```
|
|
651
731
|
README.md this file
|
|
652
732
|
agents/ vendor CLIs: filled prompt in, serialized run out
|
|
653
|
-
builders/ first-pass and repair roles for the unit of work, and the
|
|
733
|
+
builders/ first-pass and repair roles for the unit of work, and the feature-test Ticket's
|
|
654
734
|
ticket/ fix and validate for an assembled ticket
|
|
655
735
|
feature/ fix for a whole feature
|
|
656
736
|
lib/ what the shipped templates say, shared by the roles
|
|
@@ -659,3 +739,12 @@ messages/ what a commit says: a rule, or an agent under the team's guidelin
|
|
|
659
739
|
test/ one suite per kind; each spawns the real command
|
|
660
740
|
fixtures/ fake agent CLIs the Builder suites run against
|
|
661
741
|
```
|
|
742
|
+
|
|
743
|
+
## Copy mode
|
|
744
|
+
|
|
745
|
+
A work line isolated by `@bluewombat/isolation-copy` has no `.git`. The slots
|
|
746
|
+
that read git need one: the `git` Publisher and Refresher,
|
|
747
|
+
`messages/git-producer`, and the Gates `workspace-changed`, `workspace-unchanged`,
|
|
748
|
+
`sensitive-path`, `base-diff`, `ci-green`, `gitignore-leak` and `tests-only`. A
|
|
749
|
+
copy-mode Project uses `parent-clean`, `project-checks` and `feature-tests`, and
|
|
750
|
+
brings its own Publisher and Gates for what it hands over.
|
package/agents/agent-plugins.mjs
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
|
-
import {
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import {
|
|
4
|
+
existsSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
readdirSync,
|
|
7
|
+
readFileSync,
|
|
8
|
+
rmSync,
|
|
9
|
+
statSync,
|
|
10
|
+
writeFileSync,
|
|
11
|
+
} from "node:fs";
|
|
3
12
|
import { createRequire } from "node:module";
|
|
4
13
|
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
5
14
|
import { listOption, textOption } from "@bluewombat/slot-kit";
|
|
@@ -13,15 +22,18 @@ import { listOption, textOption } from "@bluewombat/slot-kit";
|
|
|
13
22
|
// checkout: `[<app dir>/].ai/profiles/<name>`. The base
|
|
14
23
|
// and that profile, as the app's locks pin them
|
|
15
24
|
// --plugins-out DIR where the profile is materialized, one folder per
|
|
16
|
-
//
|
|
25
|
+
// workspace and profile (<DIR>/<workspace>/<profile>).
|
|
26
|
+
// Required with --app-deck-profile: never the
|
|
17
27
|
// workspace, whose tree the Gates read
|
|
18
28
|
// --deck-bin PATH the deck CLI. Default: the @pinkpuma/deck this
|
|
19
29
|
// package depends on
|
|
20
30
|
//
|
|
21
31
|
// The profile is read from the working directory — the checkout the agent
|
|
22
32
|
// works on — so a role gets what the project committed at that commit, not
|
|
23
|
-
// what the operator has installed. Agents of one Mason run
|
|
24
|
-
//
|
|
33
|
+
// what the operator has installed. Agents of one Mason may run side by side
|
|
34
|
+
// (ticket.maxParallel above 1): each workspace has its own folder, so one
|
|
35
|
+
// agent's plugins are never rewritten under another, and the runs of deck
|
|
36
|
+
// that fill them take turns, because deck's cache has no lock of its own.
|
|
25
37
|
|
|
26
38
|
const PROFILE_NAME = /^[A-Za-z0-9_-][A-Za-z0-9._-]*$/;
|
|
27
39
|
|
|
@@ -111,19 +123,34 @@ export function loadPlugins(options, cwd) {
|
|
|
111
123
|
if (!deck.ok) {
|
|
112
124
|
return deck;
|
|
113
125
|
}
|
|
126
|
+
const outRoot = resolve(cwd, out);
|
|
114
127
|
const args = [
|
|
115
128
|
deck.path,
|
|
116
129
|
"materialize",
|
|
117
130
|
"-C",
|
|
118
131
|
appDir,
|
|
119
132
|
"--out",
|
|
120
|
-
|
|
133
|
+
join(outRoot, workspaceSegment(cwd), profile),
|
|
121
134
|
"--json",
|
|
122
135
|
...(hasProfile ? ["--profile", profile] : []),
|
|
123
136
|
];
|
|
124
137
|
// No timeout: this runs inside the agent wrapper's own process group, so
|
|
125
138
|
// the slot's deadline ends deck with it.
|
|
126
|
-
|
|
139
|
+
/** @type {import("node:child_process").SpawnSyncReturns<string>} */
|
|
140
|
+
let run;
|
|
141
|
+
try {
|
|
142
|
+
run = takingTurns(outRoot, () => {
|
|
143
|
+
retireStaleFolders(outRoot);
|
|
144
|
+
markWorkspace(outRoot, cwd);
|
|
145
|
+
return spawnSync(process.execPath, args, { cwd, encoding: "utf8" });
|
|
146
|
+
});
|
|
147
|
+
} catch (error) {
|
|
148
|
+
const said = error instanceof Error ? error.message : String(error);
|
|
149
|
+
return {
|
|
150
|
+
ok: false,
|
|
151
|
+
reason: `deck materialize could not start for profile ${profile}: ${said}`,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
127
154
|
if (run.error !== undefined || run.status !== 0) {
|
|
128
155
|
const said = (run.stderr || run.error?.message || `exit ${run.status}`).trim();
|
|
129
156
|
return {
|
|
@@ -146,6 +173,127 @@ export function loadPlugins(options, cwd) {
|
|
|
146
173
|
};
|
|
147
174
|
}
|
|
148
175
|
|
|
176
|
+
const WORKSPACE_MARK = ".workspace";
|
|
177
|
+
const LOCK_NAME = ".materialize.lock";
|
|
178
|
+
const LOCK_WAIT_MS = 10 * 60 * 1000;
|
|
179
|
+
const LOCK_CREATION_GRACE_MS = 10 * 1000;
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* The folder under `--plugins-out` that belongs to one workspace: the same
|
|
183
|
+
* name every time the same directory asks, a different one for each directory.
|
|
184
|
+
*
|
|
185
|
+
* @param {string} cwd
|
|
186
|
+
* @returns {string}
|
|
187
|
+
*/
|
|
188
|
+
export function workspaceSegment(cwd) {
|
|
189
|
+
return createHash("sha256").update(resolve(cwd)).digest("hex").slice(0, 12);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Record whose folder this is, so that it can be dropped once the workspace is gone.
|
|
194
|
+
*
|
|
195
|
+
* @param {string} outRoot
|
|
196
|
+
* @param {string} cwd
|
|
197
|
+
*/
|
|
198
|
+
function markWorkspace(outRoot, cwd) {
|
|
199
|
+
const folder = join(outRoot, workspaceSegment(cwd));
|
|
200
|
+
mkdirSync(folder, { recursive: true });
|
|
201
|
+
writeFileSync(join(folder, WORKSPACE_MARK), `${resolve(cwd)}\n`);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Drop the folders of workspaces that no longer exist (a ticket that landed
|
|
206
|
+
* clears its own). Only a folder that carries the mark is touched.
|
|
207
|
+
*
|
|
208
|
+
* @param {string} outRoot
|
|
209
|
+
*/
|
|
210
|
+
function retireStaleFolders(outRoot) {
|
|
211
|
+
let names;
|
|
212
|
+
try {
|
|
213
|
+
names = readdirSync(outRoot);
|
|
214
|
+
} catch {
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
for (const name of names) {
|
|
218
|
+
const folder = join(outRoot, name);
|
|
219
|
+
let owner;
|
|
220
|
+
try {
|
|
221
|
+
owner = readFileSync(join(folder, WORKSPACE_MARK), "utf8").trim();
|
|
222
|
+
} catch {
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
if (owner.length > 0 && !existsSync(owner)) {
|
|
226
|
+
rmSync(folder, { recursive: true, force: true });
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Run `work` while holding the lock of `outRoot`: two agents starting together
|
|
233
|
+
* materialize one after the other, since deck's cache has no lock. A lock whose
|
|
234
|
+
* holder is gone is taken over.
|
|
235
|
+
*
|
|
236
|
+
* @template T
|
|
237
|
+
* @param {string} outRoot
|
|
238
|
+
* @param {() => T} work
|
|
239
|
+
* @returns {T}
|
|
240
|
+
*/
|
|
241
|
+
function takingTurns(outRoot, work) {
|
|
242
|
+
mkdirSync(outRoot, { recursive: true });
|
|
243
|
+
const lock = join(outRoot, LOCK_NAME);
|
|
244
|
+
const giveUpAt = Date.now() + LOCK_WAIT_MS;
|
|
245
|
+
for (;;) {
|
|
246
|
+
try {
|
|
247
|
+
mkdirSync(lock);
|
|
248
|
+
break;
|
|
249
|
+
} catch (error) {
|
|
250
|
+
if (/** @type {NodeJS.ErrnoException} */ (error).code !== "EEXIST") {
|
|
251
|
+
throw error;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
if (holderGone(lock)) {
|
|
255
|
+
rmSync(lock, { recursive: true, force: true });
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
if (Date.now() > giveUpAt) {
|
|
259
|
+
throw new Error(`another agent held ${lock} for more than ${LOCK_WAIT_MS / 60000} minutes`);
|
|
260
|
+
}
|
|
261
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 200);
|
|
262
|
+
}
|
|
263
|
+
try {
|
|
264
|
+
writeFileSync(join(lock, "pid"), String(process.pid));
|
|
265
|
+
return work();
|
|
266
|
+
} finally {
|
|
267
|
+
rmSync(lock, { recursive: true, force: true });
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Whether the process that took `lock` is gone. A lock with no pid yet was
|
|
273
|
+
* just made: it is given a few seconds to say whose it is.
|
|
274
|
+
*
|
|
275
|
+
* @param {string} lock
|
|
276
|
+
* @returns {boolean}
|
|
277
|
+
*/
|
|
278
|
+
function holderGone(lock) {
|
|
279
|
+
let pid;
|
|
280
|
+
try {
|
|
281
|
+
pid = Number(readFileSync(join(lock, "pid"), "utf8"));
|
|
282
|
+
} catch {
|
|
283
|
+
try {
|
|
284
|
+
return Date.now() - statSync(lock).mtimeMs > LOCK_CREATION_GRACE_MS;
|
|
285
|
+
} catch {
|
|
286
|
+
return false;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
try {
|
|
290
|
+
process.kill(pid, 0);
|
|
291
|
+
return false;
|
|
292
|
+
} catch (error) {
|
|
293
|
+
return /** @type {NodeJS.ErrnoException} */ (error).code === "ESRCH";
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
149
297
|
/**
|
|
150
298
|
* The filled prompt, then the project's own words for this role.
|
|
151
299
|
*
|
package/agents/claude.mjs
CHANGED
|
@@ -116,7 +116,8 @@ const transcript = transcriptFor(process.argv, options, {
|
|
|
116
116
|
prompt,
|
|
117
117
|
plugins,
|
|
118
118
|
});
|
|
119
|
-
|
|
119
|
+
transcript.start();
|
|
120
|
+
const run = await runAgent({ file: bin, args, cwd, onOutput: transcript.tap });
|
|
120
121
|
const extras = extrasFromClaude(run.stdout, outputFormat, cwd);
|
|
121
122
|
transcript.write(run, extras);
|
|
122
123
|
answer(run, bin, extras);
|
package/agents/cursor.mjs
CHANGED
|
@@ -143,10 +143,12 @@ const transcript = transcriptFor(process.argv, options, {
|
|
|
143
143
|
prompt,
|
|
144
144
|
plugins,
|
|
145
145
|
});
|
|
146
|
+
transcript.start();
|
|
146
147
|
const run = await runAgent({
|
|
147
148
|
file: bin,
|
|
148
149
|
args,
|
|
149
150
|
cwd,
|
|
151
|
+
onOutput: transcript.tap,
|
|
150
152
|
...(isolation.env === undefined ? {} : { env: isolation.env }),
|
|
151
153
|
});
|
|
152
154
|
const extras = extrasFromCursor(run.stdout, outputFormat, cwd);
|