@bluewombat/slots 0.9.0 → 0.10.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,124 @@
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
+ // The Ticket that holds a Feature's e2e acceptance suite. One command for its first pass and for
19
+ // a pass a Gate refused: the suite is written once from the scenarios of the task, and a refusal
20
+ // is about its form, so there is no second command to tell apart. `{{report}}` and
21
+ // `{{refused_by}}` are empty on a first pass; `{{refusal}}` is a ready-made paragraph about them.
22
+
23
+ const NAME = "builder e2e";
24
+
25
+ const DEFAULT_TEMPLATE = `You are a coding agent working unattended in a checkout of the project.
26
+
27
+ You write the acceptance suite of a feature. The task below lists its scenarios, agreed with the
28
+ operator before development, each with an id (E1, E2, ...).
29
+
30
+ Task:
31
+ {{task}}
32
+
33
+ - Write one test per scenario, in the end-to-end framework this project already uses, and
34
+ start the name of each test with its scenario id ("E1 …"). Follow the scenario's steps and
35
+ expected result as written; do not add, merge or drop one.
36
+ - List the ids in the manifest \`e2e/scenarios.json\`, a JSON array: ["E1", "E2"].
37
+ - Write only inside the suite folder (\`e2e/\`). Touch no file of the application, and add no
38
+ dependency it does not already have.
39
+ - The feature is not finished here and the application is not started: do not run the suite. Check
40
+ only that it compiles, with the project's own typecheck or build command.
41
+
42
+ {{refusal}}
43
+
44
+ {{history}}
45
+
46
+ ## When you cannot act
47
+
48
+ If what you were given is not enough to act on (a scenario is ambiguous, the framework is missing,
49
+ the cause is outside this directory), do not invent a change. Change nothing and end your final
50
+ 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
+
56
+ Rules of this run:
57
+ {{rules}}
58
+ `;
59
+
60
+ const intention = (take(process.argv, "--intention") ?? "").trim();
61
+ if (intention.length === 0) {
62
+ emitFailure("fail-blocking", "The Builder received no --intention.");
63
+ process.exit(1);
64
+ }
65
+
66
+ const parsed = parseRole(process.argv, BUILDER_FLAGS, NAME);
67
+ if (!parsed.ok) {
68
+ emitFailure("fail-blocking", parsed.reason);
69
+ process.exit(1);
70
+ }
71
+
72
+ const template = loadPrompt(parsed.options, DEFAULT_TEMPLATE);
73
+ if (!template.ok) {
74
+ emitFailure("fail-blocking", template.reason);
75
+ process.exit(1);
76
+ }
77
+ const rules = loadRules(parsed.options, PROMPT_RULES);
78
+ if (!rules.ok) {
79
+ emitFailure("fail-blocking", rules.reason);
80
+ process.exit(1);
81
+ }
82
+
83
+ const report = (take(process.argv, "--report") ?? "").trim();
84
+ const refusedBy = (take(process.argv, "--report-from") ?? "").trim();
85
+ const refusal =
86
+ report.length === 0
87
+ ? ""
88
+ : [
89
+ "What is in this directory is what an earlier attempt left. It was refused",
90
+ `${refusedBy.length > 0 ? `by ${refusedBy}` : "by a check"}:`,
91
+ "",
92
+ "```raw",
93
+ report,
94
+ "```",
95
+ "",
96
+ "Resolve that, change what it takes and nothing more.",
97
+ ].join("\n");
98
+
99
+ const rendered = fillPrompt(
100
+ template.value,
101
+ {
102
+ task: intention,
103
+ history: (take(process.argv, "--history") ?? "").trim(),
104
+ refusal,
105
+ report,
106
+ refused_by: refusedBy,
107
+ rules: rules.value.trim(),
108
+ id: take(process.argv, "--id") ?? "",
109
+ attempt: String(attemptOf(process.argv)),
110
+ },
111
+ ["task", "rules"],
112
+ textOption(parsed.options, "promptFile") ?? "built-in template",
113
+ );
114
+ if (!rendered.ok) {
115
+ emitFailure("fail-blocking", rendered.reason);
116
+ process.exit(1);
117
+ }
118
+
119
+ const spawned = await spawnFilled(parsed.runner, rendered.prompt, transcriptArgs(process.argv));
120
+ if (!spawned.ok) {
121
+ emitFailure("fail-blocking", spawned.reason);
122
+ process.exit(1);
123
+ }
124
+ finishRun({ run: spawned.run, bin: spawned.about.bin, name: spawned.about.name });
@@ -25,6 +25,10 @@ Task:
25
25
  This checkout is the project as it stands. Add or change only what this task
