@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 CHANGED
@@ -1,11 +1,9 @@
1
1
  # Slots
2
2
 
3
- The Planner, Builder, and Gate commands that ship with mason. A Project points
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 a Subtask: fill `{{task}}`, then spawn the agent |
64
- | `builders/repair.mjs` | Builder | A Gate refused the Subtask: fill `{{task}}` and `{{report}}` |
65
- | `assembly/fix.mjs` | Builder | A judgement refused the assembled feature: fill `{{task}}` and `{{report}}` |
66
- | `assembly/validate.mjs` | Builder | Read-only review of the assembled feature: fill `{{task}}`; answers `MASON_VERDICT: VALIDATED` or `MASON_VERDICT: REFUSED: …` |
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 feature's commits add nothing its own `.gitignore` excludes |
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
- | `planners/one-subtask.mjs` | Planner | Bootstrap: one Subtask that is the FeatureStandard itself |
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
- | `planners/producer.mjs` | Planner | An agent CLI splits the FeatureStandard |
76
- | `messages/conventional.mjs` | Message | `<type>: <title>` for the feature's fold, no agent; `--scope`, `--type` |
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. `claude.mjs`
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 Subtask workspace, so a template
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`. `{{done_when}}` is `Done when:` plus the
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}}` | `{{done_when}}`, `{{id}}`, `{{attempt}}` |
243
- | `builders/repair` | `{{task}}`, `{{report}}`, `{{rules}}` | `{{refused_by}}`, `{{done_when}}`, `{{id}}`, `{{attempt}}` |
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 feature that refused it is not a Subtask retry.
263
- The units already passed. `assembly/fix.mjs` tells the agent to change what the
264
- refusal names and leave the rest. Same placeholders as repair except
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 Subtask directory, so an
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 Feature the Task belongs to, named for the Subtask and 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 feature's commits add a path 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 feature is offered to
384
- (`main`): only what the feature adds since it left that line is judged, and
385
- only what is committed, so a path tracked on purpose before the feature began
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 Subtask's work yet.
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 feature where the Authority can read it. It runs
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 feature — and `--target`, the work line it is offered to.
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/feature-42" }
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 FeatureManager is told to judge, so a Project
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 a Subtask
560
- assembly/ the same two roles for the assembled feature
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 and Planner suites run against
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
- return { ok: true, env: isolatedVendorEnv(resolve(dir, home), apiKey) };
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 = { ...process.env, HOME: agentHome };
158
- if (process.env.PATH !== undefined) {
159
- env.PATH = process.env.PATH;
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 === undefined) {
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 === undefined) {
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 feature. The units of work that made it have
23
- already been validated. An earlier judgement of the whole refused it. Do not
24
- redo those units. Fix what the refusal names.
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",
@@ -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 feature. Every unit of work that makes it up
23
- has already been validated on its own. Your job now is different: read the
24
- diff this feature carries against the work line it will land on, and judge
25
- whether the WHOLE meets what was asked. Do not redo the units. Do not change
26
- anything — this is a read-only review, not a repair.
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 feature meets what was asked.
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 feature, with no reason given.",
165
+ : "assembly.validate refused the assembled ticket, with no reason given.",
166
166
  );
167
167
  process.exit(1);
168
168
  }
@@ -13,7 +13,7 @@ import {
13
13
  textOption,
14
14
  transcriptArgs,
15
15
  } from "@bluewombat/slot-kit";
16
- import { attemptOf, doneWhen } from "../lib/fill.mjs";
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
- {{done_when}}
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
- This checkout already includes any earlier subtasks that were folded into the
28
- feature. Do not rewrite that work. Add or change only what this task names, and
29
- leave the project's own checks green on the whole tree.
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
- done_when: doneWhen(take(process.argv, "--definition-of-done")),
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)),
@@ -13,7 +13,7 @@ import {
13
13
  textOption,
14
14
  transcriptArgs,
15
15
  } from "@bluewombat/slot-kit";
16
- import { attemptOf, doneWhen } from "../lib/fill.mjs";
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
- {{done_when}}
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)),
@@ -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. The ceiling is this Gate's own timeout:
67
- // reaching it ends the Attempt, and the next one asks from scratch.
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; the Gate's timeout
81
- // is what ends the wait on a commit no check ever picks up.
82
- await sleep(options.pollMs);
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
- await sleep(options.pollMs);
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
  /**
@@ -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 feature's commits add a path the workspace's own
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 feature is offered to. Only what the
10
- // feature adds since it left that line is judged, in what is committed: a path
11
- // tracked on purpose before the feature began is not this feature's leak, and
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. A Subtask's work is uncommitted until a fold records it,
15
- // so there is no commit of its own here to judge.
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 feature is offered to.",
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
- "feature does mean to track one, change .gitignore so it no longer excludes it:",
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 feature's commit in the Conventional Commits shape, from the issue's
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 feature's commit message — and the pull request's description with it
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 feature. A role slot like the builders: it fills a template
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.7.0",
3
+ "version": "0.9.0",
4
4
  "type": "module",
5
- "description": "Shipped mason slots: Planners, Builders, Gates, publishers, and refreshers a Project names in its config.",
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.7.0",
53
+ "@bluewombat/slot-kit": "^0.9.0",
57
54
  "@pinkpuma/deck": "1.0.0"
58
55
  }
59
56
  }
@@ -11,7 +11,7 @@ import {
11
11
  writeDiagnostic,
12
12
  } from "@bluewombat/slot-kit";
13
13
 
14
- // Put the feature's work where the Authority can read it, by pushing the name
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
@@ -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
- const fetched = git(["fetch", "origin", target]);
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
@@ -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
- ]);
@@ -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
- }