@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 +78 -8
- package/agents/agent-plugins.mjs +231 -0
- package/agents/claude.mjs +24 -0
- package/agents/cursor.mjs +124 -14
- package/assembly/fix.mjs +14 -0
- package/builders/producer.mjs +14 -0
- package/builders/repair.mjs +14 -0
- package/gates/ci-green.mjs +60 -7
- package/package.json +3 -2
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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 = {
|
|
145
|
-
|
|
146
|
-
|
|
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
|
|
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
|
|
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",
|
package/builders/producer.mjs
CHANGED
|
@@ -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") ?? "",
|
package/builders/repair.mjs
CHANGED
|
@@ -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",
|
package/gates/ci-green.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { spawnSync } from "node:child_process";
|
|
3
|
-
import { emitVerdict, GATE_FLAGS, ownArgv } from "@bluewombat/slot-kit";
|
|
3
|
+
import { emitVerdict, GATE_FLAGS, ownArgv, take } from "@bluewombat/slot-kit";
|
|
4
4
|
|
|
5
5
|
// The work line's own checks, on the work this workspace published: GitHub
|
|
6
6
|
// Actions' check runs, and the commit statuses CI outside Actions reports
|
|
@@ -20,6 +20,12 @@ import { emitVerdict, GATE_FLAGS, ownArgv } from "@bluewombat/slot-kit";
|
|
|
20
20
|
// to it failed only at the first assembly of a real run
|
|
21
21
|
// --require-checks wait for a check to appear; without it, a commit with no
|
|
22
22
|
// check at all passes
|
|
23
|
+
//
|
|
24
|
+
// It also receives `--timeout-ms` from Implementer: the ceiling it will be
|
|
25
|
+
// killed at. A Gate that waits on a CI it does not run must not wait to be cut
|
|
26
|
+
// off: a kill reads as "refused", and a refusal sends the work to an agent that
|
|
27
|
+
// has nothing to fix. Near that ceiling it says so itself, `fail-blocking`, with
|
|
28
|
+
// what it was waiting for.
|
|
23
29
|
|
|
24
30
|
// What the runner writes at the top of every step it runs.
|
|
25
31
|
const STEP_OPEN = "##[group]Run ";
|
|
@@ -63,8 +69,10 @@ if (pull === undefined) {
|
|
|
63
69
|
process.exit(0);
|
|
64
70
|
}
|
|
65
71
|
|
|
66
|
-
// Ask again until the answer settles
|
|
67
|
-
//
|
|
72
|
+
// Ask again until the answer settles, or until just before Implementer would
|
|
73
|
+
// kill this Gate (`--timeout-ms`); without that flag, until it does.
|
|
74
|
+
const startedAt = Date.now();
|
|
75
|
+
const stopAt = stopTime(startedAt, take(process.argv, "--timeout-ms"));
|
|
68
76
|
for (;;) {
|
|
69
77
|
const checks = await readChecks(pull.sha);
|
|
70
78
|
if (checks === undefined) {
|
|
@@ -77,16 +85,61 @@ for (;;) {
|
|
|
77
85
|
}
|
|
78
86
|
if (checks.total === 0 && options.requireChecks) {
|
|
79
87
|
// Just after a push there is a window where the checks are not created
|
|
80
|
-
// yet, and an empty list then says nothing. Ask again
|
|
81
|
-
//
|
|
82
|
-
|
|
88
|
+
// yet, and an empty list then says nothing. Ask again, until the wait
|
|
89
|
+
// runs out: a commit no check ever picks up is not a refusal of the work.
|
|
90
|
+
if (Date.now() >= stopAt) {
|
|
91
|
+
stopWaiting(pull, "no check has appeared");
|
|
92
|
+
}
|
|
93
|
+
await sleep(Math.min(options.pollMs, Math.max(0, stopAt - Date.now())));
|
|
83
94
|
continue;
|
|
84
95
|
}
|
|
85
96
|
if (checks.running === 0) {
|
|
86
97
|
emitVerdict("pass");
|
|
87
98
|
process.exit(0);
|
|
88
99
|
}
|
|
89
|
-
|
|
100
|
+
if (Date.now() >= stopAt) {
|
|
101
|
+
stopWaiting(
|
|
102
|
+
pull,
|
|
103
|
+
`${checks.running} check${checks.running === 1 ? " was" : "s were"} still running`,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
await sleep(Math.min(options.pollMs, Math.max(0, stopAt - Date.now())));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The moment to stop waiting: a margin before Implementer's ceiling (5 %, between
|
|
111
|
+
* 2 and 30 seconds, and at most half of it), so the verdict is out before the
|
|
112
|
+
* kill. `Infinity` when Implementer gave no ceiling.
|
|
113
|
+
*
|
|
114
|
+
* @param {number} from
|
|
115
|
+
* @param {string | undefined} raw
|
|
116
|
+
*/
|
|
117
|
+
function stopTime(from, raw) {
|
|
118
|
+
const ceiling = Number.parseInt(raw ?? "", 10);
|
|
119
|
+
if (!Number.isInteger(ceiling) || ceiling <= 0) {
|
|
120
|
+
return Number.POSITIVE_INFINITY;
|
|
121
|
+
}
|
|
122
|
+
const margin = Math.min(Math.max(2_000, ceiling * 0.05), 30_000, ceiling / 2);
|
|
123
|
+
return from + ceiling - margin;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The CI did not answer. That says nothing of the work, and nothing the work
|
|
128
|
+
* could change: blocking, so a person reads it instead of an agent being sent
|
|
129
|
+
* to repair what was never judged.
|
|
130
|
+
*
|
|
131
|
+
* @param {{ number: number; sha: string }} pr
|
|
132
|
+
* @param {string} what
|
|
133
|
+
* @returns {never}
|
|
134
|
+
*/
|
|
135
|
+
function stopWaiting(pr, what) {
|
|
136
|
+
const seconds = Math.round((Date.now() - startedAt) / 1000);
|
|
137
|
+
const lasted = seconds >= 120 ? `${Math.round(seconds / 60)} min` : `${seconds} s`;
|
|
138
|
+
emitVerdict(
|
|
139
|
+
"fail-blocking",
|
|
140
|
+
`ci-green stopped waiting after ${lasted}: ${what} on ${pr.sha.slice(0, 7)} (pull request #${pr.number}). The CI did not answer in time; this is not a refusal of the work, and no change to it can make the CI answer.`,
|
|
141
|
+
);
|
|
142
|
+
process.exit(0);
|
|
90
143
|
}
|
|
91
144
|
|
|
92
145
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bluewombat/slots",
|
|
3
|
-
"version": "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.
|
|
56
|
+
"@bluewombat/slot-kit": "^0.8.0",
|
|
57
|
+
"@pinkpuma/deck": "1.0.0"
|
|
57
58
|
}
|
|
58
59
|
}
|