26
26
  names, and leave the project's own checks green on the whole tree.
27
27
 
28
+ When the latest commit of this checkout is titled "Tests: …", the tests of this task were
29
+ written before the code and committed. They are the specification: make them pass as they
30
+ are. Do not edit, delete or rename them.
31
+
28
32
  {{history}}
29
33
 
30
34
  ## When you cannot act
@@ -38,6 +38,10 @@ This was asked in the first place, and still holds:
38
38
 
39
39
  {{task}}
40
40
 
41
+ When the latest commit of this checkout is titled "Tests: …", the tests of this task were
42
+ written before the code and committed. They are the specification: make them pass as they
43
+ are. Do not edit, delete or rename them.
44
+
41
45
  {{history}}
42
46
 
43
47
  ## When you cannot act
@@ -0,0 +1,139 @@
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 = "builder tests";
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
+ YOUR WORK NOW IS TO WRITE THE TESTS OF THIS TASK, AND ONLY THE TESTS. Another agent
26
+ writes the code afterwards and must make your tests pass without editing them. A test it
27
+ cannot pass is not sent back to you: it stops the task and waits for a person. So a test
28
+ you deliver must be one that a correct implementation of the task passes, and you prove
29
+ it before you hand it over (see below).
30
+
31
+ ## The cases
32
+
33
+ - The task has a "Tests" section: write one test per case of it, in the file and the
34
+ framework it names (or the ones this repository already uses), each named after the
35
+ case it covers. Test the behaviour at the seam the section names, not how it is built.
36
+ - The task has no "Tests" section: do not look for one elsewhere. Derive the cases from
37
+ its acceptance criteria ("Done when" or the like), one case per criterion, and name each
38
+ test after the criterion and the identifiers it carries. A criterion that describes two
39
+ sets of data (sixty rows for a page, more than a cap for the cap) is two cases, never one
40
+ test with two batches of data.
41
+ - Do not write the behaviour. If the interface under test does not exist yet, create the
42
+ minimal stub — the name and signature the task gives, a body that does nothing useful —
43
+ so that the tests compile and fail on what they check, not on a missing import.
44
+ - Add or update no other test, and no production code beyond the stubs.
45
+
46
+ ## How a test stays passable
47
+
48
+ - One data set per test, seeded at the top. Every expectation derives from that set: the
49
+ rows a filter keeps, the order they take, the page a limit and an offset cut out of it.
50
+ Write the expected count from the seeded data, not as a figure recounted by hand.
51
+ - To observe a bound, seed past it: a cap of 200 is only visible with more than 200 rows
52
+ that the filters keep. "At most N" is asserted with \`<= N\`, or by seeding more than N;
53
+ never as exactly N when fewer exist.
54
+
55
+ ## Prove them: red, green, red
56
+
57
+ A test you have only seen fail proves nothing about itself: a test no implementation
58
+ could pass fails exactly the same way. The tests must FAIL now, because the behaviour is
59
+ missing, and you prove every one of them passable before you finish:
60
+
61
+ 1. **Red.** Run the tests. Each fails on an assertion (not on an import, a type, a syntax
62
+ error).
63
+ 2. **Green.** In the stub bodies — the same files, no new file — write the shortest
64
+ implementation of the task that makes every test pass: naive, in memory, unoptimised;
65
+ it is thrown away. Run the tests. A test still red here is wrong, not the task: fix its
66
+ data or its expectation (never drop a case, never replace a value the task gives with a
67
+ looser one), and run again until all are green.
68
+ 3. **Red again.** Put every stub body back to no behaviour. Run the tests: each fails
69
+ again on an assertion. Leave the tree in this state.
70
+
71
+ Leave no trace of the green step: no helper, no import, no comment. The only non-test
72
+ lines you deliver are the stubs.
73
+
74
+ {{history}}
75
+
76
+ ## When you cannot act
77
+
78
+ If the cases are missing, or contradict the task or the code, do not invent them. Change
79
+ nothing and end your final message with exactly one line:
80
+
81
+ MASON_BLOCKED: <what is missing, in one line>
82
+
83
+ A person reads that line and decides. It is a correct answer, not a failure.
84
+
85
+ Rules of this run:
86
+ {{rules}}
87
+ `;
88
+
89
+ // A retry carries the refusal in --history; the flags themselves are consumed so the
90
+ // role options see only their own.
91
+ take(process.argv, "--report");
92
+ take(process.argv, "--report-from");
93
+
94
+ const intention = (take(process.argv, "--intention") ?? "").trim();
95
+ if (intention.length === 0) {
96
+ emitFailure("fail-blocking", "The Builder received no --intention.");
97
+ process.exit(1);
98
+ }
99
+
100
+ const parsed = parseRole(process.argv, BUILDER_FLAGS, NAME);
101
+ if (!parsed.ok) {
102
+ emitFailure("fail-blocking", parsed.reason);
103
+ process.exit(1);
104
+ }
105
+
106
+ const template = loadPrompt(parsed.options, DEFAULT_TEMPLATE);
107
+ if (!template.ok) {
108
+ emitFailure("fail-blocking", template.reason);
109
+ process.exit(1);
110
+ }
111
+ const rules = loadRules(parsed.options, PROMPT_RULES);
112
+ if (!rules.ok) {
113
+ emitFailure("fail-blocking", rules.reason);
114
+ process.exit(1);
115
+ }
116
+
117
+ const rendered = fillPrompt(
118
+ template.value,
119
+ {
120
+ task: intention,
121
+ history: (take(process.argv, "--history") ?? "").trim(),
122
+ rules: rules.value.trim(),
123
+ id: take(process.argv, "--id") ?? "",
124
+ attempt: String(attemptOf(process.argv)),
125
+ },
126
+ ["task", "rules"],
127
+ textOption(parsed.options, "promptFile") ?? "built-in template",
128
+ );
129
+ if (!rendered.ok) {
130
+ emitFailure("fail-blocking", rendered.reason);
131
+ process.exit(1);
132
+ }
133
+
134
+ const spawned = await spawnFilled(parsed.runner, rendered.prompt, transcriptArgs(process.argv));
135
+ if (!spawned.ok) {
136
+ emitFailure("fail-blocking", spawned.reason);
137
+ process.exit(1);
138
+ }
139
+ finishRun({ run: spawned.run, bin: spawned.about.bin, name: spawned.about.name });
@@ -0,0 +1,134 @@
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 = "feature fix";
19
+
20
+ const DEFAULT_TEMPLATE = `You are a coding agent working unattended in a checkout of the project.
21
+
22
+ This directory is a whole feature: the work of all its tickets, already
23
+ assembled and checked ticket by ticket. It was then brought up to date with the
24
+ branch it will be merged into, and checked again as a whole. Something refused
25
+ it after that: a merge conflict while bringing it up to date, a check that fails
26
+ on the result, or the project's CI. Do not redo the feature, or any ticket of
27
+ it. Fix what the refusal names.
28
+
29
+ Refused by: {{refused_by}}
30
+
31
+ What it said:
32
+
33
+ \`\`\`raw
34
+ {{report}}
35
+ \`\`\`
36
+
37
+ YOUR WORK NOW IS TO RESOLVE THAT. Change what it takes and nothing more; what is
38
+ already here and passes is not to be redone.
39
+
40
+ ## According to who refused
41
+
42
+ - **A merge conflict** (refused by \`align\`): the report names the branch the
43
+ feature is merged into. Look at what that branch changed since this feature
44
+ started (compare the two with git), then edit the files in conflict so that
45
+ both sets of changes hold: what the branch added stays, and the feature still
46
+ does what it asks. Do not drop the branch's changes to keep yours. Leave the
47
+ result uncommitted.
48
+ - **A check** (a gate named by its id, such as the typecheck or the tests): run
49
+ the failing command here the way the gate ran it, see the error, fix the
50
+ cause. The error often comes from what the target branch brought in, or from
51
+ two tickets that were each fine alone: adapt the feature's code to it.
52
+ - **The CI**: reproduce the failing job's command here, then fix it. Change the
53
+ work, not the CI definition.
54
+
55
+ ## Original request
56
+
57
+ This was asked in the first place, and still holds:
58
+
59
+ {{task}}
60
+
61
+ {{history}}
62
+
63
+ ## When you cannot act
64
+
65
+ If what you were given is not enough to act on (the refusal names nothing you
66
+ can read or run here, the request contradicts the rules of this run, or the
67
+ cause is outside this directory), do not invent a change. Change nothing and
68
+ end your final message with exactly one line:
69
+
70
+ MASON_BLOCKED: <what is missing, in one line>
71
+
72
+ A person reads that line and decides. It is a correct answer, not a failure.
73
+
74
+ ## Rules of this run
75
+
76
+ {{rules}}
77
+ `;
78
+
79
+ const report = (take(process.argv, "--report") ?? "").trim();
80
+ if (report.length === 0) {
81
+ emitFailure("fail-blocking", "The feature fix command needs --report.");
82
+ process.exit(1);
83
+ }
84
+
85
+ const intention = (take(process.argv, "--intention") ?? "").trim();
86
+ if (intention.length === 0) {
87
+ emitFailure("fail-blocking", "The Builder received no --intention.");
88
+ process.exit(1);
89
+ }
90
+
91
+ const parsed = parseRole(process.argv, BUILDER_FLAGS, NAME);
92
+ if (!parsed.ok) {
93
+ emitFailure("fail-blocking", parsed.reason);
94
+ process.exit(1);
95
+ }
96
+
97
+ const template = loadPrompt(parsed.options, DEFAULT_TEMPLATE);
98
+ if (!template.ok) {
99
+ emitFailure("fail-blocking", template.reason);
100
+ process.exit(1);
101
+ }
102
+ const rules = loadRules(parsed.options, PROMPT_RULES);
103
+ if (!rules.ok) {
104
+ emitFailure("fail-blocking", rules.reason);
105
+ process.exit(1);
106
+ }
107
+
108
+ const refusedBy = (take(process.argv, "--report-from") ?? "").trim();
109
+ const rendered = fillPrompt(
110
+ template.value,
111
+ {
112
+ task: intention,
113
+ history: (take(process.argv, "--history") ?? "").trim(),
114
+ report,
115
+ refused_by:
116
+ refusedBy.length > 0 ? refusedBy : "the producer of the last attempt, which did not finish",
117
+ rules: rules.value.trim(),
118
+ id: take(process.argv, "--id") ?? "",
119
+ attempt: String(attemptOf(process.argv)),
120
+ },
121
+ ["task", "report", "rules"],
122
+ textOption(parsed.options, "promptFile") ?? "built-in template",
123
+ );
124
+ if (!rendered.ok) {
125
+ emitFailure("fail-blocking", rendered.reason);
126
+ process.exit(1);
127
+ }
128
+
129
+ const spawned = await spawnFilled(parsed.runner, rendered.prompt, transcriptArgs(process.argv));
130
+ if (!spawned.ok) {
131
+ emitFailure("fail-blocking", spawned.reason);
132
+ process.exit(1);
133
+ }
134
+ finishRun({ run: spawned.run, bin: spawned.about.bin, name: spawned.about.name });
@@ -0,0 +1,205 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ BUILDER_FLAGS,
4
+ emitFailure,
5
+ failureOf,
6
+ fillPrompt,
7
+ loadPrompt,
8
+ loadRules,
9
+ parseRole,
10
+ readResult,
11
+ spawnFilled,
12
+ take,
13
+ textOption,
14
+ transcriptArgs,
15
+ } from "@bluewombat/slot-kit";
16
+ import { attemptOf } from "../lib/fill.mjs";
17
+
18
+ const NAME = "feature validate";
19
+
20
+ const DEFAULT_TEMPLATE = `You are a coding agent working unattended in a checkout of the project.
21
+
22
+ This directory is a whole feature: the work of all its tickets, already
23
+ assembled, and brought up to date with the branch it will be merged into. Each
24
+ ticket was checked and reviewed on its own. Your job now is different: read the
25
+ diff it carries against that branch, and judge whether the WHOLE feature meets
26
+ what was asked of the feature. Look for what no ticket covers, for what two
27
+ tickets contradict or leave half-connected, and for what the merge with the
28
+ branch broke. Do not re-judge each ticket. Do not redo the work. Do not change
29
+ anything — this is a read-only review, not a repair.
30
+
31
+ ## What was asked of the feature
32
+
33
+ {{task}}
34
+
35
+ ## Your answer
36
+
37
+ End your final message with exactly one line in this form, and nothing after
38
+ it:
39
+
40
+ - \`MASON_VERDICT: VALIDATED\` — the feature meets what was asked.
41
+ - \`MASON_VERDICT: REFUSED: <one paragraph>\` — it does not, and the paragraph
42
+ says what is missing or wrong, specifically enough for another agent to fix it
43
+ without redoing your review. Keep the whole paragraph on that one line.
44
+
45
+ Nothing else may start a line with \`MASON_VERDICT:\`. You may explain your
46
+ reasoning before it, but that line is what is read — write it last.
47
+
48
+ ## Rules of this run
49
+
50
+ {{rules}}
51
+ `;
52
+
53
+ /**
54
+ * A reviewer's bounds: read-only, and answer in the one shape the caller
55
+ * parses. `PROMPT_RULES` tells a producer to leave work uncommitted and push
56
+ * nothing — obligations a Task that writes nothing does not have.
57
+ */
58
+ const DEFAULT_RULES = [
59
+ "- Work only inside this directory. Do not read or edit anything outside it.",
60
+ "- This is a review, not a repair: do not change, stage, commit, or stash any file.",
61
+ "- Judge the diff against what was asked of the feature, not the style of the tickets that made it.",
62
+ "- Nobody is watching this run and nobody will answer a question. Where the intention is",
63
+ " ambiguous, take the reading most consistent with the existing code, and say which one",
64
+ " you took in your answer.",
65
+ "- End your final message with one line: MASON_VERDICT: VALIDATED, or",
66
+ " MASON_VERDICT: REFUSED: <one paragraph, on that one line>.",
67
+ ].join("\n");
68
+
69
+ const report = (take(process.argv, "--report") ?? "").trim();
70
+ if (report.length > 0) {
71
+ emitFailure(
72
+ "fail-blocking",
73
+ "feature.validate is a read-only review; it does not take --report — that is feature.fix's job.",
74
+ );
75
+ process.exit(1);
76
+ }
77
+
78
+ const intention = (take(process.argv, "--intention") ?? "").trim();
79
+ if (intention.length === 0) {
80
+ emitFailure("fail-blocking", "The Builder received no --intention.");
81
+ process.exit(1);
82
+ }
83
+
84
+ const parsed = parseRole(process.argv, BUILDER_FLAGS, NAME);
85
+ if (!parsed.ok) {
86
+ emitFailure("fail-blocking", parsed.reason);
87
+ process.exit(1);
88
+ }
89
+
90
+ const template = loadPrompt(parsed.options, DEFAULT_TEMPLATE);
91
+ if (!template.ok) {
92
+ emitFailure("fail-blocking", template.reason);
93
+ process.exit(1);
94
+ }
95
+ const rules = loadRules(parsed.options, DEFAULT_RULES);
96
+ if (!rules.ok) {
97
+ emitFailure("fail-blocking", rules.reason);
98
+ process.exit(1);
99
+ }
100
+
101
+ const rendered = fillPrompt(
102
+ template.value,
103
+ {
104
+ task: intention,
105
+ rules: rules.value.trim(),
106
+ id: take(process.argv, "--id") ?? "",
107
+ attempt: String(attemptOf(process.argv)),
108
+ },
109
+ ["task", "rules"],
110
+ textOption(parsed.options, "promptFile") ?? "built-in template",
111
+ );
112
+ if (!rendered.ok) {
113
+ emitFailure("fail-blocking", rendered.reason);
114
+ process.exit(1);
115
+ }
116
+
117
+ const spawned = await spawnFilled(parsed.runner, rendered.prompt, transcriptArgs(process.argv));
118
+ if (!spawned.ok) {
119
+ emitFailure("fail-blocking", spawned.reason);
120
+ process.exit(1);
121
+ }
122
+
123
+ answer(spawned.run, spawned.about.bin, spawned.about.name);
124
+
125
+ /**
126
+ * Read the agent's own verdict and map it through `emitFailure` — never
127
+ * through exit 0 alone. A review that completes cleanly still has to answer
128
+ * whether the work is right; the CLI exiting 0 only says the review ran.
129
+ *
130
+ * @param {import("@bluewombat/slot-kit").AgentRun} run
131
+ * @param {string} bin
132
+ * @param {string} name
133
+ * @returns {never}
134
+ */
135
+ function answer(run, bin, name) {
136
+ if (run.error !== undefined || run.code !== 0) {
137
+ const failure = failureOf(run, bin, name);
138
+ emitFailure(failure.outcome, failure.report);
139
+ process.exit(1);
140
+ }
141
+ const result = readResult(run.stdout);
142
+ if (result?.is_error === true) {
143
+ const failure = failureOf(run, bin, name);
144
+ emitFailure(failure.outcome, failure.report);
145
+ process.exit(1);
146
+ }
147
+ const said = typeof result?.result === "string" ? result.result.trim() : "";
148
+ const verdict = lastVerdictLine(said);
149
+ if (verdict === undefined) {
150
+ // No `MASON_VERDICT:` line at all is not a pass on the strength of exit 0.
151
+ // Treated as retryable, the same as any other malformed Attempt — the
152
+ // next round gets the same prompt again.
153
+ emitFailure(
154
+ "fail-retryable",
155
+ `feature.validate found no MASON_VERDICT: line in the answer: ${
156
+ said.length > 0 ? said.slice(0, 500) : "(no answer)"
157
+ }`,
158
+ );
159
+ process.exit(1);
160
+ }
161
+ const refused = /^REFUSED:?\s*/i.exec(verdict);
162
+ if (refused !== null) {
163
+ const why = verdict.slice(refused[0].length).trim();
164
+ emitFailure(
165
+ "fail-retryable",
166
+ why.length > 0 ? why : "feature.validate refused the feature, with no reason given.",
167
+ );
168
+ process.exit(1);
169
+ }
170
+ if (/^VALIDATED\b/i.test(verdict)) {
171
+ process.exit(0);
172
+ }
173
+ emitFailure(
174
+ "fail-retryable",
175
+ `feature.validate's MASON_VERDICT: line was neither VALIDATED nor REFUSED: ${verdict.slice(0, 500)}`,
176
+ );
177
+ process.exit(1);
178
+ }
179
+
180
+ /**
181
+ * The text after the last line that starts with `MASON_VERDICT:`, or
182
+ * `undefined` when no such line exists.
183
+ *
184
+ * A distinctive, unlikely-to-collide marker, anchored to the start of a line
185
+ * — not a bare search for "VALIDATED" or "REFUSED" anywhere in the answer,
186
+ * which a model's own reasoning can contain by accident ("the units were
187
+ * already validated on their own"). The *last* such line wins: a model that
188
+ * restates its conclusion, or that is shown an example in the prompt, still
189
+ * means its final one.
190
+ *
191
+ * @param {string} text
192
+ * @returns {string | undefined}
193
+ */
194
+ function lastVerdictLine(text) {
195
+ const marker = /^MASON_VERDICT:\s*/i;
196
+ const lines = text.split("\n");
197
+ for (let i = lines.length - 1; i >= 0; i -= 1) {
198
+ const line = (lines[i] ?? "").trim();
199
+ const match = marker.exec(line);
200
+ if (match !== null) {
201
+ return line.slice(match[0].length).trim();
202
+ }
203
+ }
204
+ return undefined;
205
+ }
@@ -88,7 +88,7 @@ for (;;) {
88
88
  // yet, and an empty list then says nothing. Ask again, until the wait
89
89
  // runs out: a commit no check ever picks up is not a refusal of the work.
90
90
  if (Date.now() >= stopAt) {
91
- stopWaiting(pull, "no check has appeared");
91
+ stopWaiting(pull, "no check has appeared", true);
92
92
  }
93
93
  await sleep(Math.min(options.pollMs, Math.max(0, stopAt - Date.now())));
94
94
  continue;
@@ -128,16 +128,25 @@ function stopTime(from, raw) {
128
128
  * could change: blocking, so a person reads it instead of an agent being sent
129
129
  * to repair what was never judged.
130
130
  *
131
- * @param {{ number: number; sha: string }} pr
131
+ * When no check ever appeared, the cause is on the repository's side, and the
132
+ * message says the three it can be: no CI at all, a CI that does not run on pull
133
+ * requests into this branch, or a CI that is late.
134
+ *
135
+ * @param {{ number: number; sha: string; base?: string }} pr
132
136
  * @param {string} what
137
+ * @param {boolean} [never]
133
138
  * @returns {never}
134
139
  */
135
- function stopWaiting(pr, what) {
140
+ function stopWaiting(pr, what, never = false) {
136
141
  const seconds = Math.round((Date.now() - startedAt) / 1000);
137
142
  const lasted = seconds >= 120 ? `${Math.round(seconds / 60)} min` : `${seconds} s`;
143
+ const into = pr.base === undefined ? "this branch" : `"${pr.base}"`;
144
+ const hint = never
145
+ ? ` Either this repository has no CI (then take ci-green out of the Gate list), or its workflows do not run on pull requests into ${into}, or the CI was removed or is late.`
146
+ : "";
138
147
  emitVerdict(
139
148
  "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.`,
149
+ `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.${hint}`,
141
150
  );
142
151
  process.exit(0);
143
152
  }
@@ -221,7 +230,7 @@ function repoFrom(url) {
221
230
 
222
231
  /**
223
232
  * @param {string} head
224
- * @returns {Promise<{ number: number; sha: string } | undefined>}
233
+ * @returns {Promise<{ number: number; sha: string; base?: string } | undefined>}
225
234
  */
226
235
  async function openPullFor(head) {
227
236
  const listed = await api(
@@ -232,8 +241,10 @@ async function openPullFor(head) {
232
241
  if (first === undefined || headOf === undefined) {
233
242
  return undefined;
234
243
  }
244
+ const baseOf = record(first.base);
245
+ const base = typeof baseOf?.ref === "string" ? baseOf.ref : undefined;
235
246
  return typeof first.number === "number" && typeof headOf.sha === "string"
236
- ? { number: first.number, sha: headOf.sha }
247
+ ? { number: first.number, sha: headOf.sha, ...(base === undefined ? {} : { base }) }
237
248
  : undefined;
238
249
  }
239
250