@bluewombat/slots 0.6.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,16 +118,62 @@ 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 |
123
+ | `--plugin-dir DIR` | A plugin folder of the operator's (a Mason home's `ai-plugins/core/*`). Repeatable, loaded first |
124
+ | `--profile NAME` | The project's deck profile for this role, read in the working directory. Cursor only; Claude refuses |
125
+ | `--plugins-out DIR` | Where `--profile` is built, one folder per profile. Required with `--profile`; never the workspace |
126
+ | `--deck-bin PATH` | The deck CLI. Default: the `@pinkpuma/deck` this package depends on |
115
127
  | `--id` / `--attempt` / `--context` | How a transcript is filed. Forwarded by the role |
116
128
 
129
+ ### Plugins
130
+
131
+ An agent starts with plugin folders, each passed to the vendor as
132
+ `--plugin-dir`: first the `--plugin-dir` folders, in order, then what the
133
+ project's [deck](https://github.com/tilap/pinkpuma-deck) profile resolves to.
134
+
135
+ `--profile builder` runs `deck materialize -C <cwd> --profile builder --out
136
+ <plugins-out>/builder --json` before the vendor starts. The working directory
137
+ is the checkout the agent works on, so a role gets what the project committed
138
+ at that commit — the base `ai-plugin.json` and `.ai/profiles/builder/`, as their
139
+ locks pin them — never what the operator has installed. deck refuses a request
140
+ no committed lock pins, and the wrapper then refuses before the vendor starts
141
+ (`Run deck install in the project and commit its locks`). Every Attempt of the
142
+ role refuses the same way until the project is fixed; `mason doctor` says so
143
+ first.
144
+
145
+ | The project has | The agent gets |
146
+ | --------------------------------------- | ------------------------------------ |
147
+ | `.ai/profiles/<name>/ai-plugin.json` | the base and that profile |
148
+ | only a base `ai-plugin.json` | the base, and a note on stderr |
149
+ | no deck file at all | the `--plugin-dir` folders only |
150
+
151
+ A non-empty `.ai/profiles/<name>/prompt.md` is appended to the filled prompt
152
+ under `## Project instructions for this role`: what the project wants from that
153
+ role, next to what Mason asks.
154
+
155
+ The plugins keep their hooks: deck writes a plugin's `hooks/hooks.json` as its
156
+ author did. `cursor-agent` ran a Claude-format one (`${CLAUDE_PLUGIN_ROOT}`,
157
+ `PostToolUse` on edits) on a real run; a hook that runs is part of the agent's
158
+ time and of what it reads back.
159
+
160
+ deck's cache (`~/.cache/pinkpuma-deck`, or `DECK_CACHE`) has no lock, and
161
+ `--plugins-out` holds one folder per profile: both rely on a Mason run starting
162
+ one agent at a time, which it does.
163
+
164
+ `claude.mjs` takes `--plugin-dir` but refuses `--profile`: not isolated, Claude
165
+ also loads the operator's plugins and the project's `.claude/settings.json`, so
166
+ a role's profile would not be all it gets.
167
+
117
168
  The agent CLI prints **one JSON line** — a `SerializedRun` from
118
- `@bluewombat/slot-kit` — with the vendor's raw streams plus three extras the
169
+ `@bluewombat/slot-kit` — with the vendor's raw streams plus the extras the
119
170
  wrapper fills when it can:
120
171
 
121
172
  | Field | Meaning |
122
173
  | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
123
174
  | `skills` | `null` = unknown (not stream-json, or unparseable). `[]` = looked, none used. Otherwise skill names in order |
124
175
  | `usage` | `null` = unknown. Otherwise token counts (`input`, `output`, `cacheRead`, `cacheWrite`) and `costUsd` (`null` when the vendor did not report a cost) |
