@bluewombat/slots 0.7.0 → 0.9.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 +59 -78
- package/agents/cursor.mjs +109 -13
- package/assembly/fix.mjs +17 -3
- package/assembly/validate.mjs +7 -7
- package/builders/producer.mjs +16 -6
- package/builders/repair.mjs +14 -3
- package/gates/ci-green.mjs +60 -7
- package/gates/gitignore-leak.mjs +8 -8
- package/lib/fill.mjs +0 -6
- package/messages/conventional.mjs +1 -1
- package/messages/git-producer.mjs +2 -2
- package/package.json +4 -7
- package/publishers/git.mjs +1 -1
- package/refreshers/git.mjs +17 -1
- package/planners/one-subtask.mjs +0 -21
- package/planners/producer.mjs +0 -233
package/README.md
CHANGED
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
# Slots
|
|
2
2
|
|
|
3
|
-
The
|
|
3
|
+
The Builder, Gate, and publishing commands that ship with mason. A Project points
|
|
4
4
|
its config at one of these paths; nothing here is imported.
|
|
5
5
|
|
|
6
6
|
```yaml
|
|
7
|
-
planner:
|
|
8
|
-
cmd: [node, ./node_modules/@bluewombat/slots/planners/one-subtask.mjs]
|
|
9
7
|
builder:
|
|
10
8
|
producer:
|
|
11
9
|
cmd:
|
|
@@ -52,7 +50,6 @@ these all use it. How Host wires a slot into a run:
|
|
|
52
50
|
- [The rules](#the-rules)
|
|
53
51
|
- [Transcripts](#transcripts)
|
|
54
52
|
- [Gates](#gates)
|
|
55
|
-
- [Planners](#planners)
|
|
56
53
|
|
|
57
54
|
## Catalogue
|
|
58
55
|
|
|
@@ -60,21 +57,19 @@ these all use it. How Host wires a slot into a run:
|
|
|
60
57
|
| ----------------------------- | --------- | ------------------------------------------------------------------------------------------ |
|
|
61
58
|
| `agents/cursor.mjs` | Agent | Runs Cursor CLI with a filled prompt |
|
|
62
59
|
| `agents/claude.mjs` | Agent | Runs Claude Code with a filled prompt |
|
|
63
|
-
| `builders/producer.mjs` | Builder | First pass of
|
|
64
|
-
| `builders/repair.mjs` | Builder | A Gate refused the
|
|
65
|
-
| `assembly/fix.mjs` | Builder | A judgement refused the assembled
|
|
66
|
-
| `assembly/validate.mjs` | Builder | Read-only review of the assembled
|
|
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: …` |
|
|
67
64
|
| `gates/workspace-changed.mjs` | Gate | The Attempt left an uncommitted change |
|
|
68
65
|
| `gates/parent-clean.mjs` | Gate | Isolator's Parent working files did not move |
|
|
69
66
|
| `gates/sensitive-path.mjs` | Gate | No path matching a glob was touched |
|
|
70
|
-
| `gates/gitignore-leak.mjs` | Gate | The
|
|
67
|
+
| `gates/gitignore-leak.mjs` | Gate | The ticket's commits add nothing its own `.gitignore` excludes |
|
|
71
68
|
| `gates/ci-green.mjs` | Gate | The work line's own checks came back green |
|
|
72
|
-
| `
|
|
73
|
-
| `publishers/git.mjs` | Publisher | Pushes the feature's branch to the work line's own remote |
|
|
69
|
+
| `publishers/git.mjs` | Publisher | Pushes the ticket's branch to the work line's own remote |
|
|
74
70
|
| `refreshers/git.mjs` | Refresher | Fast-forwards the work line copy onto what the Authority holds |
|
|
75
|
-
| `
|
|
76
|
-
| `messages/
|
|
77
|
-
| `messages/git-producer.mjs` | Describer | An agent reads the git diff and writes the feature's subject and body under `--rules-file` |
|
|
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` |
|
|
78
73
|
|
|
79
74
|
## Agents
|
|
80
75
|
|
|
@@ -95,7 +90,13 @@ or `--report`. A role slot after `--` is what names one:
|
|
|
95
90
|
`cursor.mjs` needs `cursor-agent` on PATH. Without `--agent-home` it uses the
|
|
96
91
|
CLI's own login (`cursor-agent login`). With `--agent-home DIR` and
|
|
97
92
|
`--api-key-env NAME` the vendor process gets that directory as `HOME` and
|
|
98
|
-
`CURSOR_API_KEY` from `NAME`; the value never appears on the argv.
|
|
93
|
+
`CURSOR_API_KEY` from `NAME`; the value never appears on the argv. It also gets
|
|
94
|
+
a short environment (`PATH`, locale, proxy and certificate variables and a few
|
|
95
|
+
others, plus the names given with `--pass-env`), not the operator's whole
|
|
96
|
+
shell; git's credential helpers are cleared for it; and `ssh`, `scp` plus the
|
|
97
|
+
commands given with `--deny-shell` are denied in its own `cli-config.json`,
|
|
98
|
+
rewritten every run. That
|
|
99
|
+
lowers a risk and confines nothing (RESERVATIONS F32). `claude.mjs`
|
|
99
100
|
needs `claude`, signed in once, and refuses those two flags: Claude is not
|
|
100
101
|
isolated yet, and starting it would keep the operator's own config.
|
|
101
102
|
|
|
@@ -112,6 +113,8 @@ isolated yet, and starting it would keep the operator's own config.
|
|
|
112
113
|
| `--agent-arg VALUE` | Appended to the CLI argv, before the prompt. Repeatable |
|
|
113
114
|
| `--agent-home DIR` | Isolate the vendor process: its `HOME` is this directory. Cursor only; Claude refuses |
|
|
114
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 |
|
|
115
118
|
| `--plugin-dir DIR` | A plugin folder of the operator's (a Mason home's `ai-plugins/core/*`). Repeatable, loaded first |
|
|
116
119
|
| `--profile NAME` | The project's deck profile for this role, read in the working directory. Cursor only; Claude refuses |
|
|
117
120
|
| `--plugins-out DIR` | Where `--profile` is built, one folder per profile. Required with `--profile`; never the workspace |
|
|
@@ -229,24 +232,40 @@ builder:
|
|
|
229
232
|
```
|
|
230
233
|
|
|
231
234
|
A path that names a file next to the config resolves against the config; any
|
|
232
|
-
other relative path resolves inside the
|
|
235
|
+
other relative path resolves inside the workspace, so a template
|
|
233
236
|
committed in the Project travels with it.
|
|
234
237
|
|
|
235
238
|
Each role owns a closed set. An unknown name, or a required name the template
|
|
236
|
-
never places, is `fail-blocking`.
|
|
237
|
-
definition of done, and vanishes whole when there is none — which is every
|
|
238
|
-
assembly, so `assembly/fix` does not fill it at all.
|
|
239
|
+
never places, is `fail-blocking`.
|
|
239
240
|
|
|
240
241
|
| Role | Required | Also fills |
|
|
241
242
|
| ------------------- | ------------------------------------- | ---------------------------------------------------------- |
|
|
242
|
-
| `builders/producer` | `{{task}}`, `{{rules}}` | `{{
|
|
243
|
-
| `builders/repair` | `{{task}}`, `{{report}}`, `{{rules}}` | `{{
|
|
244
|
-
| `assembly/fix` | `{{task}}`, `{{report}}`, `{{rules}}` | `{{refused_by}}`, `{{id}}`, `{{attempt}}`
|
|
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}}` |
|
|
245
246
|
| `assembly/validate` | `{{task}}`, `{{rules}}` | `{{id}}`, `{{attempt}}` |
|
|
246
247
|
|
|
247
248
|
A first-pass command that receives `--report` is `fail-blocking`: that report
|
|
248
249
|
belongs to the repair command. A repair or fix command without `--report` is the same.
|
|
249
250
|
|
|
251
|
+
`{{history}}` is what the Task went through before this Attempt, oldest first (the
|
|
252
|
+
refusal it came with, each Attempt, each Gate's answer; see Implementer's SPECS §
|
|
253
|
+
History). It is empty on a first Attempt and vanishes whole then, so a template
|
|
254
|
+
that does not place it simply omits it.
|
|
255
|
+
|
|
256
|
+
### The way out
|
|
257
|
+
|
|
258
|
+
Every shipped producer prompt ends with the same exit: when what the agent was
|
|
259
|
+
given is not enough to act on, it changes nothing and ends its final message with
|
|
260
|
+
one line, `MASON_BLOCKED: <what is missing>`. The slot reads the last such line
|
|
261
|
+
in the agent's final message, and answers `fail-blocking` with that reason, so the
|
|
262
|
+
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
|
|
264
|
+
`MASON_VERDICT:` line, and it is not tied to any refuser. A clean exit prints
|
|
265
|
+
`{ "said": "<the agent's final message, clipped>" }` on stdout, which the Task
|
|
266
|
+
keeps for its History. A Project's own template should keep a paragraph that
|
|
267
|
+
tells the agent about the line, or the agent has no way to use it.
|
|
268
|
+
|
|
250
269
|
### The repair prompt
|
|
251
270
|
|
|
252
271
|
A pass a Gate refused is a different job from a first pass: not "do this" but
|
|
@@ -259,10 +278,9 @@ a run spent on nothing. A command that arrives with a report and no
|
|
|
259
278
|
|
|
260
279
|
### The fix prompt
|
|
261
280
|
|
|
262
|
-
A judgement of the assembled
|
|
263
|
-
The
|
|
264
|
-
refusal names and leave the rest. Same placeholders as repair
|
|
265
|
-
`{{done_when}}`, which an assembly has nothing to fill.
|
|
281
|
+
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
|
|
283
|
+
refusal names and leave the rest. Same placeholders as repair.
|
|
266
284
|
|
|
267
285
|
### The validate prompt
|
|
268
286
|
|
|
@@ -293,7 +311,7 @@ nothing pushed. A producing template must place it — one that leaves it out is
|
|
|
293
311
|
Gates are what says no, which is slower, reaches the tracker, and spends a run.
|
|
294
312
|
|
|
295
313
|
`--rules-file` replaces them with the Project's own. The last three are not a
|
|
296
|
-
matter of taste: the Gates read the working tree of the
|
|
314
|
+
matter of taste: the Gates read the working tree of the workspace, so an
|
|
297
315
|
agent that commits, stashes or writes elsewhere leaves them nothing to see and
|
|
298
316
|
the pass is refused for having done nothing. Replacing the rules does not lift
|
|
299
317
|
that; it only stops saying it out loud.
|
|
@@ -309,7 +327,7 @@ loop that misbehaves cannot be read back, only guessed at.
|
|
|
309
327
|
as sent, stdout, stderr, how long it took, and — when the agent wrapper passed
|
|
310
328
|
them — `skills`, `usage` and what the tools did (`unknown`, `(none)`, or the
|
|
311
329
|
values). Files are
|
|
312
|
-
filed under the
|
|
330
|
+
filed under the Ticket the Task belongs to, named for the Subtask and the
|
|
313
331
|
Attempt. It belongs on the **agent** command, after `--`.
|
|
314
332
|
|
|
315
333
|
```yaml
|
|
@@ -378,15 +396,15 @@ the workspace — tracked paths, and new ones `.gitignore` does not exclude —
|
|
|
378
396
|
what an install left under an ignored directory (a dependency's own `.github/`)
|
|
379
397
|
is not a change to guard.
|
|
380
398
|
|
|
381
|
-
`gitignore-leak --base REF` fail-retries if the
|
|
399
|
+
`gitignore-leak --base REF` fail-retries if the ticket's commits add a path the
|
|
382
400
|
workspace's own `.gitignore` excludes — a Builder's `node_modules/`, a build's
|
|
383
|
-
`dist/`, a generated file. `REF` is the work line the
|
|
384
|
-
(`main`): only what the
|
|
385
|
-
only what is committed, so a path tracked on purpose before the
|
|
401
|
+
`dist/`, a generated file. `REF` is the work line the ticket is offered to
|
|
402
|
+
(`main`): only what the ticket adds since it left that line is judged, and
|
|
403
|
+
only what is committed, so a path tracked on purpose before the ticket began
|
|
386
404
|
is not blamed on it, and a removal staged but not committed does not pass. The
|
|
387
405
|
report is written for `assembly.fix`: untrack the paths, or change `.gitignore`
|
|
388
406
|
if tracking one is the point. It passes at `--stage unit`, where no commit
|
|
389
|
-
holds a
|
|
407
|
+
holds a the unit's work yet.
|
|
390
408
|
|
|
391
409
|
It is a second line, not the first: the `isolation-git` fold already keeps an
|
|
392
410
|
ignored path out of the commits it makes. It catches a leak that came some
|
|
@@ -420,7 +438,7 @@ the work in front of the checks did that before the Gate ran; a workspace
|
|
|
420
438
|
with nothing published is `fail-blocking`, because no Attempt of that Task
|
|
421
439
|
can change it.
|
|
422
440
|
|
|
423
|
-
It waits while checks are running, so give it a ceiling of its own. On a red
|
|
441
|
+
It waits while checks are running, so give it a ceiling of its own (the Gate's `timeoutMs`, which Implementer passes to it as `--timeout-ms`). A CI that does not answer is not a refusal of the work: a Gate killed by its clock reads as one, and a refusal sends the work to a producer with nothing to fix. So a few seconds before the ceiling, with no check yet or checks still running, `ci-green` answers `fail-blocking` itself, saying what it was waiting for and for how long, and a person reads it. A check that failed still refuses at once, with its reason. On a red
|
|
424
442
|
check the report carries the failing job's log, cut at the error the runner
|
|
425
443
|
marked — a check name and the word "failure" tell the next Attempt nothing it
|
|
426
444
|
can act on. Right after a push the checks may not be created yet, and an empty
|
|
@@ -438,47 +456,11 @@ without the other.
|
|
|
438
456
|
is what Isolator makes when WorkLineStable is a git tree. They fail-block on a
|
|
439
457
|
workspace Isolator had to copy.
|
|
440
458
|
|
|
441
|
-
## Planners
|
|
442
|
-
|
|
443
|
-
`one-subtask.mjs` is the bootstrap Planner: one Subtask that is the
|
|
444
|
-
FeatureStandard itself. It is what `mason init` writes into a fresh config, and
|
|
445
|
-
it takes no option.
|
|
446
|
-
|
|
447
|
-
`planners/producer.mjs` hands the split to an agent CLI. It reads the project
|
|
448
|
-
before answering and writes nothing into it. `--prompt-file` and `--read` belong
|
|
449
|
-
on the role; the vendor is named after `--`, the same way a Builder names it.
|
|
450
|
-
|
|
451
|
-
```yaml
|
|
452
|
-
planner:
|
|
453
|
-
cmd:
|
|
454
|
-
- node
|
|
455
|
-
- ./node_modules/@bluewombat/slots/planners/producer.mjs
|
|
456
|
-
- --read
|
|
457
|
-
- ./work-line-stable
|
|
458
|
-
- --
|
|
459
|
-
- node
|
|
460
|
-
- ./node_modules/@bluewombat/slots/agents/cursor.mjs
|
|
461
|
-
timeoutMs: 600000
|
|
462
|
-
```
|
|
463
|
-
|
|
464
|
-
| Option | Meaning |
|
|
465
|
-
| -------------------- | ------------------------------------------------------------- |
|
|
466
|
-
| `--read PATH` | The project the agent may read before splitting. Default: cwd |
|
|
467
|
-
| `--prompt-file PATH` | Prompt template. Must place `{{intention}}` and `{{out}}` |
|
|
468
|
-
|
|
469
|
-
`--bin`, `--model`, `--transcript-dir` and `--agent-arg` are the agent's, after
|
|
470
|
-
`--`. Optional placeholder: `{{max_units}}`.
|
|
471
|
-
|
|
472
|
-
A Plan it cannot use — a cycle, a dangling dependency, more Subtasks than
|
|
473
|
-
`--max-units` — is this slot's own failure: it exits non-zero so the Breakdown is
|
|
474
|
-
retried. A FeatureStandard that cannot be split is a refusal on stdout, which
|
|
475
|
-
is a verdict on the intention instead.
|
|
476
|
-
|
|
477
459
|
## Publishers
|
|
478
460
|
|
|
479
|
-
The slot that puts an assembled
|
|
461
|
+
The slot that puts an assembled ticket where the Authority can read it. It runs
|
|
480
462
|
**in the work line**, and mason appends `--id`, `--ref` — the name Isolator gave
|
|
481
|
-
the
|
|
463
|
+
the ticket — and `--target`, the work line it is offered to.
|
|
482
464
|
|
|
483
465
|
`git.mjs` pushes that name to the work line's own remote. A plain push, no force
|
|
484
466
|
of any kind: Integrator only ever adds history, so republishing after a refusal
|
|
@@ -493,12 +475,12 @@ read another. It comes back when that half is a slot too.
|
|
|
493
475
|
The answer is one JSON object on stdout, like every slot:
|
|
494
476
|
|
|
495
477
|
```json
|
|
496
|
-
{ "ref": "issue/
|
|
478
|
+
{ "ref": "issue/ticket-42" }
|
|
497
479
|
{ "outcome": "refused", "reason": "the remote rejected the push" }
|
|
498
480
|
```
|
|
499
481
|
|
|
500
482
|
The reference is opaque above this line — a branch, a directory, a URL. Whatever
|
|
501
|
-
the Publisher answers is what the
|
|
483
|
+
the Publisher answers is what the TicketTracker is told to judge, so a Project
|
|
502
484
|
that places its work some other way writes its own Publisher and changes nothing
|
|
503
485
|
else. A refusal stops the Submission and travels back to the tracker; a slot that
|
|
504
486
|
could not run at all exits non-zero and says why on stderr.
|
|
@@ -556,12 +538,11 @@ type in JSDoc.
|
|
|
556
538
|
```
|
|
557
539
|
README.md this file
|
|
558
540
|
agents/ vendor CLIs: filled prompt in, serialized run out
|
|
559
|
-
builders/ first-pass and repair roles for
|
|
560
|
-
assembly/ the same two roles for the assembled
|
|
541
|
+
builders/ first-pass and repair roles for the unit of work
|
|
542
|
+
assembly/ the same two roles for the assembled work
|
|
561
543
|
lib/ what the shipped templates say, shared by the roles
|
|
562
544
|
gates/ checks on the workspace, and on the work line
|
|
563
|
-
planners/ FeatureStandard in, a Plan out
|
|
564
545
|
messages/ what a commit says: a rule, or an agent under the team's guideline
|
|
565
546
|
test/ one suite per kind; each spawns the real command
|
|
566
|
-
fixtures/ fake agent CLIs the Builder
|
|
547
|
+
fixtures/ fake agent CLIs the Builder suites run against
|
|
567
548
|
```
|
package/agents/cursor.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { existsSync, mkdirSync } from "node:fs";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
|
-
import { join, resolve } from "node:path";
|
|
4
|
+
import { dirname, join, resolve } from "node:path";
|
|
5
5
|
import {
|
|
6
6
|
AGENT_OPTIONS,
|
|
7
7
|
findExecutable,
|
|
@@ -30,6 +30,12 @@ import { extrasFromCursor } from "./cursor-extras.mjs";
|
|
|
30
30
|
// --agent-home DIR isolate this process: HOME is DIR, not the operator's
|
|
31
31
|
// --api-key-env NAME required with --agent-home. Read process.env[NAME];
|
|
32
32
|
// the value is never put on the argv
|
|
33
|
+
// --pass-env NAME with --agent-home: also hand the vendor this variable
|
|
34
|
+
// of the operator. Repeatable. The vendor otherwise
|
|
35
|
+
// gets a short list (KEPT_ENV), not the whole environment
|
|
36
|
+
// --deny-shell NAME with --agent-home: also refuse this command to the
|
|
37
|
+
// vendor (`gh`, `vercel`…). Repeatable. ssh and scp are
|
|
38
|
+
// always refused
|
|
33
39
|
// --plugin-dir / --profile / --plugins-out / --deck-bin plugin folders,
|
|
34
40
|
// see agent-plugins.mjs
|
|
35
41
|
// --id / --attempt / --context how a transcript is filed
|
|
@@ -38,6 +44,45 @@ const NAME = "Cursor CLI";
|
|
|
38
44
|
const CLI_NAMES = ["cursor-agent", "agent"];
|
|
39
45
|
const INSTALL_DIRS = [join(homedir(), ".local/bin"), join(homedir(), ".cursor/bin")];
|
|
40
46
|
|
|
47
|
+
/**
|
|
48
|
+
* What the vendor gets of the operator's environment, besides what this file
|
|
49
|
+
* sets itself: enough to find tools, name the locale and reach the network.
|
|
50
|
+
* Tokens, agent sockets and the rest of an interactive shell stay with the
|
|
51
|
+
* operator; a Project that needs one more names it with `--pass-env`.
|
|
52
|
+
*/
|
|
53
|
+
const KEPT_ENV = [
|
|
54
|
+
"PATH",
|
|
55
|
+
"TERM",
|
|
56
|
+
"LANG",
|
|
57
|
+
"LC_ALL",
|
|
58
|
+
"LC_CTYPE",
|
|
59
|
+
"TZ",
|
|
60
|
+
"TMPDIR",
|
|
61
|
+
"USER",
|
|
62
|
+
"LOGNAME",
|
|
63
|
+
"SHELL",
|
|
64
|
+
"PNPM_HOME",
|
|
65
|
+
"HTTP_PROXY",
|
|
66
|
+
"HTTPS_PROXY",
|
|
67
|
+
"NO_PROXY",
|
|
68
|
+
"http_proxy",
|
|
69
|
+
"https_proxy",
|
|
70
|
+
"no_proxy",
|
|
71
|
+
"SSL_CERT_FILE",
|
|
72
|
+
"SSL_CERT_DIR",
|
|
73
|
+
"NODE_EXTRA_CA_CERTS",
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Commands refused to the vendor whatever the Project says: the ones that
|
|
78
|
+
* reach for the operator's own logins through a socket or a key. A Project adds
|
|
79
|
+
* its own with `--deny-shell` (the GitHub CLI reads a keychain whatever HOME
|
|
80
|
+
* is; `security` is macOS only; each machine has its own). The vendor's
|
|
81
|
+
* permission list refuses a command even under `--force`. It is a list of
|
|
82
|
+
* names, not a sandbox: `python -c` goes round it.
|
|
83
|
+
*/
|
|
84
|
+
const DENIED_SHELL = ["ssh", "scp"];
|
|
85
|
+
|
|
41
86
|
const cwd = process.cwd();
|
|
42
87
|
|
|
43
88
|
const parsed = parseOptions(process.argv.slice(2), AGENT_OPTIONS, NAME);
|
|
@@ -142,42 +187,93 @@ function cursorIsolation(options, dir) {
|
|
|
142
187
|
reason: `The environment variable ${keyEnv} is empty. An isolated Cursor agent needs it, or the CLI tries to store credentials in the login keychain.`,
|
|
143
188
|
};
|
|
144
189
|
}
|
|
145
|
-
|
|
190
|
+
const passEnv = listOption(options, "passEnv");
|
|
191
|
+
const bad = passEnv.find((name) => !/^[A-Za-z_][A-Za-z0-9_]*$/.test(name));
|
|
192
|
+
if (bad !== undefined) {
|
|
193
|
+
return { ok: false, reason: `--pass-env must name an environment variable, not "${bad}".` };
|
|
194
|
+
}
|
|
195
|
+
const denyShell = listOption(options, "denyShell");
|
|
196
|
+
const badCommand = denyShell.find((name) => !/^[A-Za-z0-9._+-]+$/.test(name));
|
|
197
|
+
if (badCommand !== undefined) {
|
|
198
|
+
return {
|
|
199
|
+
ok: false,
|
|
200
|
+
reason: `--deny-shell must name a command, not "${badCommand}".`,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
const agentHome = resolve(dir, home);
|
|
204
|
+
const env = isolatedVendorEnv(agentHome, apiKey, passEnv);
|
|
205
|
+
denyShellCommands(agentHome, denyShell);
|
|
206
|
+
return { ok: true, env };
|
|
146
207
|
}
|
|
147
208
|
|
|
148
209
|
/**
|
|
149
210
|
* @param {string} agentHome
|
|
150
211
|
* @param {string} apiKey
|
|
212
|
+
* @param {readonly string[]} passEnv
|
|
151
213
|
* @returns {NodeJS.ProcessEnv}
|
|
152
214
|
*/
|
|
153
|
-
function isolatedVendorEnv(agentHome, apiKey) {
|
|
215
|
+
function isolatedVendorEnv(agentHome, apiKey, passEnv) {
|
|
154
216
|
mkdirSync(agentHome, { recursive: true });
|
|
155
217
|
const parentHome = homedir();
|
|
156
218
|
/** @type {NodeJS.ProcessEnv} */
|
|
157
|
-
const env = {
|
|
158
|
-
|
|
159
|
-
|
|
219
|
+
const env = { HOME: agentHome };
|
|
220
|
+
for (const name of [...KEPT_ENV, ...passEnv]) {
|
|
221
|
+
if (process.env[name] !== undefined) {
|
|
222
|
+
env[name] = process.env[name];
|
|
223
|
+
}
|
|
160
224
|
}
|
|
161
225
|
env.CURSOR_API_KEY = apiKey;
|
|
162
226
|
env.AGENT_CLI_CREDENTIAL_STORE = "memory";
|
|
227
|
+
// The operator's git config may name a credential helper (a keychain with
|
|
228
|
+
// their account). An empty helper first clears that list for the vendor's
|
|
229
|
+
// git, and a missing credential fails instead of prompting.
|
|
230
|
+
env.GIT_CONFIG_COUNT = "1";
|
|
231
|
+
env.GIT_CONFIG_KEY_0 = "credential.helper";
|
|
232
|
+
env.GIT_CONFIG_VALUE_0 = "";
|
|
233
|
+
env.GIT_TERMINAL_PROMPT = "0";
|
|
163
234
|
const gitConfig = parentGitConfig(parentHome);
|
|
164
|
-
if (gitConfig
|
|
165
|
-
delete env.GIT_CONFIG_GLOBAL;
|
|
166
|
-
} else {
|
|
235
|
+
if (gitConfig !== undefined) {
|
|
167
236
|
env.GIT_CONFIG_GLOBAL = gitConfig;
|
|
168
237
|
}
|
|
169
238
|
const npmCache = process.env.npm_config_cache;
|
|
170
239
|
env.npm_config_cache =
|
|
171
240
|
npmCache !== undefined && npmCache.length > 0 ? npmCache : join(parentHome, ".npm");
|
|
172
241
|
const corepack = parentCorepack(parentHome);
|
|
173
|
-
if (corepack
|
|
174
|
-
delete env.COREPACK_HOME;
|
|
175
|
-
} else {
|
|
242
|
+
if (corepack !== undefined) {
|
|
176
243
|
env.COREPACK_HOME = corepack;
|
|
177
244
|
}
|
|
178
245
|
return env;
|
|
179
246
|
}
|
|
180
247
|
|
|
248
|
+
/**
|
|
249
|
+
* Write the denials into the vendor's own config in `agentHome`, keeping what
|
|
250
|
+
* else is in it. Every pass, because the vendor can edit that file.
|
|
251
|
+
*
|
|
252
|
+
* @param {string} agentHome
|
|
253
|
+
* @param {readonly string[]} extra commands the Project also refuses
|
|
254
|
+
*/
|
|
255
|
+
function denyShellCommands(agentHome, extra) {
|
|
256
|
+
const file = join(agentHome, ".cursor", "cli-config.json");
|
|
257
|
+
/** @type {Record<string, any>} */
|
|
258
|
+
let config = {};
|
|
259
|
+
try {
|
|
260
|
+
config = JSON.parse(readFileSync(file, "utf8"));
|
|
261
|
+
} catch {
|
|
262
|
+
// none yet, or unreadable: start from nothing rather than refuse to run
|
|
263
|
+
}
|
|
264
|
+
const permissions = config.permissions ?? {};
|
|
265
|
+
const deny = Array.isArray(permissions.deny) ? permissions.deny : [];
|
|
266
|
+
config.permissions = {
|
|
267
|
+
...permissions,
|
|
268
|
+
allow: Array.isArray(permissions.allow) ? permissions.allow : [],
|
|
269
|
+
deny: [...new Set([...deny, ...[...DENIED_SHELL, ...extra].map((name) => `Shell(${name})`)])],
|
|
270
|
+
};
|
|
271
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
272
|
+
const next = `${file}.${process.pid}.tmp`;
|
|
273
|
+
writeFileSync(next, `${JSON.stringify(config, null, 2)}\n`);
|
|
274
|
+
renameSync(next, file);
|
|
275
|
+
}
|
|
276
|
+
|
|
181
277
|
/**
|
|
182
278
|
* The global git file the parent already resolves. `~/.gitconfig` wins over
|
|
183
279
|
* the XDG file, which is git's own order when both exist.
|
package/assembly/fix.mjs
CHANGED
|
@@ -19,9 +19,9 @@ const NAME = "assembly fix";
|
|
|
19
19
|
|
|
20
20
|
const DEFAULT_TEMPLATE = `You are a coding agent working unattended in a checkout of the project.
|
|
21
21
|
|
|
22
|
-
This directory is the assembled
|
|
23
|
-
|
|
24
|
-
|
|
22
|
+
This directory is the assembled work. It passed the checks made on the unit
|
|
23
|
+
itself. An earlier judgement of the whole refused it. Do not redo the work.
|
|
24
|
+
Fix what the refusal names.
|
|
25
25
|
|
|
26
26
|
Refused by: {{refused_by}}
|
|
27
27
|
|
|
@@ -40,6 +40,19 @@ This was asked in the first place, and still holds:
|
|
|
40
40
|
|
|
41
41
|
{{task}}
|
|
42
42
|
|
|
43
|
+
{{history}}
|
|
44
|
+
|
|
45
|
+
## When you cannot act
|
|
46
|
+
|
|
47
|
+
If what you were given is not enough to act on (the refusal names nothing you
|
|
48
|
+
can read or run here, the request contradicts the rules of this run, or the
|
|
49
|
+
cause is outside this directory), do not invent a change. Change nothing and
|
|
50
|
+
end your final message with exactly one line:
|
|
51
|
+
|
|
52
|
+
MASON_BLOCKED: <what is missing, in one line>
|
|
53
|
+
|
|
54
|
+
A person reads that line and decides. It is a correct answer, not a failure.
|
|
55
|
+
|
|
43
56
|
## Rules of this run
|
|
44
57
|
|
|
45
58
|
{{rules}}
|
|
@@ -79,6 +92,7 @@ const rendered = fillPrompt(
|
|
|
79
92
|
template.value,
|
|
80
93
|
{
|
|
81
94
|
task: intention,
|
|
95
|
+
history: (take(process.argv, "--history") ?? "").trim(),
|
|
82
96
|
report,
|
|
83
97
|
refused_by:
|
|
84
98
|
refusedBy.length > 0 ? refusedBy : "the producer of the last attempt, which did not finish",
|
package/assembly/validate.mjs
CHANGED
|
@@ -19,11 +19,11 @@ const NAME = "assembly validate";
|
|
|
19
19
|
|
|
20
20
|
const DEFAULT_TEMPLATE = `You are a coding agent working unattended in a checkout of the project.
|
|
21
21
|
|
|
22
|
-
This directory is the assembled
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
22
|
+
This directory is the assembled work. It has already passed the checks made on
|
|
23
|
+
the unit itself. Your job now is different: read the diff it carries against
|
|
24
|
+
the work line it will land on, and judge whether the WHOLE meets what was
|
|
25
|
+
asked. Do not redo the work. Do not change anything — this is a
|
|
26
|
+
read-only review, not a repair.
|
|
27
27
|
|
|
28
28
|
## What was asked
|
|
29
29
|
|
|
@@ -34,7 +34,7 @@ anything — this is a read-only review, not a repair.
|
|
|
34
34
|
End your final message with exactly one line in this form, and nothing after
|
|
35
35
|
it:
|
|
36
36
|
|
|
37
|
-
- \`MASON_VERDICT: VALIDATED\` — the assembled
|
|
37
|
+
- \`MASON_VERDICT: VALIDATED\` — the assembled ticket meets what was asked.
|
|
38
38
|
- \`MASON_VERDICT: REFUSED: <one paragraph>\` — it does not, and the paragraph
|
|
39
39
|
says what is wrong, specifically enough for another agent to fix it without
|
|
40
40
|
redoing your review. Keep the whole paragraph on that one line.
|
|
@@ -162,7 +162,7 @@ function answer(run, bin, name) {
|
|
|
162
162
|
"fail-retryable",
|
|
163
163
|
why.length > 0
|
|
164
164
|
? why
|
|
165
|
-
: "assembly.validate refused the assembled
|
|
165
|
+
: "assembly.validate refused the assembled ticket, with no reason given.",
|
|
166
166
|
);
|
|
167
167
|
process.exit(1);
|
|
168
168
|
}
|
package/builders/producer.mjs
CHANGED
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
textOption,
|
|
14
14
|
transcriptArgs,
|
|
15
15
|
} from "@bluewombat/slot-kit";
|
|
16
|
-
import { attemptOf
|
|
16
|
+
import { attemptOf } from "../lib/fill.mjs";
|
|
17
17
|
|
|
18
18
|
const NAME = "builder producer";
|
|
19
19
|
|
|
@@ -22,11 +22,21 @@ const DEFAULT_TEMPLATE = `You are a coding agent working unattended in a checkou
|
|
|
22
22
|
Task:
|
|
23
23
|
{{task}}
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
This checkout is the project as it stands. Add or change only what this task
|
|
26
|
+
names, and leave the project's own checks green on the whole tree.
|
|
26
27
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
{{history}}
|
|
29
|
+
|
|
30
|
+
## When you cannot act
|
|
31
|
+
|
|
32
|
+
If what you were given is not enough to act on (the refusal names nothing you
|
|
33
|
+
can read or run here, the request contradicts the rules of this run, or the
|
|
34
|
+
cause is outside this directory), do not invent a change. Change nothing and
|
|
35
|
+
end your final message with exactly one line:
|
|
36
|
+
|
|
37
|
+
MASON_BLOCKED: <what is missing, in one line>
|
|
38
|
+
|
|
39
|
+
A person reads that line and decides. It is a correct answer, not a failure.
|
|
30
40
|
|
|
31
41
|
Rules of this run:
|
|
32
42
|
{{rules}}
|
|
@@ -68,7 +78,7 @@ const rendered = fillPrompt(
|
|
|
68
78
|
template.value,
|
|
69
79
|
{
|
|
70
80
|
task: intention,
|
|
71
|
-
|
|
81
|
+
history: (take(process.argv, "--history") ?? "").trim(),
|
|
72
82
|
rules: rules.value.trim(),
|
|
73
83
|
id: take(process.argv, "--id") ?? "",
|
|
74
84
|
attempt: String(attemptOf(process.argv)),
|
package/builders/repair.mjs
CHANGED
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
textOption,
|
|
14
14
|
transcriptArgs,
|
|
15
15
|
} from "@bluewombat/slot-kit";
|
|
16
|
-
import { attemptOf
|
|
16
|
+
import { attemptOf } from "../lib/fill.mjs";
|
|
17
17
|
|
|
18
18
|
const NAME = "builder repair";
|
|
19
19
|
|
|
@@ -38,7 +38,18 @@ This was asked in the first place, and still holds:
|
|
|
38
38
|
|
|
39
39
|
{{task}}
|
|
40
40
|
|
|
41
|
-
{{
|
|
41
|
+
{{history}}
|
|
42
|
+
|
|
43
|
+
## When you cannot act
|
|
44
|
+
|
|
45
|
+
If what you were given is not enough to act on (the refusal names nothing you
|
|
46
|
+
can read or run here, the request contradicts the rules of this run, or the
|
|
47
|
+
cause is outside this directory), do not invent a change. Change nothing and
|
|
48
|
+
end your final message with exactly one line:
|
|
49
|
+
|
|
50
|
+
MASON_BLOCKED: <what is missing, in one line>
|
|
51
|
+
|
|
52
|
+
A person reads that line and decides. It is a correct answer, not a failure.
|
|
42
53
|
|
|
43
54
|
## Rules of this run
|
|
44
55
|
|
|
@@ -79,10 +90,10 @@ const rendered = fillPrompt(
|
|
|
79
90
|
template.value,
|
|
80
91
|
{
|
|
81
92
|
task: intention,
|
|
93
|
+
history: (take(process.argv, "--history") ?? "").trim(),
|
|
82
94
|
report,
|
|
83
95
|
refused_by:
|
|
84
96
|
refusedBy.length > 0 ? refusedBy : "the producer of the last attempt, which did not finish",
|
|
85
|
-
done_when: doneWhen(take(process.argv, "--definition-of-done")),
|
|
86
97
|
rules: rules.value.trim(),
|
|
87
98
|
id: take(process.argv, "--id") ?? "",
|
|
88
99
|
attempt: String(attemptOf(process.argv)),
|
package/gates/ci-green.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { spawnSync } from "node:child_process";
|
|
3
|
-
import { emitVerdict, GATE_FLAGS, ownArgv } from "@bluewombat/slot-kit";
|
|
3
|
+
import { emitVerdict, GATE_FLAGS, ownArgv, take } from "@bluewombat/slot-kit";
|
|
4
4
|
|
|
5
5
|
// The work line's own checks, on the work this workspace published: GitHub
|
|
6
6
|
// Actions' check runs, and the commit statuses CI outside Actions reports
|
|
@@ -20,6 +20,12 @@ import { emitVerdict, GATE_FLAGS, ownArgv } from "@bluewombat/slot-kit";
|
|
|
20
20
|
// to it failed only at the first assembly of a real run
|
|
21
21
|
// --require-checks wait for a check to appear; without it, a commit with no
|
|
22
22
|
// check at all passes
|
|
23
|
+
//
|
|
24
|
+
// It also receives `--timeout-ms` from Implementer: the ceiling it will be
|
|
25
|
+
// killed at. A Gate that waits on a CI it does not run must not wait to be cut
|
|
26
|
+
// off: a kill reads as "refused", and a refusal sends the work to an agent that
|
|
27
|
+
// has nothing to fix. Near that ceiling it says so itself, `fail-blocking`, with
|
|
28
|
+
// what it was waiting for.
|
|
23
29
|
|
|
24
30
|
// What the runner writes at the top of every step it runs.
|
|
25
31
|
const STEP_OPEN = "##[group]Run ";
|
|
@@ -63,8 +69,10 @@ if (pull === undefined) {
|
|
|
63
69
|
process.exit(0);
|
|
64
70
|
}
|
|
65
71
|
|
|
66
|
-
// Ask again until the answer settles
|
|
67
|
-
//
|
|
72
|
+
// Ask again until the answer settles, or until just before Implementer would
|
|
73
|
+
// kill this Gate (`--timeout-ms`); without that flag, until it does.
|
|
74
|
+
const startedAt = Date.now();
|
|
75
|
+
const stopAt = stopTime(startedAt, take(process.argv, "--timeout-ms"));
|
|
68
76
|
for (;;) {
|
|
69
77
|
const checks = await readChecks(pull.sha);
|
|
70
78
|
if (checks === undefined) {
|
|
@@ -77,16 +85,61 @@ for (;;) {
|
|
|
77
85
|
}
|
|
78
86
|
if (checks.total === 0 && options.requireChecks) {
|
|
79
87
|
// Just after a push there is a window where the checks are not created
|
|
80
|
-
// yet, and an empty list then says nothing. Ask again
|
|
81
|
-
//
|
|
82
|
-
|
|
88
|
+
// yet, and an empty list then says nothing. Ask again, until the wait
|
|
89
|
+
// runs out: a commit no check ever picks up is not a refusal of the work.
|
|
90
|
+
if (Date.now() >= stopAt) {
|
|
91
|
+
stopWaiting(pull, "no check has appeared");
|
|
92
|
+
}
|
|
93
|
+
await sleep(Math.min(options.pollMs, Math.max(0, stopAt - Date.now())));
|
|
83
94
|
continue;
|
|
84
95
|
}
|
|
85
96
|
if (checks.running === 0) {
|
|
86
97
|
emitVerdict("pass");
|
|
87
98
|
process.exit(0);
|
|
88
99
|
}
|
|
89
|
-
|
|
100
|
+
if (Date.now() >= stopAt) {
|
|
101
|
+
stopWaiting(
|
|
102
|
+
pull,
|
|
103
|
+
`${checks.running} check${checks.running === 1 ? " was" : "s were"} still running`,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
await sleep(Math.min(options.pollMs, Math.max(0, stopAt - Date.now())));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The moment to stop waiting: a margin before Implementer's ceiling (5 %, between
|
|
111
|
+
* 2 and 30 seconds, and at most half of it), so the verdict is out before the
|
|
112
|
+
* kill. `Infinity` when Implementer gave no ceiling.
|
|
113
|
+
*
|
|
114
|
+
* @param {number} from
|
|
115
|
+
* @param {string | undefined} raw
|
|
116
|
+
*/
|
|
117
|
+
function stopTime(from, raw) {
|
|
118
|
+
const ceiling = Number.parseInt(raw ?? "", 10);
|
|
119
|
+
if (!Number.isInteger(ceiling) || ceiling <= 0) {
|
|
120
|
+
return Number.POSITIVE_INFINITY;
|
|
121
|
+
}
|
|
122
|
+
const margin = Math.min(Math.max(2_000, ceiling * 0.05), 30_000, ceiling / 2);
|
|
123
|
+
return from + ceiling - margin;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The CI did not answer. That says nothing of the work, and nothing the work
|
|
128
|
+
* could change: blocking, so a person reads it instead of an agent being sent
|
|
129
|
+
* to repair what was never judged.
|
|
130
|
+
*
|
|
131
|
+
* @param {{ number: number; sha: string }} pr
|
|
132
|
+
* @param {string} what
|
|
133
|
+
* @returns {never}
|
|
134
|
+
*/
|
|
135
|
+
function stopWaiting(pr, what) {
|
|
136
|
+
const seconds = Math.round((Date.now() - startedAt) / 1000);
|
|
137
|
+
const lasted = seconds >= 120 ? `${Math.round(seconds / 60)} min` : `${seconds} s`;
|
|
138
|
+
emitVerdict(
|
|
139
|
+
"fail-blocking",
|
|
140
|
+
`ci-green stopped waiting after ${lasted}: ${what} on ${pr.sha.slice(0, 7)} (pull request #${pr.number}). The CI did not answer in time; this is not a refusal of the work, and no change to it can make the CI answer.`,
|
|
141
|
+
);
|
|
142
|
+
process.exit(0);
|
|
90
143
|
}
|
|
91
144
|
|
|
92
145
|
/**
|
package/gates/gitignore-leak.mjs
CHANGED
|
@@ -2,17 +2,17 @@
|
|
|
2
2
|
import { spawnSync } from "node:child_process";
|
|
3
3
|
import { emitVerdict, GATE_FLAGS, ownArgv, stageOf } from "@bluewombat/slot-kit";
|
|
4
4
|
|
|
5
|
-
// Fail-retryable when the
|
|
5
|
+
// Fail-retryable when the ticket's commits add a path the workspace's own
|
|
6
6
|
// .gitignore excludes — a Builder's node_modules/, a build's dist/, a generated
|
|
7
7
|
// file — so it would ship in the Submission.
|
|
8
8
|
//
|
|
9
|
-
// --base REF (required): the work line the
|
|
10
|
-
//
|
|
11
|
-
// tracked on purpose before the
|
|
9
|
+
// --base REF (required): the work line the ticket is offered to. Only what the
|
|
10
|
+
// ticket adds since it left that line is judged, in what is committed: a path
|
|
11
|
+
// tracked on purpose before the ticket began is not this ticket's leak, and
|
|
12
12
|
// a removal staged but not committed has not removed anything yet.
|
|
13
13
|
//
|
|
14
|
-
// A unit only: nothing.
|
|
15
|
-
//
|
|
14
|
+
// A unit only: nothing. The work is uncommitted until a fold records it, so
|
|
15
|
+
// there is no commit of its own here to judge.
|
|
16
16
|
|
|
17
17
|
const cwd = process.cwd();
|
|
18
18
|
const tokens = ownArgv(process.argv, GATE_FLAGS);
|
|
@@ -37,7 +37,7 @@ for (let i = 0; i < tokens.length; i++) {
|
|
|
37
37
|
if (base === undefined) {
|
|
38
38
|
emitVerdict(
|
|
39
39
|
"fail-blocking",
|
|
40
|
-
"gitignore-leak needs --base <ref>: the work line the
|
|
40
|
+
"gitignore-leak needs --base <ref>: the work line the ticket is offered to.",
|
|
41
41
|
);
|
|
42
42
|
process.exit(0);
|
|
43
43
|
}
|
|
@@ -105,7 +105,7 @@ emitVerdict(
|
|
|
105
105
|
[
|
|
106
106
|
`These paths are committed on top of ${base}, and this workspace's .gitignore excludes them.`,
|
|
107
107
|
"Take them out of the commit with git rm -r --cached <path> and keep the files. If the",
|
|
108
|
-
"
|
|
108
|
+
"ticket does mean to track one, change .gitignore so it no longer excludes it:",
|
|
109
109
|
...shown,
|
|
110
110
|
...(rest > 0 ? [`… and ${rest} more`] : []),
|
|
111
111
|
].join("\n"),
|
package/lib/fill.mjs
CHANGED
|
@@ -4,12 +4,6 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { take } from "@bluewombat/slot-kit";
|
|
6
6
|
|
|
7
|
-
/** @param {string | undefined} raw */
|
|
8
|
-
export function doneWhen(raw) {
|
|
9
|
-
const done = (raw ?? "").trim();
|
|
10
|
-
return done.length > 0 ? `Done when:\n${done}` : "";
|
|
11
|
-
}
|
|
12
|
-
|
|
13
7
|
/** @param {readonly string[]} argv */
|
|
14
8
|
export function attemptOf(argv) {
|
|
15
9
|
const parsed = Number.parseInt(take(argv, "--attempt") ?? "", 10);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { emitMessage, MESSAGE_FLAGS, ownArgv, take, writeDiagnostic } from "@bluewombat/slot-kit";
|
|
3
3
|
|
|
4
|
-
// The
|
|
4
|
+
// The ticket's commit in the Conventional Commits shape, from the issue's
|
|
5
5
|
// title and body and with no agent: `<type>: <title>`, the body as written.
|
|
6
6
|
//
|
|
7
7
|
// The type is read off the title's first word — a Project that wants a
|
|
@@ -16,9 +16,9 @@ import {
|
|
|
16
16
|
writeDiagnostic,
|
|
17
17
|
} from "@bluewombat/slot-kit";
|
|
18
18
|
|
|
19
|
-
// The
|
|
19
|
+
// The ticket's commit message — and the pull request's description with it
|
|
20
20
|
// — written by an agent that read the diff, under the Project's own rules.
|
|
21
|
-
// Asked once per
|
|
21
|
+
// Asked once per ticket. A role slot like the builders: it fills a template
|
|
22
22
|
// and hands it to the agent after `--`; the agent answers in a file this slot
|
|
23
23
|
// reads back. `--prompt-file` replaces the template, `--rules-file` the rules
|
|
24
24
|
// — that is where a team's commit guideline goes.
|
package/package.json
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bluewombat/slots",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "Shipped mason slots:
|
|
5
|
+
"description": "Shipped mason slots: Builders, Gates, publishers, and refreshers a Project names in its config.",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"mason",
|
|
8
8
|
"bluewombat",
|
|
9
9
|
"gate",
|
|
10
|
-
"builder"
|
|
11
|
-
"planner"
|
|
10
|
+
"builder"
|
|
12
11
|
],
|
|
13
12
|
"license": "MIT",
|
|
14
13
|
"homepage": "https://github.com/tilap/bluewombat/tree/main/packages/plugins/slots#readme",
|
|
@@ -19,7 +18,6 @@
|
|
|
19
18
|
"directory": "packages/plugins/slots"
|
|
20
19
|
},
|
|
21
20
|
"exports": {
|
|
22
|
-
"./planners/*": "./planners/*",
|
|
23
21
|
"./builders/*": "./builders/*",
|
|
24
22
|
"./assembly/*": "./assembly/*",
|
|
25
23
|
"./agents/*": "./agents/*",
|
|
@@ -29,7 +27,6 @@
|
|
|
29
27
|
"./messages/*": "./messages/*"
|
|
30
28
|
},
|
|
31
29
|
"files": [
|
|
32
|
-
"planners",
|
|
33
30
|
"builders",
|
|
34
31
|
"assembly",
|
|
35
32
|
"agents",
|
|
@@ -53,7 +50,7 @@
|
|
|
53
50
|
"test": "tsx --test $(find test -name '*.test.ts')"
|
|
54
51
|
},
|
|
55
52
|
"dependencies": {
|
|
56
|
-
"@bluewombat/slot-kit": "^0.
|
|
53
|
+
"@bluewombat/slot-kit": "^0.9.0",
|
|
57
54
|
"@pinkpuma/deck": "1.0.0"
|
|
58
55
|
}
|
|
59
56
|
}
|
package/publishers/git.mjs
CHANGED
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
writeDiagnostic,
|
|
12
12
|
} from "@bluewombat/slot-kit";
|
|
13
13
|
|
|
14
|
-
// Put the
|
|
14
|
+
// Put the ticket's work where the Authority can read it, by pushing the name
|
|
15
15
|
// Isolator gave it to the work line's own remote.
|
|
16
16
|
//
|
|
17
17
|
// A plain push, and no force of any kind. Integrator only ever adds history, so
|
package/refreshers/git.mjs
CHANGED
|
@@ -16,6 +16,9 @@ import {
|
|
|
16
16
|
// would make the next Isolation start from a version that no longer exists, so
|
|
17
17
|
// this runs before anything else in a pass.
|
|
18
18
|
//
|
|
19
|
+
// The copy moves onto the target line first: a Feature's Tickets are cut from
|
|
20
|
+
// and folded into that Feature's line, not the final branch.
|
|
21
|
+
//
|
|
19
22
|
// Fast-forward only: this system never rewrites that line. A copy that cannot
|
|
20
23
|
// fast-forward has been written to by somebody else, and that is a person's
|
|
21
24
|
// problem, not a merge to attempt.
|
|
@@ -41,7 +44,8 @@ if (!existsSync(join(cwd, ".git"))) {
|
|
|
41
44
|
process.exit(0);
|
|
42
45
|
}
|
|
43
46
|
|
|
44
|
-
|
|
47
|
+
// An explicit refspec: a single-branch clone only knows its own branch.
|
|
48
|
+
const fetched = git(["fetch", "origin", `+refs/heads/${target}:refs/remotes/origin/${target}`]);
|
|
45
49
|
if (fetched.status !== 0) {
|
|
46
50
|
emitRefreshRefusal(
|
|
47
51
|
`Cannot fetch origin/${target} from ${cwd}: ${fetched.stderr.trim().slice(-500)}`,
|
|
@@ -49,6 +53,18 @@ if (fetched.status !== 0) {
|
|
|
49
53
|
process.exit(0);
|
|
50
54
|
}
|
|
51
55
|
|
|
56
|
+
const current = git(["symbolic-ref", "--short", "HEAD"]);
|
|
57
|
+
if (current.status !== 0 || current.stdout.trim() !== target) {
|
|
58
|
+
const exists = git(["rev-parse", "--verify", "--quiet", `refs/heads/${target}`]).status === 0;
|
|
59
|
+
const switched = git(
|
|
60
|
+
exists ? ["checkout", target] : ["checkout", "-b", target, `origin/${target}`],
|
|
61
|
+
);
|
|
62
|
+
if (switched.status !== 0) {
|
|
63
|
+
emitRefreshRefusal(`Cannot move ${cwd} onto ${target}: ${switched.stderr.trim().slice(-500)}`);
|
|
64
|
+
process.exit(0);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
52
68
|
const merged = git(["merge", "--ff-only", `origin/${target}`]);
|
|
53
69
|
if (merged.status !== 0) {
|
|
54
70
|
// The one way a copy stops fast-forwarding is a commit made into it that
|
package/planners/one-subtask.mjs
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* Bootstrap Planner: one Subtask that is the FeatureStandard itself.
|
|
4
|
-
* FeatureBreakdown appends --intention, --key, --max-units.
|
|
5
|
-
*/
|
|
6
|
-
import { emitPlan, emitRefusal, take } from "@bluewombat/slot-kit";
|
|
7
|
-
|
|
8
|
-
const intention = (take(process.argv, "--intention") ?? "").trim();
|
|
9
|
-
if (intention.length === 0) {
|
|
10
|
-
emitRefusal("The FeatureStandard has no intention to turn into a Subtask.");
|
|
11
|
-
process.exit(0);
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
emitPlan([
|
|
15
|
-
{
|
|
16
|
-
id: "A",
|
|
17
|
-
intention,
|
|
18
|
-
definition_of_done: "The intention is realized in the workspace.",
|
|
19
|
-
depends_on: [],
|
|
20
|
-
},
|
|
21
|
-
]);
|
package/planners/producer.mjs
DELETED
|
@@ -1,233 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
3
|
-
import { tmpdir } from "node:os";
|
|
4
|
-
import { join } from "node:path";
|
|
5
|
-
import {
|
|
6
|
-
checkPlan,
|
|
7
|
-
emitPlan,
|
|
8
|
-
emitRefusal,
|
|
9
|
-
fillPrompt,
|
|
10
|
-
loadPrompt,
|
|
11
|
-
PLANNER_FLAGS,
|
|
12
|
-
parseRole,
|
|
13
|
-
spawnFilled,
|
|
14
|
-
take,
|
|
15
|
-
textOption,
|
|
16
|
-
transcriptArgs,
|
|
17
|
-
writeDiagnostic,
|
|
18
|
-
} from "@bluewombat/slot-kit";
|
|
19
|
-
|
|
20
|
-
const NAME = "planner producer";
|
|
21
|
-
|
|
22
|
-
const DEFAULT_TEMPLATE = `You are splitting one piece of work into an ordered list of subtasks.
|
|
23
|
-
|
|
24
|
-
You are not writing any of it. Do not modify anything in this directory. Read as
|
|
25
|
-
much of it as you need first: the split has to fit the project as it is. Do not
|
|
26
|
-
invent conventions you can discover from the repository.
|
|
27
|
-
|
|
28
|
-
The work:
|
|
29
|
-
{{intention}}
|
|
30
|
-
|
|
31
|
-
Treat that as the source of truth for the desired outcome, not as a finished
|
|
32
|
-
specification. Its wording may be incomplete. Separate what it explicitly
|
|
33
|
-
requires from what the repository or ordinary engineering practice implies.
|
|
34
|
-
Do not invent requirements to make the plan look complete.
|
|
35
|
-
|
|
36
|
-
How the subtasks will be run, which is what makes a good split:
|
|
37
|
-
|
|
38
|
-
- One at a time, never in parallel. Each runs in its own copy of this project,
|
|
39
|
-
and its result is folded back before the next one starts. So a later subtask
|
|
40
|
-
sees the earlier ones' work. If two slices would both change the same path
|
|
41
|
-
(a README, an index or barrel file, a changelog, package.json, or any other
|
|
42
|
-
shared file), make them **one** subtask, or give the second a \`depends_on\` on
|
|
43
|
-
the first and let **only** that second one write the shared path. Do not invent
|
|
44
|
-
a docs-only subtask unless the work asked for documentation.
|
|
45
|
-
- Every subtask is checked by the project's own checks — its tests, its lint —
|
|
46
|
-
on its own. **A subtask must leave the project working.** This is the rule that
|
|
47
|
-
decides your split: never cut by layer. "Add the types", then "add the code",
|
|
48
|
-
then "add the tests" is wrong, because the first two leave the project broken.
|
|
49
|
-
Cut by thin slices that each work end to end. Tests belong in the subtask that
|
|
50
|
-
introduces or changes the behavior they verify.
|
|
51
|
-
- Each subtask is given to an agent with no memory of you and no memory of the
|
|
52
|
-
others. Its intention has to stand alone: no "as discussed above", no knowledge
|
|
53
|
-
of this planning process, no requirement that lives only in the original work
|
|
54
|
-
description. Copy into the subtask everything its agent needs. A pointer at
|
|
55
|
-
existing code is fine if it names the behavior or location clearly enough to
|
|
56
|
-
investigate.
|
|
57
|
-
- Write each intention the way a good commit message is written: one headline
|
|
58
|
-
of at most 72 characters in the imperative ("Add titleCase in
|
|
59
|
-
src/title-case.js"), a blank line, then everything the agent needs. The
|
|
60
|
-
headline is what people see: in the plan on the tracker, in the change list
|
|
61
|
-
of the submission, on the commit.
|
|
62
|
-
- Its definition of done has to be checkable by someone who cannot read your
|
|
63
|
-
mind, and preferably by a test. It describes this subtask's observable result,
|
|
64
|
-
not the whole feature and not an intention.
|
|
65
|
-
- \`depends_on\` means "cannot start before this one is done", and nothing else.
|
|
66
|
-
Sharing a file is a reason not to start side by side: pack those slices into
|
|
67
|
-
one subtask, or make the later one depend on the earlier. The graph describes
|
|
68
|
-
what must land before a merge is safe, not the order you happened to think in.
|
|
69
|
-
Chaining every subtask to the one before it when their files do not overlap
|
|
70
|
-
hides which ones were actually independent.
|
|
71
|
-
|
|
72
|
-
You are choosing between two costs, and both are real.
|
|
73
|
-
|
|
74
|
-
Splitting too finely costs a full agent run per slice, and each slice pays the
|
|
75
|
-
project's whole check sequence again. Do not create a subtask solely to read
|
|
76
|
-
code, decide what to implement, create folders, rename things, add comments,
|
|
77
|
-
write documentation (unless the work asked for it), clean up, or refactor
|
|
78
|
-
speculatively.
|
|
79
|
-
|
|
80
|
-
Not splitting enough costs more, and less visibly. One subtask is one agent run,
|
|
81
|
-
with one budget of attempts and one clock: a subtask too large for that exhausts
|
|
82
|
-
them and the whole piece of work stops. And a subtask that fails goes back to
|
|
83
|
-
nothing, while a subtask that succeeded is never redone. Ten modules in one
|
|
84
|
-
subtask means ten modules redone when the tenth goes wrong.
|
|
85
|
-
|
|
86
|
-
Prefer adapting what the project already does over introducing a parallel
|
|
87
|
-
mechanism. Do not propose an architectural change merely because it might be
|
|
88
|
-
cleaner.
|
|
89
|
-
|
|
90
|
-
So: as few subtasks as the work allows, and none larger than what one competent
|
|
91
|
-
engineer would finish in a single sitting without losing the thread. At most
|
|
92
|
-
{{max_units}}.
|
|
93
|
-
|
|
94
|
-
Write your answer as JSON, and nothing else, to this file:
|
|
95
|
-
|
|
96
|
-
{{out}}
|
|
97
|
-
|
|
98
|
-
It looks like this:
|
|
99
|
-
|
|
100
|
-
{
|
|
101
|
-
"subtasks": [
|
|
102
|
-
{ "id": "s1", "intention": "Headline\\n\\nEverything the agent needs.", "definition_of_done": "...", "depends_on": [] },
|
|
103
|
-
{ "id": "s2", "intention": "Headline\\n\\n...", "definition_of_done": "...", "depends_on": ["s1"] }
|
|
104
|
-
]
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
If the work cannot be split as it is written — contradictory outcomes, a
|
|
108
|
-
critical requirement so ambiguous that the implementation would change with
|
|
109
|
-
the reading, or too little to determine what should be built — write this
|
|
110
|
-
instead, and do not invent a plan to fill the silence. Do not refuse merely
|
|
111
|
-
because some implementation details are unspecified; those come from the
|
|
112
|
-
repository.
|
|
113
|
-
|
|
114
|
-
{ "outcome": "refused", "reason": "..." }
|
|
115
|
-
`;
|
|
116
|
-
|
|
117
|
-
const intention = (take(process.argv, "--intention") ?? "").trim();
|
|
118
|
-
if (intention.length === 0) {
|
|
119
|
-
fail("The Planner received no --intention.");
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
const parsed = parseRole(process.argv, PLANNER_FLAGS, NAME, { "--read": {} });
|
|
123
|
-
if (!parsed.ok) {
|
|
124
|
-
fail(parsed.reason);
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
const template = loadPrompt(parsed.options, DEFAULT_TEMPLATE);
|
|
128
|
-
if (!template.ok) {
|
|
129
|
-
fail(template.reason);
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
const maxUnits = Number.parseInt(take(process.argv, "--max-units") ?? "", 10);
|
|
133
|
-
const scratch = mkdtempSync(join(tmpdir(), "plan-"));
|
|
134
|
-
const out = join(scratch, "plan.json");
|
|
135
|
-
const cwd = textOption(parsed.options, "read") ?? process.cwd();
|
|
136
|
-
|
|
137
|
-
try {
|
|
138
|
-
const rendered = fillPrompt(
|
|
139
|
-
template.value,
|
|
140
|
-
{
|
|
141
|
-
intention,
|
|
142
|
-
max_units: Number.isInteger(maxUnits) ? String(maxUnits) : "a handful",
|
|
143
|
-
out,
|
|
144
|
-
},
|
|
145
|
-
["intention", "out"],
|
|
146
|
-
textOption(parsed.options, "promptFile") ?? "built-in template",
|
|
147
|
-
);
|
|
148
|
-
if (!rendered.ok) {
|
|
149
|
-
fail(rendered.reason);
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
const spawned = await spawnFilled(
|
|
153
|
-
parsed.runner,
|
|
154
|
-
rendered.prompt,
|
|
155
|
-
transcriptArgs(process.argv, {
|
|
156
|
-
id: "breakdown",
|
|
157
|
-
context: take(process.argv, "--key"),
|
|
158
|
-
}),
|
|
159
|
-
cwd,
|
|
160
|
-
);
|
|
161
|
-
if (!spawned.ok) {
|
|
162
|
-
fail(spawned.reason);
|
|
163
|
-
}
|
|
164
|
-
if (spawned.run.error !== undefined) {
|
|
165
|
-
fail(`Could not start the agent at ${spawned.about.bin}: ${spawned.run.error.message}`);
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
const answer = readAnswer(out);
|
|
169
|
-
if (answer === undefined) {
|
|
170
|
-
fail(`The agent wrote no usable plan to ${out}. ${tail(spawned.run.output)}`);
|
|
171
|
-
}
|
|
172
|
-
if (answer.outcome === "refused") {
|
|
173
|
-
const reason =
|
|
174
|
-
typeof answer.reason === "string" && answer.reason.trim().length > 0
|
|
175
|
-
? answer.reason.trim()
|
|
176
|
-
: "The Planner refused without saying why.";
|
|
177
|
-
emitRefusal(reason);
|
|
178
|
-
process.exit(0);
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
const checked = checkPlan(answer.subtasks, maxUnits);
|
|
182
|
-
if (!checked.ok) {
|
|
183
|
-
fail(`The agent returned a plan that cannot be used: ${checked.reason}`);
|
|
184
|
-
}
|
|
185
|
-
emitPlan(
|
|
186
|
-
checked.subtasks.map((subtask) => ({
|
|
187
|
-
id: subtask.id,
|
|
188
|
-
intention: subtask.intention,
|
|
189
|
-
definition_of_done: subtask.definition_of_done,
|
|
190
|
-
depends_on: subtask.depends_on,
|
|
191
|
-
})),
|
|
192
|
-
);
|
|
193
|
-
process.exit(0);
|
|
194
|
-
} finally {
|
|
195
|
-
rmSync(scratch, { recursive: true, force: true });
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
/** @param {string} path @returns {Record<string, unknown> | undefined} */
|
|
199
|
-
function readAnswer(path) {
|
|
200
|
-
let text;
|
|
201
|
-
try {
|
|
202
|
-
text = readFileSync(path, "utf8");
|
|
203
|
-
} catch {
|
|
204
|
-
return undefined;
|
|
205
|
-
}
|
|
206
|
-
const fenced = /```(?:json)?\s*([\s\S]*?)```/.exec(text);
|
|
207
|
-
for (const candidate of [fenced?.[1], text]) {
|
|
208
|
-
if (candidate === undefined) {
|
|
209
|
-
continue;
|
|
210
|
-
}
|
|
211
|
-
try {
|
|
212
|
-
const value = JSON.parse(candidate);
|
|
213
|
-
if (value !== null && typeof value === "object") {
|
|
214
|
-
return value;
|
|
215
|
-
}
|
|
216
|
-
} catch {
|
|
217
|
-
// try the next reading
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
return undefined;
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
/** @param {string | undefined} output */
|
|
224
|
-
function tail(output) {
|
|
225
|
-
const trimmed = (output ?? "").trim();
|
|
226
|
-
return trimmed.length === 0 ? "" : trimmed.slice(-1000);
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
/** @param {string} reason @returns {never} */
|
|
230
|
-
function fail(reason) {
|
|
231
|
-
writeDiagnostic(`${reason}\n`);
|
|
232
|
-
process.exit(1);
|
|
233
|
-
}
|