@bluewombat/slots 0.7.0 → 0.8.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
@@ -95,7 +95,13 @@ or `--report`. A role slot after `--` is what names one:
95
95
  `cursor.mjs` needs `cursor-agent` on PATH. Without `--agent-home` it uses the
96
96
  CLI's own login (`cursor-agent login`). With `--agent-home DIR` and
97
97
  `--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`
98
+ `CURSOR_API_KEY` from `NAME`; the value never appears on the argv. It also gets
99
+ a short environment (`PATH`, locale, proxy and certificate variables and a few
100
+ others, plus the names given with `--pass-env`), not the operator's whole
101
+ shell; git's credential helpers are cleared for it; and `ssh`, `scp` plus the
102
+ commands given with `--deny-shell` are denied in its own `cli-config.json`,
103
+ rewritten every run. That
104
+ lowers a risk and confines nothing (RESERVATIONS F32). `claude.mjs`
99
105
  needs `claude`, signed in once, and refuses those two flags: Claude is not
100
106
  isolated yet, and starting it would keep the operator's own config.
101
107
 
@@ -112,6 +118,8 @@ isolated yet, and starting it would keep the operator's own config.
112
118
  | `--agent-arg VALUE` | Appended to the CLI argv, before the prompt. Repeatable |
113
119
  | `--agent-home DIR` | Isolate the vendor process: its `HOME` is this directory. Cursor only; Claude refuses |
114
120
  | `--api-key-env NAME` | With `--agent-home`, read `process.env[NAME]` for the vendor key. Never the value itself |
121
+ | `--pass-env NAME` | With `--agent-home`, also hand the vendor this environment variable. Repeatable |
122
+ | `--deny-shell NAME` | With `--agent-home`, also refuse this command to the vendor (`gh`, `vercel`…). `ssh` and `scp` always are. Repeatable |
115
123
  | `--plugin-dir DIR` | A plugin folder of the operator's (a Mason home's `ai-plugins/core/*`). Repeatable, loaded first |
116
124
  | `--profile NAME` | The project's deck profile for this role, read in the working directory. Cursor only; Claude refuses |
117
125
  | `--plugins-out DIR` | Where `--profile` is built, one folder per profile. Required with `--profile`; never the workspace |
@@ -239,14 +247,32 @@ assembly, so `assembly/fix` does not fill it at all.
239
247
 
240
248
  | Role | Required | Also fills |
241
249
  | ------------------- | ------------------------------------- | ---------------------------------------------------------- |
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}}` |
250
+ | `builders/producer` | `{{task}}`, `{{rules}}` | `{{history}}`, `{{done_when}}`, `{{id}}`, `{{attempt}}` |
251
+ | `builders/repair` | `{{task}}`, `{{report}}`, `{{rules}}` | `{{history}}`, `{{refused_by}}`, `{{done_when}}`, `{{id}}`, `{{attempt}}` |
252
+ | `assembly/fix` | `{{task}}`, `{{report}}`, `{{rules}}` | `{{history}}`, `{{refused_by}}`, `{{id}}`, `{{attempt}}` |
245
253
  | `assembly/validate` | `{{task}}`, `{{rules}}` | `{{id}}`, `{{attempt}}` |
246
254
 
247
255
  A first-pass command that receives `--report` is `fail-blocking`: that report
248
256
  belongs to the repair command. A repair or fix command without `--report` is the same.
249
257
 
258
+ `{{history}}` is what the Task went through before this Attempt, oldest first (the
259
+ refusal it came with, each Attempt, each Gate's answer; see Implementer's SPECS §
260
+ History). It is empty on a first Attempt and vanishes whole then, so a template
261
+ that does not place it works as before.
262
+
263
+ ### The way out
264
+
265
+ Every shipped producer prompt ends with the same exit: when what the agent was
266
+ given is not enough to act on, it changes nothing and ends its final message with
267
+ one line, `MASON_BLOCKED: <what is missing>`. The slot reads the last such line
268
+ in the agent's final message, and answers `fail-blocking` with that reason, so the
269
+ Task stops as `escalated` and a person reads it instead of another Attempt
270
+ inventing a change. This is the same kind of signal as `assembly/validate`'s
271
+ `MASON_VERDICT:` line, and it is not tied to any refuser. A clean exit prints
272
+ `{ "said": "<the agent's final message, clipped>" }` on stdout, which the Task
273
+ keeps for its History. A Project's own template should keep a paragraph that
274
+ tells the agent about the line, or the agent has no way to use it.
275
+
250
276
  ### The repair prompt
251
277
 
252
278
  A pass a Gate refused is a different job from a first pass: not "do this" but
@@ -420,7 +446,7 @@ the work in front of the checks did that before the Gate ran; a workspace
420
446
  with nothing published is `fail-blocking`, because no Attempt of that Task
421
447
  can change it.
422
448
 
423
- It waits while checks are running, so give it a ceiling of its own. On a red
449
+ 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
450
  check the report carries the failing job's log, cut at the error the runner
425
451
  marked — a check name and the word "failure" tell the next Attempt nothing it
426
452
  can act on. Right after a push the checks may not be created yet, and an empty
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
@@ -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",
@@ -28,6 +28,19 @@ This checkout already includes any earlier subtasks that were folded into the
28
28
  feature. Do not rewrite that work. Add or change only what this task names, and
29
29
  leave the project's own checks green on the whole tree.
30
30
 
31
+ {{history}}
32
+
33
+ ## When you cannot act
34
+
35
+ If what you were given is not enough to act on (the refusal names nothing you
36
+ can read or run here, the request contradicts the rules of this run, or the
37
+ cause is outside this directory), do not invent a change. Change nothing and
38
+ end your final message with exactly one line:
39
+
40
+ MASON_BLOCKED: <what is missing, in one line>
41
+
42
+ A person reads that line and decides. It is a correct answer, not a failure.
43
+
31
44
  Rules of this run:
32
45
  {{rules}}
33
46
  `;
@@ -68,6 +81,7 @@ const rendered = fillPrompt(
68
81
  template.value,
69
82
  {
70
83
  task: intention,
84
+ history: (take(process.argv, "--history") ?? "").trim(),
71
85
  done_when: doneWhen(take(process.argv, "--definition-of-done")),
72
86
  rules: rules.value.trim(),
73
87
  id: take(process.argv, "--id") ?? "",
@@ -40,6 +40,19 @@ This was asked in the first place, and still holds:
40
40
 
41
41
  {{done_when}}
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",
@@ -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
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bluewombat/slots",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "type": "module",
5
5
  "description": "Shipped mason slots: Planners, Builders, Gates, publishers, and refreshers a Project names in its config.",
6
6
  "keywords": [
@@ -53,7 +53,7 @@
53
53
  "test": "tsx --test $(find test -name '*.test.ts')"
54
54
  },
55
55
  "dependencies": {
56
- "@bluewombat/slot-kit": "^0.7.0",
56
+ "@bluewombat/slot-kit": "^0.8.0",
57
57
  "@pinkpuma/deck": "1.0.0"
58
58
  }
59
59
  }