176
+ | `plugins` | Absent when no plugin folder was loaded. Otherwise each folder: `id`, `path`, `from` (`local` or `deck`), and `integrity` (deck's, `null` for a local one) |
125
177
  | `tools` | `null` = unknown (not stream-json, or unparseable). Otherwise what the agent did with its tools: `calls` per tool name, `busyMs` (time with a tool running; `null` when the stream has no times, as Claude's), and the lists `web` (pages fetched, searches, URLs a shell command named), `mcp` (`server/tool`, plus `getMcpTools` when the agent only listed a server's tools), `subagents` (by description), `outside` (paths named outside the working directory, skill files excepted) |
126
178
 
127
179
  Cursor extracts skills from `stream-json` `readToolCall` paths ending in
@@ -134,8 +186,8 @@ Claude's `tool_use` blocks (`WebFetch`, `WebSearch`, `Bash`, `Task`,
134
186
  future vendor agent (`codex.mjs`, …) uses the same fields: leave them `null`
135
187
  when it cannot tell.
136
188
 
137
- When `--transcript-dir` is set, the transcript header repeats `skills`,
138
- `usage` and the tools — `tools`, `web`, `mcp`, `subagents`, `outside the
189
+ When `--transcript-dir` is set, the transcript header lists the `plugins` and
190
+ repeats `skills`, `usage` and the tools — `tools`, `web`, `mcp`, `subagents`, `outside the
139
191
  workspace` — each `unknown`, `(none)`, or the values. "Did it read the page I
140
192
  linked?" is answered there, without reading the stream.
141
193
 
@@ -195,14 +247,32 @@ assembly, so `assembly/fix` does not fill it at all.
195
247
 
196
248
  | Role | Required | Also fills |
197
249
  | ------------------- | ------------------------------------- | ---------------------------------------------------------- |
198
- | `builders/producer` | `{{task}}`, `{{rules}}` | `{{done_when}}`, `{{id}}`, `{{attempt}}` |
199
- | `builders/repair` | `{{task}}`, `{{report}}`, `{{rules}}` | `{{refused_by}}`, `{{done_when}}`, `{{id}}`, `{{attempt}}` |
200
- | `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}}` |
201
253
  | `assembly/validate` | `{{task}}`, `{{rules}}` | `{{id}}`, `{{attempt}}` |
202
254
 
203
255
  A first-pass command that receives `--report` is `fail-blocking`: that report
204
256
  belongs to the repair command. A repair or fix command without `--report` is the same.
205
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
+
206
276
  ### The repair prompt
207
277
 
208
278
  A pass a Gate refused is a different job from a first pass: not "do this" but
@@ -376,7 +446,7 @@ the work in front of the checks did that before the Gate ran; a workspace
376
446
  with nothing published is `fail-blocking`, because no Attempt of that Task
377
447
  can change it.
378
448
 
379
- 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
380
450
  check the report carries the failing job's log, cut at the error the runner
381
451
  marked — a check name and the word "failure" tell the next Attempt nothing it
382
452
  can act on. Right after a push the checks may not be created yet, and an empty
@@ -0,0 +1,231 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync, readFileSync, statSync } from "node:fs";
3
+ import { createRequire } from "node:module";
4
+ import { basename, dirname, join, resolve } from "node:path";
5
+ import { listOption, textOption } from "@bluewombat/slot-kit";
6
+
7
+ // The plugin folders an agent starts with, shared by every vendor wrapper.
8
+ //
9
+ // --plugin-dir DIR a folder of the operator's own (the Mason home's
10
+ // core plugins). Repeatable, loaded first, in order
11
+ // --profile NAME the project's deck profile for this role: the base
12
+ // and that profile, as the project's locks pin them
13
+ // --plugins-out DIR where the profile is materialized, one folder per
14
+ // profile. Required with --profile: never the
15
+ // workspace, whose tree the Gates read
16
+ // --deck-bin PATH the deck CLI. Default: the @pinkpuma/deck this
17
+ // package depends on
18
+ //
19
+ // The profile is read from the working directory — the checkout the agent
20
+ // works on — so a role gets what the project committed at that commit, not
21
+ // what the operator has installed. Agents of one Mason run one at a time,
22
+ // which is why one folder per profile is enough.
23
+
24
+ const PROFILE_NAME = /^[A-Za-z0-9_-][A-Za-z0-9._-]*$/;
25
+
26
+ /**
27
+ * @typedef {import("@bluewombat/slot-kit").AgentPlugin} AgentPlugin
28
+ * @typedef {{ ok: true, plugins: AgentPlugin[], prompt: string | undefined, notes: string[] }
29
+ * | { ok: false, reason: string }} LoadedPlugins
30
+ */
31
+
32
+ /**
33
+ * @param {import("@bluewombat/slot-kit").OptionValues} options
34
+ * @param {string} cwd
35
+ * @returns {LoadedPlugins}
36
+ */
37
+ export function loadPlugins(options, cwd) {
38
+ /** @type {AgentPlugin[]} */
39
+ const plugins = [];
40
+ for (const dir of listOption(options, "pluginDir")) {
41
+ const path = resolve(cwd, dir);
42
+ if (!isDirectory(path)) {
43
+ return { ok: false, reason: `The --plugin-dir ${dir} is not a folder.` };
44
+ }
45
+ plugins.push({ id: pluginId(path), path, from: "local", integrity: null });
46
+ }
47
+ const profile = textOption(options, "profile");
48
+ if (profile === undefined) {
49
+ return { ok: true, plugins, prompt: undefined, notes: [] };
50
+ }
51
+ if (!PROFILE_NAME.test(profile) || profile === "current") {
52
+ return { ok: false, reason: `The --profile "${profile}" is not a deck profile name.` };
53
+ }
54
+ const out = textOption(options, "pluginsOut");
55
+ if (out === undefined) {
56
+ return {
57
+ ok: false,
58
+ reason: "--profile needs --plugins-out: the profile is built outside the workspace.",
59
+ };
60
+ }
61
+ const hasBase = existsSync(join(cwd, "ai-plugin.json"));
62
+ // deck's own rule: a profile is a folder of .ai/profiles that holds a request.
63
+ const hasProfile = existsSync(join(cwd, ".ai", "profiles", profile, "ai-plugin.json"));
64
+ if (!hasBase && !hasProfile) {
65
+ return {
66
+ ok: true,
67
+ plugins,
68
+ prompt: undefined,
69
+ notes: [`The project has no deck setup: no plugin for profile ${profile}.`],
70
+ };
71
+ }
72
+ const deck = deckBin(options);
73
+ if (!deck.ok) {
74
+ return deck;
75
+ }
76
+ const args = [
77
+ deck.path,
78
+ "materialize",
79
+ "-C",
80
+ cwd,
81
+ "--out",
82
+ resolve(cwd, out, profile),
83
+ "--json",
84
+ ...(hasProfile ? ["--profile", profile] : []),
85
+ ];
86
+ // No timeout: this runs inside the agent wrapper's own process group, so
87
+ // the slot's deadline ends deck with it.
88
+ const run = spawnSync(process.execPath, args, { cwd, encoding: "utf8" });
89
+ if (run.error !== undefined || run.status !== 0) {
90
+ const said = (run.stderr || run.error?.message || `exit ${run.status}`).trim();
91
+ return {
92
+ ok: false,
93
+ reason: `deck materialize refused profile ${profile}: ${said} Run deck install in the project and commit its locks.`,
94
+ };
95
+ }
96
+ const result = parseMaterialized(run.stdout);
97
+ if (result === undefined) {
98
+ return { ok: false, reason: `deck materialize answered no JSON for profile ${profile}.` };
99
+ }
100
+ for (const plugin of result.plugins) {
101
+ plugins.push({ ...plugin, from: "deck" });
102
+ }
103
+ return {
104
+ ok: true,
105
+ plugins,
106
+ prompt: profilePrompt(result.prompt),
107
+ notes: hasProfile ? [] : [`The project has no deck profile ${profile}: base plugins only.`],
108
+ };
109
+ }
110
+
111
+ /**
112
+ * The filled prompt, then the project's own words for this role.
113
+ *
114
+ * @param {string} prompt
115
+ * @param {string | undefined} profilePromptText
116
+ * @returns {string}
117
+ */
118
+ export function withProfilePrompt(prompt, profilePromptText) {
119
+ if (profilePromptText === undefined) {
120
+ return prompt;
121
+ }
122
+ return `${prompt.trimEnd()}\n\n## Project instructions for this role\n\n${profilePromptText}\n`;
123
+ }
124
+
125
+ /**
126
+ * `--plugin-dir` once per plugin folder: what both Cursor and Claude read.
127
+ *
128
+ * @param {readonly AgentPlugin[]} plugins
129
+ * @returns {string[]}
130
+ */
131
+ export function pluginDirArgs(plugins) {
132
+ return plugins.flatMap((plugin) => ["--plugin-dir", plugin.path]);
133
+ }
134
+
135
+ /**
136
+ * @param {import("@bluewombat/slot-kit").OptionValues} options
137
+ * @returns {{ ok: true, path: string } | { ok: false, reason: string }}
138
+ */
139
+ function deckBin(options) {
140
+ const given = textOption(options, "deckBin");
141
+ if (given !== undefined) {
142
+ return { ok: true, path: resolve(given) };
143
+ }
144
+ try {
145
+ const manifest = createRequire(import.meta.url).resolve("@pinkpuma/deck/package.json");
146
+ return { ok: true, path: join(dirname(manifest), "bin", "deck.js") };
147
+ } catch {
148
+ return {
149
+ ok: false,
150
+ reason:
151
+ "@pinkpuma/deck is not installed next to @bluewombat/slots. Install it, or pass --deck-bin.",
152
+ };
153
+ }
154
+ }
155
+
156
+ /**
157
+ * @param {string} stdout
158
+ * @returns {{ prompt: string | null, plugins: { id: string, path: string, integrity: string | null }[] } | undefined}
159
+ */
160
+ function parseMaterialized(stdout) {
161
+ let raw;
162
+ try {
163
+ raw = JSON.parse(stdout);
164
+ } catch {
165
+ return undefined;
166
+ }
167
+ if (raw === null || typeof raw !== "object" || !Array.isArray(raw.plugins)) {
168
+ return undefined;
169
+ }
170
+ const plugins = [];
171
+ for (const entry of raw.plugins) {
172
+ if (typeof entry?.id !== "string" || typeof entry?.path !== "string") {
173
+ return undefined;
174
+ }
175
+ plugins.push({
176
+ id: entry.id,
177
+ path: entry.path,
178
+ integrity: typeof entry.integrity === "string" ? entry.integrity : null,
179
+ });
180
+ }
181
+ return { prompt: typeof raw.prompt === "string" ? raw.prompt : null, plugins };
182
+ }
183
+
184
+ /**
185
+ * @param {string | null} path
186
+ * @returns {string | undefined}
187
+ */
188
+ function profilePrompt(path) {
189
+ if (path === null) {
190
+ return undefined;
191
+ }
192
+ try {
193
+ const text = readFileSync(path, "utf8").trim();
194
+ return text.length === 0 ? undefined : text;
195
+ } catch {
196
+ return undefined;
197
+ }
198
+ }
199
+
200
+ /**
201
+ * The name the plugin's manifest gives, else its folder's.
202
+ *
203
+ * @param {string} dir
204
+ * @returns {string}
205
+ */
206
+ function pluginId(dir) {
207
+ for (const manifest of [
208
+ "plugin.json",
209
+ ".cursor-plugin/plugin.json",
210
+ ".claude-plugin/plugin.json",
211
+ ]) {
212
+ try {
213
+ const name = JSON.parse(readFileSync(join(dir, manifest), "utf8")).name;
214
+ if (typeof name === "string" && name.length > 0) {
215
+ return name;
216
+ }
217
+ } catch {
218
+ // try the next manifest
219
+ }
220
+ }
221
+ return basename(dir);
222
+ }
223
+
224
+ /** @param {string} path */
225
+ function isDirectory(path) {
226
+ try {
227
+ return statSync(path).isDirectory();
228
+ } catch {
229
+ return false;
230
+ }
231
+ }
package/agents/claude.mjs CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  transcriptFor,
14
14
  writeContract,
