@bluewombat/slots 0.5.0 → 0.7.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
@@ -112,16 +112,60 @@ isolated yet, and starting it would keep the operator's own config.
112
112
  | `--agent-arg VALUE` | Appended to the CLI argv, before the prompt. Repeatable |
113
113
  | `--agent-home DIR` | Isolate the vendor process: its `HOME` is this directory. Cursor only; Claude refuses |
114
114
  | `--api-key-env NAME` | With `--agent-home`, read `process.env[NAME]` for the vendor key. Never the value itself |
115
+ | `--plugin-dir DIR` | A plugin folder of the operator's (a Mason home's `ai-plugins/core/*`). Repeatable, loaded first |
116
+ | `--profile NAME` | The project's deck profile for this role, read in the working directory. Cursor only; Claude refuses |
117
+ | `--plugins-out DIR` | Where `--profile` is built, one folder per profile. Required with `--profile`; never the workspace |
118
+ | `--deck-bin PATH` | The deck CLI. Default: the `@pinkpuma/deck` this package depends on |
115
119
  | `--id` / `--attempt` / `--context` | How a transcript is filed. Forwarded by the role |
116
120
 
121
+ ### Plugins
122
+
123
+ An agent starts with plugin folders, each passed to the vendor as
124
+ `--plugin-dir`: first the `--plugin-dir` folders, in order, then what the
125
+ project's [deck](https://github.com/tilap/pinkpuma-deck) profile resolves to.
126
+
127
+ `--profile builder` runs `deck materialize -C <cwd> --profile builder --out
128
+ <plugins-out>/builder --json` before the vendor starts. The working directory
129
+ is the checkout the agent works on, so a role gets what the project committed
130
+ at that commit — the base `ai-plugin.json` and `.ai/profiles/builder/`, as their
131
+ locks pin them — never what the operator has installed. deck refuses a request
132
+ no committed lock pins, and the wrapper then refuses before the vendor starts
133
+ (`Run deck install in the project and commit its locks`). Every Attempt of the
134
+ role refuses the same way until the project is fixed; `mason doctor` says so
135
+ first.
136
+
137
+ | The project has | The agent gets |
138
+ | --------------------------------------- | ------------------------------------ |
139
+ | `.ai/profiles/<name>/ai-plugin.json` | the base and that profile |
140
+ | only a base `ai-plugin.json` | the base, and a note on stderr |
141
+ | no deck file at all | the `--plugin-dir` folders only |
142
+
143
+ A non-empty `.ai/profiles/<name>/prompt.md` is appended to the filled prompt
144
+ under `## Project instructions for this role`: what the project wants from that
145
+ role, next to what Mason asks.
146
+
147
+ The plugins keep their hooks: deck writes a plugin's `hooks/hooks.json` as its
148
+ author did. `cursor-agent` ran a Claude-format one (`${CLAUDE_PLUGIN_ROOT}`,
149
+ `PostToolUse` on edits) on a real run; a hook that runs is part of the agent's
150
+ time and of what it reads back.
151
+
152
+ deck's cache (`~/.cache/pinkpuma-deck`, or `DECK_CACHE`) has no lock, and
153
+ `--plugins-out` holds one folder per profile: both rely on a Mason run starting
154
+ one agent at a time, which it does.
155
+
156
+ `claude.mjs` takes `--plugin-dir` but refuses `--profile`: not isolated, Claude
157
+ also loads the operator's plugins and the project's `.claude/settings.json`, so
158
+ a role's profile would not be all it gets.
159
+
117
160
  The agent CLI prints **one JSON line** — a `SerializedRun` from
118
- `@bluewombat/slot-kit` — with the vendor's raw streams plus three extras the
161
+ `@bluewombat/slot-kit` — with the vendor's raw streams plus the extras the
119
162
  wrapper fills when it can:
120
163
 
121
164
  | Field | Meaning |
122
165
  | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
123
166
  | `skills` | `null` = unknown (not stream-json, or unparseable). `[]` = looked, none used. Otherwise skill names in order |
124
167
  | `usage` | `null` = unknown. Otherwise token counts (`input`, `output`, `cacheRead`, `cacheWrite`) and `costUsd` (`null` when the vendor did not report a cost) |
168
+ | `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
169
  | `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
170
 
127
171
  Cursor extracts skills from `stream-json` `readToolCall` paths ending in
@@ -134,8 +178,8 @@ Claude's `tool_use` blocks (`WebFetch`, `WebSearch`, `Bash`, `Task`,
134
178
  future vendor agent (`codex.mjs`, …) uses the same fields: leave them `null`
135
179
  when it cannot tell.
136
180
 
137
- When `--transcript-dir` is set, the transcript header repeats `skills`,
138
- `usage` and the tools — `tools`, `web`, `mcp`, `subagents`, `outside the
181
+ When `--transcript-dir` is set, the transcript header lists the `plugins` and
182
+ repeats `skills`, `usage` and the tools — `tools`, `web`, `mcp`, `subagents`, `outside the
139
183
  workspace` — each `unknown`, `(none)`, or the values. "Did it read the page I
140
184
  linked?" is answered there, without reading the stream.
141
185
 
@@ -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
@@ -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,6 +30,8 @@ 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
+ // --plugin-dir / --profile / --plugins-out / --deck-bin plugin folders,
34
+ // see agent-plugins.mjs
32
35
  // --id / --attempt / --context how a transcript is filed
33
36
 
34
37
  const NAME = "Cursor CLI";
@@ -47,7 +50,15 @@ const loaded = readAgentPrompt(options);
47
50
  if (!loaded.ok) {
48
51
  refuse(loaded.reason);
49
52
  }
50
- const prompt = loaded.prompt;
53
+ const loadedPlugins = loadPlugins(options, cwd);
54
+ if (!loadedPlugins.ok) {
55
+ refuse(loadedPlugins.reason);
56
+ }
57
+ for (const note of loadedPlugins.notes) {
58
+ process.stderr.write(`${note}\n`);
59
+ }
60
+ const plugins = loadedPlugins.plugins;
61
+ const prompt = withProfilePrompt(loaded.prompt, loadedPlugins.prompt);
51
62
 
52
63
  const bin = textOption(options, "bin") ?? findExecutable(CLI_NAMES, INSTALL_DIRS);
53
64
  if (bin === undefined) {
@@ -70,6 +81,7 @@ const args = [
70
81
  "--output-format",
71
82
  outputFormat,
72
83
  ...(model === undefined ? [] : ["--model", model]),
84
+ ...pluginDirArgs(plugins),
73
85
  ...listOption(options, "agentArg"),
74
86
  prompt,
75
87
  ];
@@ -84,6 +96,7 @@ const transcript = transcriptFor(process.argv, options, {
84
96
  bin,
85
97
  args,
86
98
  prompt,
99
+ plugins,
87
100
  });
88
101
  const run = await runAgent({
89
102
  file: bin,
@@ -224,6 +237,7 @@ function answer(run, binPath, extras) {
224
237
  skills: extras.skills,
225
238
  usage: extras.usage,
226
239
  tools: extras.tools,
240
+ plugins,
227
241
  }),
228
242
  )}\n`,
229
243
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bluewombat/slots",
3
- "version": "0.5.0",
3
+ "version": "0.7.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.5.0"
56
+ "@bluewombat/slot-kit": "^0.7.0",
57
+ "@pinkpuma/deck": "1.0.0"
57
58
  }
58
59
  }