@bluewombat/slots 0.1.2 → 0.2.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 +26 -2
- package/agents/claude.mjs +3 -2
- package/agents/cursor.mjs +3 -2
- package/assembly/validate.mjs +204 -0
- package/messages/conventional.mjs +2 -2
- package/messages/git-producer.mjs +2 -1
- package/package.json +2 -2
- package/planners/producer.mjs +2 -1
- package/publishers/git.mjs +2 -1
package/README.md
CHANGED
|
@@ -65,6 +65,7 @@ these all use it. How Host wires a slot into a run:
|
|
|
65
65
|
| `builders/producer.mjs` | Builder | First pass of a Subtask: fill `{{task}}`, then spawn the agent |
|
|
66
66
|
| `builders/repair.mjs` | Builder | A Gate refused the Subtask: fill `{{task}}` and `{{report}}` |
|
|
67
67
|
| `assembly/fix.mjs` | Builder | A judgement refused the assembled feature: fill `{{task}}` and `{{report}}` |
|
|
68
|
+
| `assembly/validate.mjs` | Builder | Read-only review of the assembled feature: fill `{{task}}`; answers `MASON_VERDICT: VALIDATED` or `MASON_VERDICT: REFUSED: …` |
|
|
68
69
|
| `gates/workspace-changed.mjs` | Gate | The Attempt left an uncommitted change |
|
|
69
70
|
| `gates/parent-clean.mjs` | Gate | Isolator's Parent working files did not move |
|
|
70
71
|
| `gates/sensitive-path.mjs` | Gate | No path matching a glob was touched |
|
|
@@ -80,8 +81,9 @@ these all use it. How Host wires a slot into a run:
|
|
|
80
81
|
|
|
81
82
|
A vendor CLI is not a Builder. `agents/cursor.mjs` and `agents/claude.mjs` take
|
|
82
83
|
a prompt that is **already filled**, run the vendor, write a transcript if asked,
|
|
83
|
-
and print the serialized run on stdout
|
|
84
|
-
|
|
84
|
+
and print the serialized run on stdout with `writeContract` (so a large
|
|
85
|
+
stream-json transcript survives `process.exit`). They do not know `--intention`
|
|
86
|
+
or `--report`. A role slot after `--` is what names one:
|
|
85
87
|
|
|
86
88
|
```json
|
|
87
89
|
"--",
|
|
@@ -182,6 +184,7 @@ assembly, so `assembly/fix` does not fill it at all.
|
|
|
182
184
|
| `builders/producer` | `{{task}}`, `{{rules}}` | `{{done_when}}`, `{{id}}`, `{{attempt}}` |
|
|
183
185
|
| `builders/repair` | `{{task}}`, `{{report}}`, `{{rules}}` | `{{refused_by}}`, `{{done_when}}`, `{{id}}`, `{{attempt}}` |
|
|
184
186
|
| `assembly/fix` | `{{task}}`, `{{report}}`, `{{rules}}` | `{{refused_by}}`, `{{id}}`, `{{attempt}}` |
|
|
187
|
+
| `assembly/validate` | `{{task}}`, `{{rules}}` | `{{id}}`, `{{attempt}}` |
|
|
185
188
|
|
|
186
189
|
A first-pass command that receives `--report` is `fail-blocking`: that report
|
|
187
190
|
belongs to the repair command. A repair or fix command without `--report` is the same.
|
|
@@ -203,6 +206,27 @@ The units already passed. `assembly/fix.mjs` tells the agent to change what the
|
|
|
203
206
|
refusal names and leave the rest. Same placeholders as repair except
|
|
204
207
|
`{{done_when}}`, which an assembly has nothing to fill.
|
|
205
208
|
|
|
209
|
+
### The validate prompt
|
|
210
|
+
|
|
211
|
+
`assembly/validate.mjs` is a different kind of Builder: it never writes. It
|
|
212
|
+
fills only `{{task}}` and `{{rules}}` — there is nothing to fix yet, so no
|
|
213
|
+
`{{report}}` — and asks the agent to end its final message with one line:
|
|
214
|
+
`MASON_VERDICT: VALIDATED` or `MASON_VERDICT: REFUSED: <one paragraph>`. Its
|
|
215
|
+
own `{{rules}}` default is not `PROMPT_RULES`: a review has no work to leave
|
|
216
|
+
uncommitted and nothing to push, so it says "read-only" instead of "do not
|
|
217
|
+
commit, do not push".
|
|
218
|
+
|
|
219
|
+
The slot does not trust the CLI's exit code alone — a review can complete
|
|
220
|
+
cleanly and still find a problem. It reads the agent's own final answer
|
|
221
|
+
(`readResult(run.stdout)?.result`) for the **last** line starting with
|
|
222
|
+
`MASON_VERDICT:` — not a bare search for "VALIDATED" or "REFUSED" anywhere in
|
|
223
|
+
the text, which a model's own reasoning can contain by accident ("the units
|
|
224
|
+
were already validated on their own"). It maps a `REFUSED:` verdict through
|
|
225
|
+
`emitFailure("fail-retryable", …)` itself, the report being what followed
|
|
226
|
+
`REFUSED:` on that line. No `MASON_VERDICT:` line, or one that is neither
|
|
227
|
+
form, is treated the same way, naming what the agent said: a review that did
|
|
228
|
+
not answer as asked is not a silent pass.
|
|
229
|
+
|
|
206
230
|
### The rules
|
|
207
231
|
|
|
208
232
|
`{{rules}}` holds the bounds of the slot: one directory, work left uncommitted,
|
package/agents/claude.mjs
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
serializeRun,
|
|
12
12
|
textOption,
|
|
13
13
|
transcriptFor,
|
|
14
|
+
writeContract,
|
|
14
15
|
} from "@bluewombat/slot-kit";
|
|
15
16
|
import { extrasFromClaude } from "./claude-extras.mjs";
|
|
16
17
|
|
|
@@ -88,7 +89,7 @@ answer(run, bin, extras);
|
|
|
88
89
|
|
|
89
90
|
/** @param {string} reason @returns {never} */
|
|
90
91
|
function refuse(reason) {
|
|
91
|
-
|
|
92
|
+
writeContract(`${JSON.stringify({ error: reason })}\n`);
|
|
92
93
|
process.exit(1);
|
|
93
94
|
}
|
|
94
95
|
|
|
@@ -99,7 +100,7 @@ function refuse(reason) {
|
|
|
99
100
|
* @returns {never}
|
|
100
101
|
*/
|
|
101
102
|
function answer(run, binPath, extras) {
|
|
102
|
-
|
|
103
|
+
writeContract(
|
|
103
104
|
`${JSON.stringify(
|
|
104
105
|
serializeRun(run, { name: NAME, bin: binPath, skills: extras.skills, usage: extras.usage }),
|
|
105
106
|
)}\n`,
|
package/agents/cursor.mjs
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
serializeRun,
|
|
12
12
|
textOption,
|
|
13
13
|
transcriptFor,
|
|
14
|
+
writeContract,
|
|
14
15
|
} from "@bluewombat/slot-kit";
|
|
15
16
|
import { extrasFromCursor } from "./cursor-extras.mjs";
|
|
16
17
|
|
|
@@ -82,7 +83,7 @@ answer(run, bin, extras);
|
|
|
82
83
|
|
|
83
84
|
/** @param {string} reason @returns {never} */
|
|
84
85
|
function refuse(reason) {
|
|
85
|
-
|
|
86
|
+
writeContract(`${JSON.stringify({ error: reason })}\n`);
|
|
86
87
|
process.exit(1);
|
|
87
88
|
}
|
|
88
89
|
|
|
@@ -93,7 +94,7 @@ function refuse(reason) {
|
|
|
93
94
|
* @returns {never}
|
|
94
95
|
*/
|
|
95
96
|
function answer(run, binPath, extras) {
|
|
96
|
-
|
|
97
|
+
writeContract(
|
|
97
98
|
`${JSON.stringify(
|
|
98
99
|
serializeRun(run, { name: NAME, bin: binPath, skills: extras.skills, usage: extras.usage }),
|
|
99
100
|
)}\n`,
|
|
@@ -0,0 +1,204 @@
|
|
|
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 = "assembly validate";
|
|
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. Every unit of work that makes it up
|
|
23
|
+
has already been validated on its own. Your job now is different: read the
|
|
24
|
+
diff this feature carries against the work line it will land on, and judge
|
|
25
|
+
whether the WHOLE meets what was asked. Do not redo the units. Do not change
|
|
26
|
+
anything — this is a read-only review, not a repair.
|
|
27
|
+
|
|
28
|
+
## What was asked
|
|
29
|
+
|
|
30
|
+
{{task}}
|
|
31
|
+
|
|
32
|
+
## Your answer
|
|
33
|
+
|
|
34
|
+
End your final message with exactly one line in this form, and nothing after
|
|
35
|
+
it:
|
|
36
|
+
|
|
37
|
+
- \`MASON_VERDICT: VALIDATED\` — the assembled feature meets what was asked.
|
|
38
|
+
- \`MASON_VERDICT: REFUSED: <one paragraph>\` — it does not, and the paragraph
|
|
39
|
+
says what is wrong, specifically enough for another agent to fix it without
|
|
40
|
+
redoing your review. Keep the whole paragraph on that one line.
|
|
41
|
+
|
|
42
|
+
Nothing else may start a line with \`MASON_VERDICT:\`. You may explain your
|
|
43
|
+
reasoning before it, but that line is what is read — write it last.
|
|
44
|
+
|
|
45
|
+
## Rules of this run
|
|
46
|
+
|
|
47
|
+
{{rules}}
|
|
48
|
+
`;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* A reviewer's bounds: read-only, and answer in the one shape the caller
|
|
52
|
+
* parses. `PROMPT_RULES` tells a producer to leave work uncommitted and push
|
|
53
|
+
* nothing — obligations a Task that writes nothing does not have.
|
|
54
|
+
*/
|
|
55
|
+
const DEFAULT_RULES = [
|
|
56
|
+
"- Work only inside this directory. Do not read or edit anything outside it.",
|
|
57
|
+
"- This is a review, not a repair: do not change, stage, commit, or stash any file.",
|
|
58
|
+
"- Judge the diff against the intention above, not the style of the units that made it.",
|
|
59
|
+
"- Nobody is watching this run and nobody will answer a question. Where the intention is",
|
|
60
|
+
" ambiguous, take the reading most consistent with the existing code, and say which one",
|
|
61
|
+
" you took in your answer.",
|
|
62
|
+
"- End your final message with one line: MASON_VERDICT: VALIDATED, or",
|
|
63
|
+
" MASON_VERDICT: REFUSED: <one paragraph, on that one line>.",
|
|
64
|
+
].join("\n");
|
|
65
|
+
|
|
66
|
+
const report = (take(process.argv, "--report") ?? "").trim();
|
|
67
|
+
if (report.length > 0) {
|
|
68
|
+
emitFailure(
|
|
69
|
+
"fail-blocking",
|
|
70
|
+
"assembly.validate is a read-only review; it does not take --report — that is assembly.fix's job.",
|
|
71
|
+
);
|
|
72
|
+
process.exit(1);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const intention = (take(process.argv, "--intention") ?? "").trim();
|
|
76
|
+
if (intention.length === 0) {
|
|
77
|
+
emitFailure("fail-blocking", "The Builder received no --intention.");
|
|
78
|
+
process.exit(1);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const parsed = parseRole(process.argv, BUILDER_FLAGS, NAME);
|
|
82
|
+
if (!parsed.ok) {
|
|
83
|
+
emitFailure("fail-blocking", parsed.reason);
|
|
84
|
+
process.exit(1);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const template = loadPrompt(parsed.options, DEFAULT_TEMPLATE);
|
|
88
|
+
if (!template.ok) {
|
|
89
|
+
emitFailure("fail-blocking", template.reason);
|
|
90
|
+
process.exit(1);
|
|
91
|
+
}
|
|
92
|
+
const rules = loadRules(parsed.options, DEFAULT_RULES);
|
|
93
|
+
if (!rules.ok) {
|
|
94
|
+
emitFailure("fail-blocking", rules.reason);
|
|
95
|
+
process.exit(1);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const rendered = fillPrompt(
|
|
99
|
+
template.value,
|
|
100
|
+
{
|
|
101
|
+
task: intention,
|
|
102
|
+
rules: rules.value.trim(),
|
|
103
|
+
id: take(process.argv, "--id") ?? "",
|
|
104
|
+
attempt: String(attemptOf(process.argv)),
|
|
105
|
+
},
|
|
106
|
+
["task", "rules"],
|
|
107
|
+
textOption(parsed.options, "promptFile") ?? "built-in template",
|
|
108
|
+
);
|
|
109
|
+
if (!rendered.ok) {
|
|
110
|
+
emitFailure("fail-blocking", rendered.reason);
|
|
111
|
+
process.exit(1);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const spawned = await spawnFilled(parsed.runner, rendered.prompt, transcriptArgs(process.argv));
|
|
115
|
+
if (!spawned.ok) {
|
|
116
|
+
emitFailure("fail-blocking", spawned.reason);
|
|
117
|
+
process.exit(1);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
answer(spawned.run, spawned.about.bin, spawned.about.name);
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Read the agent's own verdict and map it through `emitFailure` — never
|
|
124
|
+
* through exit 0 alone. A review that completes cleanly still has to answer
|
|
125
|
+
* whether the work is right; the CLI exiting 0 only says the review ran.
|
|
126
|
+
*
|
|
127
|
+
* @param {import("@bluewombat/slot-kit").AgentRun} run
|
|
128
|
+
* @param {string} bin
|
|
129
|
+
* @param {string} name
|
|
130
|
+
* @returns {never}
|
|
131
|
+
*/
|
|
132
|
+
function answer(run, bin, name) {
|
|
133
|
+
if (run.error !== undefined || run.code !== 0) {
|
|
134
|
+
const failure = failureOf(run, bin, name);
|
|
135
|
+
emitFailure(failure.outcome, failure.report);
|
|
136
|
+
process.exit(1);
|
|
137
|
+
}
|
|
138
|
+
const result = readResult(run.stdout);
|
|
139
|
+
if (result?.is_error === true) {
|
|
140
|
+
const failure = failureOf(run, bin, name);
|
|
141
|
+
emitFailure(failure.outcome, failure.report);
|
|
142
|
+
process.exit(1);
|
|
143
|
+
}
|
|
144
|
+
const said = typeof result?.result === "string" ? result.result.trim() : "";
|
|
145
|
+
const verdict = lastVerdictLine(said);
|
|
146
|
+
if (verdict === undefined) {
|
|
147
|
+
// No `MASON_VERDICT:` line at all is not a pass on the strength of exit 0.
|
|
148
|
+
// Treated as retryable, the same as any other malformed Attempt — the
|
|
149
|
+
// next round gets the same prompt again.
|
|
150
|
+
emitFailure(
|
|
151
|
+
"fail-retryable",
|
|
152
|
+
`assembly.validate found no MASON_VERDICT: line in the answer: ${
|
|
153
|
+
said.length > 0 ? said.slice(0, 500) : "(no answer)"
|
|
154
|
+
}`,
|
|
155
|
+
);
|
|
156
|
+
process.exit(1);
|
|
157
|
+
}
|
|
158
|
+
const refused = /^REFUSED:?\s*/i.exec(verdict);
|
|
159
|
+
if (refused !== null) {
|
|
160
|
+
const why = verdict.slice(refused[0].length).trim();
|
|
161
|
+
emitFailure(
|
|
162
|
+
"fail-retryable",
|
|
163
|
+
why.length > 0
|
|
164
|
+
? why
|
|
165
|
+
: "assembly.validate refused the assembled feature, with no reason given.",
|
|
166
|
+
);
|
|
167
|
+
process.exit(1);
|
|
168
|
+
}
|
|
169
|
+
if (/^VALIDATED\b/i.test(verdict)) {
|
|
170
|
+
process.exit(0);
|
|
171
|
+
}
|
|
172
|
+
emitFailure(
|
|
173
|
+
"fail-retryable",
|
|
174
|
+
`assembly.validate's MASON_VERDICT: line was neither VALIDATED nor REFUSED: ${verdict.slice(0, 500)}`,
|
|
175
|
+
);
|
|
176
|
+
process.exit(1);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* The text after the last line that starts with `MASON_VERDICT:`, or
|
|
181
|
+
* `undefined` when no such line exists.
|
|
182
|
+
*
|
|
183
|
+
* A distinctive, unlikely-to-collide marker, anchored to the start of a line
|
|
184
|
+
* — not a bare search for "VALIDATED" or "REFUSED" anywhere in the answer,
|
|
185
|
+
* which a model's own reasoning can contain by accident ("the units were
|
|
186
|
+
* already validated on their own"). The *last* such line wins: a model that
|
|
187
|
+
* restates its conclusion, or that is shown an example in the prompt, still
|
|
188
|
+
* means its final one.
|
|
189
|
+
*
|
|
190
|
+
* @param {string} text
|
|
191
|
+
* @returns {string | undefined}
|
|
192
|
+
*/
|
|
193
|
+
function lastVerdictLine(text) {
|
|
194
|
+
const marker = /^MASON_VERDICT:\s*/i;
|
|
195
|
+
const lines = text.split("\n");
|
|
196
|
+
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
|
197
|
+
const line = (lines[i] ?? "").trim();
|
|
198
|
+
const match = marker.exec(line);
|
|
199
|
+
if (match !== null) {
|
|
200
|
+
return line.slice(match[0].length).trim();
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return undefined;
|
|
204
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { emitMessage, MESSAGE_FLAGS, ownArgv, take } from "@bluewombat/slot-kit";
|
|
2
|
+
import { emitMessage, MESSAGE_FLAGS, ownArgv, take, writeDiagnostic } from "@bluewombat/slot-kit";
|
|
3
3
|
|
|
4
4
|
// The feature's commit in the Conventional Commits shape, from the issue's
|
|
5
5
|
// title and body and with no agent: `<type>: <title>`, the body as written.
|
|
@@ -27,7 +27,7 @@ const forcedType = take(own, "--type");
|
|
|
27
27
|
const title = (take(process.argv, "--title") ?? "").trim();
|
|
28
28
|
const intention = (take(process.argv, "--intention") ?? "").trim();
|
|
29
29
|
if (title.length === 0) {
|
|
30
|
-
|
|
30
|
+
writeDiagnostic("conventional: no --title.\n");
|
|
31
31
|
process.exit(1);
|
|
32
32
|
}
|
|
33
33
|
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
take,
|
|
14
14
|
textOption,
|
|
15
15
|
transcriptArgs,
|
|
16
|
+
writeDiagnostic,
|
|
16
17
|
} from "@bluewombat/slot-kit";
|
|
17
18
|
|
|
18
19
|
// The feature's commit message — and the pull request's description with it
|
|
@@ -146,6 +147,6 @@ function readAnswer(path) {
|
|
|
146
147
|
|
|
147
148
|
/** @param {string} reason @returns {never} */
|
|
148
149
|
function fail(reason) {
|
|
149
|
-
|
|
150
|
+
writeDiagnostic(`${reason}\n`);
|
|
150
151
|
process.exit(1);
|
|
151
152
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bluewombat/slots",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.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,6 @@
|
|
|
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.2.0"
|
|
57
57
|
}
|
|
58
58
|
}
|
package/planners/producer.mjs
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
take,
|
|
15
15
|
textOption,
|
|
16
16
|
transcriptArgs,
|
|
17
|
+
writeDiagnostic,
|
|
17
18
|
} from "@bluewombat/slot-kit";
|
|
18
19
|
|
|
19
20
|
const NAME = "planner producer";
|
|
@@ -227,6 +228,6 @@ function tail(output) {
|
|
|
227
228
|
|
|
228
229
|
/** @param {string} reason @returns {never} */
|
|
229
230
|
function fail(reason) {
|
|
230
|
-
|
|
231
|
+
writeDiagnostic(`${reason}\n`);
|
|
231
232
|
process.exit(1);
|
|
232
233
|
}
|
package/publishers/git.mjs
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
ownArgv,
|
|
9
9
|
PUBLISHER_FLAGS,
|
|
10
10
|
take,
|
|
11
|
+
writeDiagnostic,
|
|
11
12
|
} from "@bluewombat/slot-kit";
|
|
12
13
|
|
|
13
14
|
// Put the feature's work where the Authority can read it, by pushing the name
|
|
@@ -49,7 +50,7 @@ const pushed = spawnSync("git", ["-C", cwd, "push", remote, `${ref}:${ref}`], {
|
|
|
49
50
|
encoding: "utf8",
|
|
50
51
|
});
|
|
51
52
|
if (pushed.error !== undefined) {
|
|
52
|
-
|
|
53
|
+
writeDiagnostic(`git publisher cannot run git: ${pushed.error.message}\n`);
|
|
53
54
|
process.exit(1);
|
|
54
55
|
}
|
|
55
56
|
if (pushed.status !== 0) {
|