15
15
  } from "@bluewombat/slot-kit";
16
+ import { loadPlugins, pluginDirArgs } from "./agent-plugins.mjs";
16
17
  import { extrasFromClaude } from "./claude-extras.mjs";
17
18
 
18
19
  // Claude Code as a vendor agent. The prompt is already filled: this command
@@ -28,6 +29,11 @@ import { extrasFromClaude } from "./claude-extras.mjs";
28
29
  // --agent-arg VALUE appended before the prompt. Repeatable
29
30
  // --agent-home / --api-key-env recognised, then refused. Claude is not
30
31
  // isolated yet; starting would keep the operator's config
32
+ // --plugin-dir DIR a plugin folder, repeatable (see agent-plugins.mjs)
33
+ // --profile / --plugins-out / --deck-bin recognised, then refused for the
34
+ // same reason: Claude would also load the operator's
35
+ // and the project's own plugins, so a role's profile
36
+ // would not be what it gets
31
37
  // --id / --attempt / --context how a transcript is filed
32
38
 
33
39
  const NAME = "Claude Code";
@@ -57,6 +63,15 @@ if (
57
63
  "Claude Code does not isolate the operator home yet. Remove --agent-home and --api-key-env; the vendor was not started.",
58
64
  );
59
65
  }
66
+ if (
67
+ textOption(options, "profile") !== undefined ||
68
+ textOption(options, "pluginsOut") !== undefined ||
69
+ textOption(options, "deckBin") !== undefined
70
+ ) {
71
+ refuse(
72
+ "Claude Code does not isolate the operator home yet, so a deck profile would not be all it loads. Remove --profile; the vendor was not started.",
73
+ );
74
+ }
60
75
 
