@bluewombat/slots 0.9.0 → 0.11.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 +205 -66
- package/agents/agent-plugins.mjs +52 -14
- package/agents/claude.mjs +3 -3
- package/agents/cursor.mjs +1 -1
- package/builders/feature-test.mjs +134 -0
- package/builders/producer.mjs +4 -0
- package/builders/repair.mjs +4 -0
- package/feature/fix.mjs +134 -0
- package/feature/validate.mjs +205 -0
- package/gates/base-diff.mjs +276 -0
- package/gates/ci-green.mjs +100 -9
- package/gates/feature-tests.mjs +280 -0
- package/gates/gitignore-leak.mjs +2 -1
- package/gates/project-checks.mjs +128 -0
- package/gates/tests-only.mjs +65 -0
- package/gates/workspace-changed.mjs +2 -1
- package/gates/workspace-unchanged.mjs +90 -0
- package/lib/junit.mjs +122 -0
- package/lib/output-tail.mjs +70 -0
- package/package.json +7 -5
- package/refreshers/git.mjs +35 -1
- package/{assembly → ticket}/fix.mjs +2 -2
- package/{assembly → ticket}/validate.mjs +5 -7
|
@@ -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
|
+
// The Ticket that holds a Feature's tests. One command for its first pass and for a pass a Gate
|
|
19
|
+
// refused: the tests are written once from the scenarios of the task, and a refusal is about their
|
|
20
|
+
// form, so there is no second command to tell apart. `{{report}}` and `{{refused_by}}` are empty
|
|
21
|
+
// on a first pass; `{{refusal}}` is a ready-made paragraph about them.
|
|
22
|
+
|
|
23
|
+
const NAME = "builder feature-test";
|
|
24
|
+
|
|
25
|
+
const DEFAULT_TEMPLATE = `You are a coding agent working unattended in a checkout of the project.
|
|
26
|
+
|
|
27
|
+
You write the tests of a feature. The task below lists its scenarios, agreed with the operator
|
|
28
|
+
before development, each with an id (S1, S2, ...). The feature is built: this checkout holds every
|
|
29
|
+
other Ticket of it.
|
|
30
|
+
|
|
31
|
+
Task:
|
|
32
|
+
{{task}}
|
|
33
|
+
|
|
34
|
+
- Write one test per scenario, in the test framework this project already uses, at the level the
|
|
35
|
+
scenario describes (through the feature's public interfaces: routes, services, pages), and start
|
|
36
|
+
the name of each test with its scenario id ("S1 …"). Follow the scenario's steps and expected
|
|
37
|
+
result as written; do not add, merge, drop or weaken one. If a scenario changes a behaviour that
|
|
38
|
+
existing tests describe, update those tests to the new behaviour, and only those.
|
|
39
|
+
- List the ids of the scenarios in the manifest \`feature-tests/scenarios.json\`, a JSON array:
|
|
40
|
+
["S1", "S2"]. Only the scenarios of this feature, never the project's other tests.
|
|
41
|
+
- Write only tests, their helpers and the manifest. Touch no file of the application, and add no
|
|
42
|
+
dependency it does not already have.
|
|
43
|
+
- A Gate runs your tests against the built feature after every attempt and gives you its report
|
|
44
|
+
(the refusal below). Read it: a test that fails for its own reasons (a wrong selector or wait, a
|
|
45
|
+
wrong data setup, a misread expectation) is yours to fix. Check only that your tests compile
|
|
46
|
+
before you finish, with the project's own typecheck or build command.
|
|
47
|
+
- If a test stays red because the feature does not do what the scenario says, do not change the
|
|
48
|
+
application and do not weaken the test: that is a finding. Change nothing more and end with
|
|
49
|
+
MASON_BLOCKED, naming the scenario, what the feature does instead and where.
|
|
50
|
+
|
|
51
|
+
{{refusal}}
|
|
52
|
+
|
|
53
|
+
{{history}}
|
|
54
|
+
|
|
55
|
+
## When you cannot act
|
|
56
|
+
|
|
57
|
+
If what you were given is not enough to act on (a scenario is ambiguous, cannot be written without
|
|
58
|
+
changing the application or without a weaker result than asked, the framework is missing, the
|
|
59
|
+
feature is wrong, the cause is outside this directory), do not invent a change and do not lower
|
|
60
|
+
the scenario. Change nothing and end your final message with exactly one line:
|
|
61
|
+
|
|
62
|
+
MASON_BLOCKED: <what is missing, in one line>
|
|
63
|
+
|
|
64
|
+
A person reads that line and decides. It is a correct answer, not a failure.
|
|
65
|
+
|
|
66
|
+
Rules of this run:
|
|
67
|
+
{{rules}}
|
|
68
|
+
`;
|
|
69
|
+
|
|
70
|
+
const intention = (take(process.argv, "--intention") ?? "").trim();
|
|
71
|
+
if (intention.length === 0) {
|
|
72
|
+
emitFailure("fail-blocking", "The Builder received no --intention.");
|
|
73
|
+
process.exit(1);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const parsed = parseRole(process.argv, BUILDER_FLAGS, NAME);
|
|
77
|
+
if (!parsed.ok) {
|
|
78
|
+
emitFailure("fail-blocking", parsed.reason);
|
|
79
|
+
process.exit(1);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const template = loadPrompt(parsed.options, DEFAULT_TEMPLATE);
|
|
83
|
+
if (!template.ok) {
|
|
84
|
+
emitFailure("fail-blocking", template.reason);
|
|
85
|
+
process.exit(1);
|
|
86
|
+
}
|
|
87
|
+
const rules = loadRules(parsed.options, PROMPT_RULES);
|
|
88
|
+
if (!rules.ok) {
|
|
89
|
+
emitFailure("fail-blocking", rules.reason);
|
|
90
|
+
process.exit(1);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const report = (take(process.argv, "--report") ?? "").trim();
|
|
94
|
+
const refusedBy = (take(process.argv, "--report-from") ?? "").trim();
|
|
95
|
+
const refusal =
|
|
96
|
+
report.length === 0
|
|
97
|
+
? ""
|
|
98
|
+
: [
|
|
99
|
+
"What is in this directory is what an earlier attempt left. It was refused",
|
|
100
|
+
`${refusedBy.length > 0 ? `by ${refusedBy}` : "by a check"}:`,
|
|
101
|
+
"",
|
|
102
|
+
"```raw",
|
|
103
|
+
report,
|
|
104
|
+
"```",
|
|
105
|
+
"",
|
|
106
|
+
"Resolve that, change what it takes and nothing more.",
|
|
107
|
+
].join("\n");
|
|
108
|
+
|
|
109
|
+
const rendered = fillPrompt(
|
|
110
|
+
template.value,
|
|
111
|
+
{
|
|
112
|
+
task: intention,
|
|
113
|
+
history: (take(process.argv, "--history") ?? "").trim(),
|
|
114
|
+
refusal,
|
|
115
|
+
report,
|
|
116
|
+
refused_by: refusedBy,
|
|
117
|
+
rules: rules.value.trim(),
|
|
118
|
+
id: take(process.argv, "--id") ?? "",
|
|
119
|
+
attempt: String(attemptOf(process.argv)),
|
|
120
|
+
},
|
|
121
|
+
["task", "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 });
|
package/builders/producer.mjs
CHANGED
|
@@ -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
|
package/builders/repair.mjs
CHANGED
|
@@ -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
|
package/feature/fix.mjs
ADDED
|
@@ -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
|
+
}
|