@bluewombat/slots 0.8.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.
- package/README.md +190 -122
- package/agents/agent-plugins.mjs +52 -14
- package/agents/claude.mjs +3 -3
- package/agents/cursor.mjs +1 -1
- package/builders/e2e.mjs +124 -0
- package/builders/producer.mjs +6 -6
- package/builders/repair.mjs +4 -3
- package/builders/tests.mjs +139 -0
- package/feature/fix.mjs +134 -0
- package/feature/validate.mjs +205 -0
- package/gates/ci-green.mjs +17 -6
- package/gates/e2e.mjs +538 -0
- package/gates/gitignore-leak.mjs +10 -9
- package/gates/project-checks.mjs +128 -0
- package/gates/tests-frozen.mjs +96 -0
- package/gates/tests-red.mjs +227 -0
- package/gates/workspace-changed.mjs +2 -1
- package/gates/workspace-unchanged.mjs +90 -0
- package/lib/fill.mjs +0 -6
- package/lib/junit.mjs +122 -0
- package/lib/output-tail.mjs +70 -0
- package/messages/conventional.mjs +1 -1
- package/messages/git-producer.mjs +2 -2
- package/package.json +9 -10
- package/publishers/git.mjs +1 -1
- package/refreshers/git.mjs +52 -2
- package/{assembly → ticket}/fix.mjs +5 -5
- package/{assembly → ticket}/validate.mjs +11 -13
- package/planners/one-subtask.mjs +0 -21
- package/planners/producer.mjs +0 -233
|
@@ -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
|
+
}
|
package/gates/ci-green.mjs
CHANGED
|
@@ -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
|
-
*
|
|
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
|
|