61
76
  const loaded = readAgentPrompt(options);
62
77
  if (!loaded.ok) {
@@ -64,6 +79,12 @@ if (!loaded.ok) {
64
79
  }
65
80
  const prompt = loaded.prompt;
66
81
 
82
+ const loadedPlugins = loadPlugins(options, cwd);
83
+ if (!loadedPlugins.ok) {
84
+ refuse(loadedPlugins.reason);
85
+ }
86
+ const plugins = loadedPlugins.plugins;
87
+
67
88
  const bin = textOption(options, "bin") ?? findExecutable(CLI_NAMES, INSTALL_DIRS);
68
89
  if (bin === undefined) {
69
90
  refuse(
@@ -83,6 +104,7 @@ const args = [
83
104
  // Claude's stream-json only emits tool events with --verbose (headless docs).
84
105
  ...(outputFormat === "stream-json" ? ["--verbose"] : []),
85
106
  ...(model === undefined ? [] : ["--model", model]),
107
+ ...pluginDirArgs(plugins),
86
108
  ...listOption(options, "agentArg"),
87
109
  prompt,
88
110
  ];
@@ -92,6 +114,7 @@ const transcript = transcriptFor(process.argv, options, {
92
114
  bin,
93
115
  args,
94
116
  prompt,
117
+ plugins,
95
118
  });
96
119
  const run = await runAgent({ file: bin, args, cwd });
97
120
  const extras = extrasFromClaude(run.stdout, outputFormat, cwd);
@@ -119,6 +142,7 @@ function answer(run, binPath, extras) {
119
142
  skills: extras.skills,
120
143
  usage: extras.usage,
121
144
  tools: extras.tools,
145
+ plugins,
122
146
  }),
123
147
  )}\n`,
124
148
  );
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,
@@ -14,6 +14,7 @@ import {
14
14
  transcriptFor,
15
15
  writeContract,
16
16
  } from "@bluewombat/slot-kit";
17
+ import { loadPlugins, pluginDirArgs, withProfilePrompt } from "./agent-plugins.mjs";
17
18
  import { extrasFromCursor } from "./cursor-extras.mjs";
18
19
 
19
20
  // Cursor CLI as a vendor agent. The prompt is already filled: this command
@@ -29,12 +30,59 @@ import { extrasFromCursor } from "./cursor-extras.mjs";
29
30
  // --agent-home DIR isolate this process: HOME is DIR, not the operator's
30
31
  // --api-key-env NAME required with --agent-home. Read process.env[NAME];
31
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
39
+ // --plugin-dir / --profile / --plugins-out / --deck-bin plugin folders,
40
+ // see agent-plugins.mjs
32
41
  // --id / --attempt / --context how a transcript is filed
33
42
 
34
43
  const NAME = "Cursor CLI";
35
44
  const CLI_NAMES = ["cursor-agent", "agent"];
36
45
  const INSTALL_DIRS = [join(homedir(), ".local/bin"), join(homedir(), ".cursor/bin")];
37
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
+
38
86
  const cwd = process.cwd();
39
87
 
40
88
  const parsed = parseOptions(process.argv.slice(2), AGENT_OPTIONS, NAME);
@@ -47,7 +95,15 @@ const loaded = readAgentPrompt(options);
47
95
  if (!loaded.ok) {
48
96
  refuse(loaded.reason);
49
97
  }
50
- const prompt = loaded.prompt;
98
+ const loadedPlugins = loadPlugins(options, cwd);
99
+ if (!loadedPlugins.ok) {
100
+ refuse(loadedPlugins.reason);
101
+ }
102
+ for (const note of loadedPlugins.notes) {
103
+ process.stderr.write(`${note}\n`);
104
+ }
105
+ const plugins = loadedPlugins.plugins;
106
+ const prompt = withProfilePrompt(loaded.prompt, loadedPlugins.prompt);
51
107
 
52
108
  const bin = textOption(options, "bin") ?? findExecutable(CLI_NAMES, INSTALL_DIRS);
53
109
  if (bin === undefined) {
@@ -70,6 +126,7 @@ const args = [
70
126
  "--output-format",
71
127
  outputFormat,
72
128
  ...(model === undefined ? [] : ["--model", model]),
129
+ ...pluginDirArgs(plugins),
73
130
  ...listOption(options, "agentArg"),
74
131
  prompt,
75
132
  ];
@@ -84,6 +141,7 @@ const transcript = transcriptFor(process.argv, options, {
84
141
  bin,
85
142
  args,
86
143
  prompt,
144
+ plugins,
87
145
  });
88
146
  const run = await runAgent({
89
147
  file: bin,
@@ -129,42 +187,93 @@ function cursorIsolation(options, dir) {
129
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.`,
130
188
  };
