@bluewombat/slots 0.1.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.
@@ -0,0 +1,102 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ BUILDER_FLAGS,
4
+ emitFailure,
5
+ fillPrompt,
6
+ finishRun,
7
+ loadPrompt,
8
+ loadRules,
9
+ PROMPT_RULES,
10
+ parseRole,
11
+ spawnFilled,
12
+ take,
13
+ textOption,
14
+ transcriptArgs,
15
+ } from "@bluewombat/slot-kit";
16
+ import { attemptOf } from "../lib/fill.mjs";
17
+
18
+ const NAME = "assembly fix";
19
+
20
+ const DEFAULT_TEMPLATE = `You are a coding agent working unattended in a checkout of the project.
21
+
22
+ This directory is the assembled feature. The units of work that made it have
23
+ already been validated. An earlier judgement of the whole refused it. Do not
24
+ redo those units. Fix what the refusal names.
25
+
26
+ Refused by: {{refused_by}}
27
+
28
+ What it said:
29
+
30
+ \`\`\`raw
31
+ {{report}}
32
+ \`\`\`
33
+
34
+ YOUR WORK NOW IS TO RESOLVE THAT. Change what it takes and nothing more; what is
35
+ already here and passes is not to be redone.
36
+
37
+ ## Original request
38
+
39
+ This was asked in the first place, and still holds:
40
+
41
+ {{task}}
42
+
43
+ ## Rules of this run
44
+
45
+ {{rules}}
46
+ `;
47
+
48
+ const report = (take(process.argv, "--report") ?? "").trim();
49
+ if (report.length === 0) {
50
+ emitFailure("fail-blocking", "The assembly fix command needs --report.");
51
+ process.exit(1);
52
+ }
53
+
54
+ const intention = (take(process.argv, "--intention") ?? "").trim();
55
+ if (intention.length === 0) {
56
+ emitFailure("fail-blocking", "The Builder received no --intention.");
57
+ process.exit(1);
58
+ }
59
+
60
+ const parsed = parseRole(process.argv, BUILDER_FLAGS, NAME);
61
+ if (!parsed.ok) {
62
+ emitFailure("fail-blocking", parsed.reason);
63
+ process.exit(1);
64
+ }
65
+
66
+ const template = loadPrompt(parsed.options, DEFAULT_TEMPLATE);
67
+ if (!template.ok) {
68
+ emitFailure("fail-blocking", template.reason);
69
+ process.exit(1);
70
+ }
71
+ const rules = loadRules(parsed.options, PROMPT_RULES);
72
+ if (!rules.ok) {
73
+ emitFailure("fail-blocking", rules.reason);
74
+ process.exit(1);
75
+ }
76
+
77
+ const refusedBy = (take(process.argv, "--report-from") ?? "").trim();
78
+ const rendered = fillPrompt(
79
+ template.value,
80
+ {
81
+ task: intention,
82
+ report,
83
+ refused_by:
84
+ refusedBy.length > 0 ? refusedBy : "the producer of the last attempt, which did not finish",
85
+ rules: rules.value.trim(),
86
+ id: take(process.argv, "--id") ?? "",
87
+ attempt: String(attemptOf(process.argv)),
88
+ },
89
+ ["task", "report", "rules"],
90
+ textOption(parsed.options, "promptFile") ?? "built-in template",
91
+ );
92
+ if (!rendered.ok) {
93
+ emitFailure("fail-blocking", rendered.reason);
94
+ process.exit(1);
95
+ }
96
+
97
+ const spawned = await spawnFilled(parsed.runner, rendered.prompt, transcriptArgs(process.argv));
98
+ if (!spawned.ok) {
99
+ emitFailure("fail-blocking", spawned.reason);
100
+ process.exit(1);
101
+ }
102
+ finishRun({ run: spawned.run, bin: spawned.about.bin, name: spawned.about.name });
@@ -0,0 +1,85 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ BUILDER_FLAGS,
4
+ emitFailure,
5
+ fillPrompt,
6
+ finishRun,
7
+ loadPrompt,
8
+ loadRules,
9
+ PROMPT_RULES,
10
+ parseRole,
11
+ spawnFilled,
12
+ take,
13
+ textOption,
14
+ transcriptArgs,
15
+ } from "@bluewombat/slot-kit";
16
+ import { attemptOf, doneWhen } from "../lib/fill.mjs";
17
+
18
+ const NAME = "builder producer";
19
+
20
+ const DEFAULT_TEMPLATE = `You are a coding agent working unattended in a checkout of the project.
21
+
22
+ Task:
23
+ {{task}}
24
+
25
+ {{done_when}}
26
+
27
+ Rules of this run:
28
+ {{rules}}
29
+ `;
30
+
31
+ const report = (take(process.argv, "--report") ?? "").trim();
32
+ if (report.length > 0) {
33
+ emitFailure(
34
+ "fail-blocking",
35
+ "The builder producer is the first-pass command; a report belongs to the repair command.",
36
+ );
37
+ process.exit(1);
38
+ }
39
+
40
+ const intention = (take(process.argv, "--intention") ?? "").trim();
41
+ if (intention.length === 0) {
42
+ emitFailure("fail-blocking", "The Builder received no --intention.");
43
+ process.exit(1);
44
+ }
45
+
46
+ const parsed = parseRole(process.argv, BUILDER_FLAGS, NAME);
47
+ if (!parsed.ok) {
48
+ emitFailure("fail-blocking", parsed.reason);
49
+ process.exit(1);
50
+ }
51
+
52
+ const template = loadPrompt(parsed.options, DEFAULT_TEMPLATE);
53
+ if (!template.ok) {
54
+ emitFailure("fail-blocking", template.reason);
55
+ process.exit(1);
56
+ }
57
+ const rules = loadRules(parsed.options, PROMPT_RULES);
58
+ if (!rules.ok) {
59
+ emitFailure("fail-blocking", rules.reason);
60
+ process.exit(1);
61
+ }
62
+
63
+ const rendered = fillPrompt(
64
+ template.value,
65
+ {
66
+ task: intention,
67
+ done_when: doneWhen(take(process.argv, "--definition-of-done")),
68
+ rules: rules.value.trim(),
69
+ id: take(process.argv, "--id") ?? "",
70
+ attempt: String(attemptOf(process.argv)),
71
+ },
72
+ ["task", "rules"],
73
+ textOption(parsed.options, "promptFile") ?? "built-in template",
74
+ );
75
+ if (!rendered.ok) {
76
+ emitFailure("fail-blocking", rendered.reason);
77
+ process.exit(1);
78
+ }
79
+
80
+ const spawned = await spawnFilled(parsed.runner, rendered.prompt, transcriptArgs(process.argv));
81
+ if (!spawned.ok) {
82
+ emitFailure("fail-blocking", spawned.reason);
83
+ process.exit(1);
84
+ }
85
+ finishRun({ run: spawned.run, bin: spawned.about.bin, name: spawned.about.name });
@@ -0,0 +1,103 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ BUILDER_FLAGS,
4
+ emitFailure,
5
+ fillPrompt,
6
+ finishRun,
7
+ loadPrompt,
8
+ loadRules,
9
+ PROMPT_RULES,
10
+ parseRole,
11
+ spawnFilled,
12
+ take,
13
+ textOption,
14
+ transcriptArgs,
15
+ } from "@bluewombat/slot-kit";
16
+ import { attemptOf, doneWhen } from "../lib/fill.mjs";
17
+
18
+ const NAME = "builder repair";
19
+
20
+ const DEFAULT_TEMPLATE = `You are a coding agent working unattended in a checkout of the project.
21
+
22
+ What is in this directory is what an earlier attempt left. It was refused.
23
+
24
+ Refused by: {{refused_by}}
25
+
26
+ What it said:
27
+
28
+ \`\`\`raw
29
+ {{report}}
30
+ \`\`\`
31
+
32
+ YOUR WORK NOW IS TO RESOLVE THAT. Change what it takes and nothing more; what is
33
+ already here and passes is not to be redone.
34
+
35
+ ## Original request
36
+
37
+ This was asked in the first place, and still holds:
38
+
39
+ {{task}}
40
+
41
+ {{done_when}}
42
+
43
+ ## Rules of this run
44
+
45
+ {{rules}}
46
+ `;
47
+
48
+ const report = (take(process.argv, "--report") ?? "").trim();
49
+ if (report.length === 0) {
50
+ emitFailure("fail-blocking", "The builder repair command needs --report.");
51
+ process.exit(1);
52
+ }
53
+
54
+ const intention = (take(process.argv, "--intention") ?? "").trim();
55
+ if (intention.length === 0) {
56
+ emitFailure("fail-blocking", "The Builder received no --intention.");
57
+ process.exit(1);
58
+ }
59
+
60
+ const parsed = parseRole(process.argv, BUILDER_FLAGS, NAME);
61
+ if (!parsed.ok) {
62
+ emitFailure("fail-blocking", parsed.reason);
63
+ process.exit(1);
64
+ }
65
+
66
+ const template = loadPrompt(parsed.options, DEFAULT_TEMPLATE);
67
+ if (!template.ok) {
68
+ emitFailure("fail-blocking", template.reason);
69
+ process.exit(1);
70
+ }
71
+ const rules = loadRules(parsed.options, PROMPT_RULES);
72
+ if (!rules.ok) {
73
+ emitFailure("fail-blocking", rules.reason);
74
+ process.exit(1);
75
+ }
76
+
77
+ const refusedBy = (take(process.argv, "--report-from") ?? "").trim();
78
+ const rendered = fillPrompt(
79
+ template.value,
80
+ {
81
+ task: intention,
82
+ report,
83
+ refused_by:
84
+ refusedBy.length > 0 ? refusedBy : "the producer of the last attempt, which did not finish",
85
+ done_when: doneWhen(take(process.argv, "--definition-of-done")),
86
+ rules: rules.value.trim(),
87
+ id: take(process.argv, "--id") ?? "",
88
+ attempt: String(attemptOf(process.argv)),
89
+ },
90
+ ["task", "report", "rules"],
91
+ textOption(parsed.options, "promptFile") ?? "built-in template",
92
+ );
93
+ if (!rendered.ok) {
94
+ emitFailure("fail-blocking", rendered.reason);
95
+ process.exit(1);
96
+ }
97
+
98
+ const spawned = await spawnFilled(parsed.runner, rendered.prompt, transcriptArgs(process.argv));
99
+ if (!spawned.ok) {
100
+ emitFailure("fail-blocking", spawned.reason);
101
+ process.exit(1);
102
+ }
103
+ finishRun({ run: spawned.run, bin: spawned.about.bin, name: spawned.about.name });
@@ -0,0 +1,319 @@
1
+ #!/usr/bin/env node
2
+ import { spawnSync } from "node:child_process";
3
+ import { emitVerdict, GATE_FLAGS, ownArgv } from "@bluewombat/slot-kit";
4
+
5
+ // The work line's own checks, on the work this workspace published.
6
+ //
7
+ // It reads and nothing else: it does not push, does not open anything, does not
8
+ // merge. Whoever put the work in front of the checks did that before this Gate
9
+ // ran; this Gate only says whether the answer came back green.
10
+ //
11
+ // --remote NAME default: origin
12
+ // --poll-ms N how often to ask again while checks are running
13
+ // --token-env NAME required: the variable holding a token that can read
14
+ // the checks. No default on purpose — GITHUB_TOKEN is the
15
+ // one name `gh` reads ahead of its own login, so an
16
+ // operator must not export it, and a Gate that fell back
17
+ // to it failed only at the first assembly of a real run
18
+ // --require-checks wait for a check to appear; without it, a commit with no
19
+ // check at all passes
20
+
21
+ const API = "https://api.github.com";
22
+ // What the runner writes at the top of every step it runs.
23
+ const STEP_OPEN = "##[group]Run ";
24
+ const COLOUR = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g");
25
+
26
+ const cwd = process.cwd();
27
+ const options = parseOwnArgv(ownArgv(process.argv, GATE_FLAGS));
28
+ if (!options.ok) {
29
+ emitVerdict("fail-blocking", options.reason);
30
+ process.exit(0);
31
+ }
32
+
33
+ if (git(["rev-parse", "--is-inside-work-tree"]).stdout.trim() !== "true") {
34
+ emitVerdict("fail-blocking", "ci-green needs a git workspace.");
35
+ process.exit(0);
36
+ }
37
+
38
+ const branch = git(["rev-parse", "--abbrev-ref", "HEAD"]).stdout.trim();
39
+ if (branch.length === 0 || branch === "HEAD") {
40
+ emitVerdict(
41
+ "fail-blocking",
42
+ "ci-green needs a named branch; this workspace is on a detached HEAD.",
43
+ );
44
+ process.exit(0);
45
+ }
46
+
47
+ const repo = readRepo(options.remote);
48
+
49
+ const token = process.env[options.tokenEnv];
50
+ if (token === undefined || token.trim().length === 0) {
51
+ emitVerdict("fail-blocking", `ci-green needs a token in ${options.tokenEnv}.`);
52
+ process.exit(0);
53
+ }
54
+
55
+ const pull = await openPullFor(branch);
56
+ if (pull === undefined) {
57
+ // Publishing is somebody else's job. If nothing was put in front of the
58
+ // checks, no Attempt of this Task can change that.
59
+ emitVerdict("fail-blocking", `ci-green found no open pull request for "${branch}".`);
60
+ process.exit(0);
61
+ }
62
+
63
+ // Ask again until the answer settles. The ceiling is this Gate's own timeout:
64
+ // reaching it ends the Attempt, and the next one asks from scratch.
65
+ for (;;) {
66
+ const checks = await readChecks(pull.sha);
67
+ if (checks === undefined) {
68
+ emitVerdict("fail-blocking", "ci-green cannot read the checks. Is the token allowed to?");
69
+ process.exit(0);
70
+ }
71
+ if (checks.failed.length > 0) {
72
+ emitVerdict("fail-retryable", checks.failed.join("\n"));
73
+ process.exit(0);
74
+ }
75
+ if (checks.total === 0 && options.requireChecks) {
76
+ // Just after a push there is a window where the checks are not created
77
+ // yet, and an empty list then says nothing. Ask again; the Gate's timeout
78
+ // is what ends the wait on a commit no check ever picks up.
79
+ await sleep(options.pollMs);
80
+ continue;
81
+ }
82
+ if (checks.running === 0) {
83
+ emitVerdict("pass");
84
+ process.exit(0);
85
+ }
86
+ await sleep(options.pollMs);
87
+ }
88
+
89
+ /**
90
+ * @param {readonly string[]} tokens
91
+ * @returns {{ ok: true; remote: string; pollMs: number; tokenEnv: string; requireChecks: boolean } | { ok: false; reason: string }}
92
+ */
93
+ function parseOwnArgv(tokens) {
94
+ let remote = "origin";
95
+ let pollMs = 15_000;
96
+ let tokenEnv;
97
+ let requireChecks = false;
98
+ for (let i = 0; i < tokens.length; i++) {
99
+ const token = tokens[i];
100
+ if (token === "--require-checks") {
101
+ requireChecks = true;
102
+ continue;
103
+ }
104
+ const value = tokens[i + 1];
105
+ if (value === undefined) {
106
+ return { ok: false, reason: `ci-green needs a value after ${token}.` };
107
+ }
108
+ i += 1;
109
+ if (token === "--remote") {
110
+ remote = value;
111
+ } else if (token === "--token-env") {
112
+ tokenEnv = value;
113
+ } else if (token === "--poll-ms") {
114
+ const parsed = Number.parseInt(value, 10);
115
+ if (!Number.isInteger(parsed) || parsed <= 0) {
116
+ return { ok: false, reason: `--poll-ms must be a positive integer, not "${value}".` };
117
+ }
118
+ pollMs = parsed;
119
+ } else {
120
+ return { ok: false, reason: `ci-green does not take "${token}".` };
121
+ }
122
+ }
123
+ if (tokenEnv === undefined) {
124
+ return {
125
+ ok: false,
126
+ reason:
127
+ "ci-green needs --token-env NAME: the environment variable holding a token that can read the checks (the same one @bluewombat/manager-github's tokenEnv names, never GITHUB_TOKEN).",
128
+ };
129
+ }
130
+ return { ok: true, remote, pollMs, tokenEnv, requireChecks };
131
+ }
132
+
133
+ /**
134
+ * The repository the remote points at, or the verdict that there is none. A
135
+ * function rather than a guard so the helpers below can rely on it.
136
+ * @param {string} remote
137
+ * @returns {{ owner: string; name: string }}
138
+ */
139
+ function readRepo(remote) {
140
+ const found = repoFrom(git(["remote", "get-url", remote]).stdout.trim());
141
+ if (found === undefined) {
142
+ emitVerdict(
143
+ "fail-blocking",
144
+ `ci-green cannot read a GitHub repository from the "${remote}" remote.`,
145
+ );
146
+ process.exit(0);
147
+ }
148
+ return found;
149
+ }
150
+
151
+ /**
152
+ * `owner/name` from either remote form.
153
+ * @param {string} url
154
+ */
155
+ function repoFrom(url) {
156
+ const matched = /github\.com[:/]([^/]+)\/(.+?)(?:\.git)?$/.exec(url);
157
+ if (matched?.[1] === undefined || matched[2] === undefined) {
158
+ return undefined;
159
+ }
160
+ return { owner: matched[1], name: matched[2] };
161
+ }
162
+
163
+ /**
164
+ * @param {string} head
165
+ * @returns {Promise<{ number: number; sha: string } | undefined>}
166
+ */
167
+ async function openPullFor(head) {
168
+ const listed = await api(
169
+ `/repos/${repo.owner}/${repo.name}/pulls?head=${encodeURIComponent(`${repo.owner}:${head}`)}&state=open&per_page=1`,
170
+ );
171
+ const first = Array.isArray(listed) ? record(listed[0]) : undefined;
172
+ const headOf = first === undefined ? undefined : record(first.head);
173
+ if (first === undefined || headOf === undefined) {
174
+ return undefined;
175
+ }
176
+ return typeof first.number === "number" && typeof headOf.sha === "string"
177
+ ? { number: first.number, sha: headOf.sha }
178
+ : undefined;
179
+ }
180
+
181
+ /** @param {string} sha */
182
+ async function readChecks(sha) {
183
+ const body = record(
184
+ await api(`/repos/${repo.owner}/${repo.name}/commits/${sha}/check-runs?per_page=100`),
185
+ );
186
+ const checkRuns = body?.check_runs;
187
+ if (!Array.isArray(checkRuns)) {
188
+ return undefined;
189
+ }
190
+ /** @type {string[]} */
191
+ const failed = [];
192
+ // One workflow can run the same job for two events (push, pull_request)
193
+ // on the same commit: same name, same log. Said once.
194
+ const seen = new Set();
195
+ let running = 0;
196
+ for (const entry of checkRuns) {
197
+ const run = record(entry) ?? {};
198
+ if (run.status !== "completed") {
199
+ running += 1;
200
+ continue;
201
+ }
202
+ if (
203
+ run.conclusion === "success" ||
204
+ run.conclusion === "neutral" ||
205
+ run.conclusion === "skipped"
206
+ ) {
207
+ continue;
208
+ }
209
+ const heading = `${run.name}: ${run.conclusion ?? "failed"}`;
210
+ if (seen.has(heading)) {
211
+ continue;
212
+ }
213
+ seen.add(heading);
214
+ failed.push(`${heading}${typeof run.id === "number" ? await logOf(run.id) : ""}`);
215
+ }
216
+ return { total: checkRuns.length, running, failed };
217
+ }
218
+
219
+ /**
220
+ * The step that failed, out of a failing job's log.
221
+ *
222
+ * A check name and the word "failure" tell the next Attempt nothing it can act
223
+ * on. The reason is in the log — but so is the runner provisioning itself, every
224
+ * step that passed, and the cleanup afterwards, and a reader handed all of that
225
+ * is no better off than one handed none of it.
226
+ *
227
+ * One step refused the work. The runner marks where it broke with `##[error]`
228
+ * and opens every step with `##[group]Run `, so the span from the last such
229
+ * opening to that error is the failing step and nothing else. It is the runner's
230
+ * own structure that says where to cut, not a count of lines chosen here.
231
+ */
232
+ /** @param {number} jobId */
233
+ async function logOf(jobId) {
234
+ const text = await api(`/repos/${repo.owner}/${repo.name}/actions/jobs/${jobId}/logs`, "text");
235
+ if (typeof text !== "string") {
236
+ return "";
237
+ }
238
+ const lines = text
239
+ .split("\n")
240
+ .map((line) =>
241
+ line
242
+ .replace(/^\S+Z\s/, "")
243
+ .replace(COLOUR, "")
244
+ .trimEnd(),
245
+ )
246
+ .filter((line) => line.trim().length > 0);
247
+
248
+ // A job can end without the runner marking anything — cancelled, or killed
249
+ // from outside. Then the last thing it did is the closest there is.
250
+ let end = -1;
251
+ for (let at = 0; at < lines.length; at++) {
252
+ if (lines[at]?.includes("##[error]")) {
253
+ end = at;
254
+ }
255
+ }
256
+ if (end < 0) {
257
+ end = lines.length - 1;
258
+ }
259
+
260
+ let start = 0;
261
+ for (let at = end; at >= 0; at--) {
262
+ if (lines[at]?.startsWith(STEP_OPEN)) {
263
+ start = at;
264
+ break;
265
+ }
266
+ }
267
+
268
+ const kept = lines
269
+ .slice(start, end + 1)
270
+ .map((line) => line.replace(/^##\[(?:group|endgroup)\]/, ""))
271
+ .filter((line) => line.length > 0);
272
+ return kept.length === 0 ? "" : `\n${kept.join("\n")}`;
273
+ }
274
+
275
+ /**
276
+ * @param {string} path
277
+ * @param {"json" | "text"} [as]
278
+ * @returns {Promise<unknown>}
279
+ */
280
+ async function api(path, as = "json") {
281
+ try {
282
+ const response = await fetch(`${API}${path}`, {
283
+ headers: {
284
+ authorization: `Bearer ${token}`,
285
+ accept: "application/vnd.github+json",
286
+ "user-agent": "ci-green-gate",
287
+ },
288
+ });
289
+ if (!response.ok) {
290
+ return undefined;
291
+ }
292
+ return as === "text" ? await response.text() : await response.json();
293
+ } catch {
294
+ return undefined;
295
+ }
296
+ }
297
+
298
+ /** @param {number} ms */
299
+ function sleep(ms) {
300
+ return new Promise((resolve) => {
301
+ setTimeout(resolve, ms);
302
+ });
303
+ }
304
+
305
+ /** @param {string[]} args */
306
+ function git(args) {
307
+ return spawnSync("git", ["-C", cwd, ...args], { encoding: "utf8" });
308
+ }
309
+
310
+ /**
311
+ * A JSON object, or nothing: GitHub's answers are read field by field.
312
+ * @param {unknown} value
313
+ * @returns {Record<string, unknown> | undefined}
314
+ */
315
+ function record(value) {
316
+ return value !== null && typeof value === "object" && !Array.isArray(value)
317
+ ? /** @type {Record<string, unknown>} */ (value)
318
+ : undefined;
319
+ }