131
189
  }
132
- 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 };
133
207
  }
134
208
 
135
209
  /**
136
210
  * @param {string} agentHome
137
211
  * @param {string} apiKey
212
+ * @param {readonly string[]} passEnv
138
213
  * @returns {NodeJS.ProcessEnv}
139
214
  */
140
- function isolatedVendorEnv(agentHome, apiKey) {
215
+ function isolatedVendorEnv(agentHome, apiKey, passEnv) {
141
216
  mkdirSync(agentHome, { recursive: true });
142
217
  const parentHome = homedir();
143
218
  /** @type {NodeJS.ProcessEnv} */
144
- const env = { ...process.env, HOME: agentHome };
145
- if (process.env.PATH !== undefined) {
146
- 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
+ }
147
224
  }
148
225
  env.CURSOR_API_KEY = apiKey;
149
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";
150
234
  const gitConfig = parentGitConfig(parentHome);
151
- if (gitConfig === undefined) {
152
- delete env.GIT_CONFIG_GLOBAL;
153
- } else {
235
+ if (gitConfig !== undefined) {
154
236
  env.GIT_CONFIG_GLOBAL = gitConfig;
155
237
  }
156
238
  const npmCache = process.env.npm_config_cache;
157
239
  env.npm_config_cache =
158
240
  npmCache !== undefined && npmCache.length > 0 ? npmCache : join(parentHome, ".npm");
159
241
  const corepack = parentCorepack(parentHome);
160
- if (corepack === undefined) {
161
- delete env.COREPACK_HOME;
162
- } else {
242
+ if (corepack !== undefined) {
163
243
  env.COREPACK_HOME = corepack;
164
244
  }
165
245
  return env;
166
246
  }
167
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
+
168
277
  /**
169
278
  * The global git file the parent already resolves. `~/.gitconfig` wins over
170
279
  * the XDG file, which is git's own order when both exist.
@@ -224,6 +333,7 @@ function answer(run, binPath, extras) {
224
333
  skills: extras.skills,
225
334
  usage: extras.usage,
226
335
  tools: extras.tools,
336
+ plugins,
227
337
  }),
228
338
  )}\n`,
229
339
  );
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.6.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,6 +53,7 @@
53
53
  "test": "tsx --test $(find test -name '*.test.ts')"
54
54
  },
55
55
  "dependencies": {
56
- "@bluewombat/slot-kit": "^0.6.0"
56
+ "@bluewombat/slot-kit": "^0.8.0",
57
+ "@pinkpuma/deck": "1.0.0"
57
58
  }
58
59
  }