@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,70 @@
|
|
|
1
|
+
// The end of a command's output, for a Gate's report: what a person or an agent needs to read
|
|
2
|
+
// to see why a check is red.
|
|
3
|
+
//
|
|
4
|
+
// Test runners and CLIs redraw a progress line (a spinner, "[✓] Pulling schema...") thousands of
|
|
5
|
+
// times. Kept as bytes, those redraws push the real error out of the tail the report is cut from,
|
|
6
|
+
// and the report says nothing but the spinner. So the output is cleaned line by line as it
|
|
7
|
+
// arrives, before anything is dropped for size: escape codes removed, a line redrawn with "\r"
|
|
8
|
+
// reduced to its last state, spinner and progress lines dropped.
|
|
9
|
+
|
|
10
|
+
// CSI sequences (colors, erase line, cursor moves) and OSC sequences (titles, links).
|
|
11
|
+
const ESC = String.fromCharCode(0x1b);
|
|
12
|
+
const BEL = String.fromCharCode(0x07);
|
|
13
|
+
const ESCAPES = new RegExp(
|
|
14
|
+
`${ESC}\\[[0-9;?]*[ -/]*[@-~]|${ESC}\\][^${BEL}${ESC}]*(?:${BEL}|${ESC}\\\\)`,
|
|
15
|
+
"g",
|
|
16
|
+
);
|
|
17
|
+
// A braille glyph is a spinner frame; "[✓] …..." is the line a spinner settles on.
|
|
18
|
+
const SPINNER_FRAME = /[⠀-⣿]/;
|
|
19
|
+
const SPINNER_DONE = /\[[✓✔]\].*\.\.\.\s*$/;
|
|
20
|
+
|
|
21
|
+
/** One line, cleaned; null when it is only progress noise. */
|
|
22
|
+
export function cleanLine(/** @type {string} */ raw) {
|
|
23
|
+
const redrawn = raw.split("\r").filter((part) => part.trim().length > 0);
|
|
24
|
+
const last = (redrawn.length > 0 ? redrawn[redrawn.length - 1] : "") ?? "";
|
|
25
|
+
const line = last.replace(ESCAPES, "").trimEnd();
|
|
26
|
+
if (SPINNER_FRAME.test(line) || SPINNER_DONE.test(line)) {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
return line;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @param {number} maxChars what `text()` returns at most; older lines are dropped first.
|
|
34
|
+
*/
|
|
35
|
+
export function createOutputTail(maxChars) {
|
|
36
|
+
let kept = "";
|
|
37
|
+
/** @type {Map<string, string>} */
|
|
38
|
+
const partial = new Map();
|
|
39
|
+
|
|
40
|
+
const add = (/** @type {string} */ raw) => {
|
|
41
|
+
const line = cleanLine(raw);
|
|
42
|
+
if (line === null) {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
kept += `${line}\n`;
|
|
46
|
+
if (kept.length > maxChars * 2) {
|
|
47
|
+
kept = kept.slice(kept.length - maxChars);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
/** Feed a chunk of one stream; each stream keeps its own unfinished line. */
|
|
53
|
+
push(/** @type {string} */ stream, /** @type {Buffer | string} */ chunk) {
|
|
54
|
+
const lines = ((partial.get(stream) ?? "") + chunk.toString()).split("\n");
|
|
55
|
+
partial.set(stream, lines.pop() ?? "");
|
|
56
|
+
lines.forEach(add);
|
|
57
|
+
},
|
|
58
|
+
/** The cleaned end of the output, cut on a line boundary. */
|
|
59
|
+
text() {
|
|
60
|
+
for (const rest of partial.values()) {
|
|
61
|
+
if (rest.length > 0) {
|
|
62
|
+
add(rest);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
partial.clear();
|
|
66
|
+
const cut = kept.length > maxChars ? kept.slice(kept.length - maxChars) : kept;
|
|
67
|
+
return (kept.length > maxChars ? cut.slice(cut.indexOf("\n") + 1) : cut).trim();
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { emitMessage, MESSAGE_FLAGS, ownArgv, take, writeDiagnostic } from "@bluewombat/slot-kit";
|
|
3
3
|
|
|
4
|
-
// The
|
|
4
|
+
// The ticket'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.
|
|
6
6
|
//
|
|
7
7
|
// The type is read off the title's first word — a Project that wants a
|
|
@@ -16,9 +16,9 @@ import {
|
|
|
16
16
|
writeDiagnostic,
|
|
17
17
|
} from "@bluewombat/slot-kit";
|
|
18
18
|
|
|
19
|
-
// The
|
|
19
|
+
// The ticket's commit message — and the pull request's description with it
|
|
20
20
|
// — written by an agent that read the diff, under the Project's own rules.
|
|
21
|
-
// Asked once per
|
|
21
|
+
// Asked once per ticket. A role slot like the builders: it fills a template
|
|
22
22
|
// and hands it to the agent after `--`; the agent answers in a file this slot
|
|
23
23
|
// reads back. `--prompt-file` replaces the template, `--rules-file` the rules
|
|
24
24
|
// — that is where a team's commit guideline goes.
|
package/package.json
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bluewombat/slots",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "Shipped mason slots:
|
|
5
|
+
"description": "Shipped mason slots: Builders, Gates, publishers, and refreshers a Project names in its config.",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"mason",
|
|
8
8
|
"bluewombat",
|
|
9
9
|
"gate",
|
|
10
|
-
"builder"
|
|
11
|
-
"planner"
|
|
10
|
+
"builder"
|
|
12
11
|
],
|
|
13
12
|
"license": "MIT",
|
|
14
13
|
"homepage": "https://github.com/tilap/bluewombat/tree/main/packages/plugins/slots#readme",
|
|
@@ -19,9 +18,9 @@
|
|
|
19
18
|
"directory": "packages/plugins/slots"
|
|
20
19
|
},
|
|
21
20
|
"exports": {
|
|
22
|
-
"./planners/*": "./planners/*",
|
|
23
21
|
"./builders/*": "./builders/*",
|
|
24
|
-
"./
|
|
22
|
+
"./ticket/*": "./ticket/*",
|
|
23
|
+
"./feature/*": "./feature/*",
|
|
25
24
|
"./agents/*": "./agents/*",
|
|
26
25
|
"./gates/*": "./gates/*",
|
|
27
26
|
"./publishers/*": "./publishers/*",
|
|
@@ -29,9 +28,9 @@
|
|
|
29
28
|
"./messages/*": "./messages/*"
|
|
30
29
|
},
|
|
31
30
|
"files": [
|
|
32
|
-
"planners",
|
|
33
31
|
"builders",
|
|
34
|
-
"
|
|
32
|
+
"ticket",
|
|
33
|
+
"feature",
|
|
35
34
|
"agents",
|
|
36
35
|
"lib",
|
|
37
36
|
"gates",
|
|
@@ -53,7 +52,7 @@
|
|
|
53
52
|
"test": "tsx --test $(find test -name '*.test.ts')"
|
|
54
53
|
},
|
|
55
54
|
"dependencies": {
|
|
56
|
-
"@bluewombat/slot-kit": "^0.
|
|
57
|
-
"@pinkpuma/deck": "1.
|
|
55
|
+
"@bluewombat/slot-kit": "^0.10.0",
|
|
56
|
+
"@pinkpuma/deck": "1.1.0"
|
|
58
57
|
}
|
|
59
58
|
}
|
package/publishers/git.mjs
CHANGED
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
writeDiagnostic,
|
|
12
12
|
} from "@bluewombat/slot-kit";
|
|
13
13
|
|
|
14
|
-
// Put the
|
|
14
|
+
// Put the ticket's work where the Authority can read it, by pushing the name
|
|
15
15
|
// Isolator gave it to the work line's own remote.
|
|
16
16
|
//
|
|
17
17
|
// A plain push, and no force of any kind. Integrator only ever adds history, so
|
package/refreshers/git.mjs
CHANGED
|
@@ -16,10 +16,19 @@ import {
|
|
|
16
16
|
// would make the next Isolation start from a version that no longer exists, so
|
|
17
17
|
// this runs before anything else in a pass.
|
|
18
18
|
//
|
|
19
|
+
// The copy moves onto the target line first: a Feature's Tickets are cut from
|
|
20
|
+
// and folded into that Feature's line, not the final branch.
|
|
21
|
+
//
|
|
19
22
|
// Fast-forward only: this system never rewrites that line. A copy that cannot
|
|
20
23
|
// fast-forward has been written to by somebody else, and that is a person's
|
|
21
24
|
// problem, not a merge to attempt.
|
|
22
25
|
//
|
|
26
|
+
// A Feature's line cut from the final branch that has no commit of its own is
|
|
27
|
+
// only a stale copy of it: when `--base` names that branch and the line is an
|
|
28
|
+
// ancestor of it, the line is fast-forwarded to it, here and on the remote, so
|
|
29
|
+
// a Ticket starts from the code as it now is. A line with commits of its own
|
|
30
|
+
// is left alone.
|
|
31
|
+
//
|
|
23
32
|
// The remote is `origin`, like the git Publisher's. Takes no option.
|
|
24
33
|
|
|
25
34
|
const cwd = process.cwd();
|
|
@@ -41,7 +50,8 @@ if (!existsSync(join(cwd, ".git"))) {
|
|
|
41
50
|
process.exit(0);
|
|
42
51
|
}
|
|
43
52
|
|
|
44
|
-
|
|
53
|
+
// An explicit refspec: a single-branch clone only knows its own branch.
|
|
54
|
+
const fetched = git(["fetch", "origin", `+refs/heads/${target}:refs/remotes/origin/${target}`]);
|
|
45
55
|
if (fetched.status !== 0) {
|
|
46
56
|
emitRefreshRefusal(
|
|
47
57
|
`Cannot fetch origin/${target} from ${cwd}: ${fetched.stderr.trim().slice(-500)}`,
|
|
@@ -49,7 +59,47 @@ if (fetched.status !== 0) {
|
|
|
49
59
|
process.exit(0);
|
|
50
60
|
}
|
|
51
61
|
|
|
52
|
-
const
|
|
62
|
+
const base = take(process.argv, "--base");
|
|
63
|
+
let goal = `origin/${target}`;
|
|
64
|
+
if (base !== undefined && base.trim().length > 0 && base !== target) {
|
|
65
|
+
const fetchedBase = git(["fetch", "origin", `+refs/heads/${base}:refs/remotes/origin/${base}`]);
|
|
66
|
+
if (fetchedBase.status !== 0) {
|
|
67
|
+
emitRefreshRefusal(
|
|
68
|
+
`Cannot fetch origin/${base} from ${cwd}: ${fetchedBase.stderr.trim().slice(-500)}`,
|
|
69
|
+
);
|
|
70
|
+
process.exit(0);
|
|
71
|
+
}
|
|
72
|
+
const behindBase =
|
|
73
|
+
git(["merge-base", "--is-ancestor", `origin/${target}`, `origin/${base}`]).status === 0;
|
|
74
|
+
if (
|
|
75
|
+
behindBase &&
|
|
76
|
+
git(["rev-parse", `origin/${target}`]).stdout !== git(["rev-parse", `origin/${base}`]).stdout
|
|
77
|
+
) {
|
|
78
|
+
// A plain push: never forced, so it only ever adds history.
|
|
79
|
+
const pushed = git(["push", "origin", `refs/remotes/origin/${base}:refs/heads/${target}`]);
|
|
80
|
+
if (pushed.status !== 0) {
|
|
81
|
+
emitRefreshRefusal(
|
|
82
|
+
`Cannot move origin/${target} up to origin/${base}: ${pushed.stderr.trim().slice(-500)}`,
|
|
83
|
+
);
|
|
84
|
+
process.exit(0);
|
|
85
|
+
}
|
|
86
|
+
goal = `origin/${base}`;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const current = git(["symbolic-ref", "--short", "HEAD"]);
|
|
91
|
+
if (current.status !== 0 || current.stdout.trim() !== target) {
|
|
92
|
+
const exists = git(["rev-parse", "--verify", "--quiet", `refs/heads/${target}`]).status === 0;
|
|
93
|
+
const switched = git(
|
|
94
|
+
exists ? ["checkout", target] : ["checkout", "-b", target, `origin/${target}`],
|
|
95
|
+
);
|
|
96
|
+
if (switched.status !== 0) {
|
|
97
|
+
emitRefreshRefusal(`Cannot move ${cwd} onto ${target}: ${switched.stderr.trim().slice(-500)}`);
|
|
98
|
+
process.exit(0);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const merged = git(["merge", "--ff-only", goal]);
|
|
53
103
|
if (merged.status !== 0) {
|
|
54
104
|
// The one way a copy stops fast-forwarding is a commit made into it that
|
|
55
105
|
// the reference does not have. The copy is the system's, not a person's:
|
|
@@ -15,13 +15,13 @@ import {
|
|
|
15
15
|
} from "@bluewombat/slot-kit";
|
|
16
16
|
import { attemptOf } from "../lib/fill.mjs";
|
|
17
17
|
|
|
18
|
-
const NAME = "
|
|
18
|
+
const NAME = "ticket fix";
|
|
19
19
|
|
|
20
20
|
const DEFAULT_TEMPLATE = `You are a coding agent working unattended in a checkout of the project.
|
|
21
21
|
|
|
22
|
-
This directory is the assembled
|
|
23
|
-
|
|
24
|
-
|
|
22
|
+
This directory is the assembled work. It passed the checks made on the unit
|
|
23
|
+
itself. An earlier judgement of the whole refused it. Do not redo the work.
|
|
24
|
+
Fix what the refusal names.
|
|
25
25
|
|
|
26
26
|
Refused by: {{refused_by}}
|
|
27
27
|
|
|
@@ -60,7 +60,7 @@ A person reads that line and decides. It is a correct answer, not a failure.
|
|
|
60
60
|
|
|
61
61
|
const report = (take(process.argv, "--report") ?? "").trim();
|
|
62
62
|
if (report.length === 0) {
|
|
63
|
-
emitFailure("fail-blocking", "The
|
|
63
|
+
emitFailure("fail-blocking", "The ticket fix command needs --report.");
|
|
64
64
|
process.exit(1);
|
|
65
65
|
}
|
|
66
66
|
|
|
@@ -15,15 +15,15 @@ import {
|
|
|
15
15
|
} from "@bluewombat/slot-kit";
|
|
16
16
|
import { attemptOf } from "../lib/fill.mjs";
|
|
17
17
|
|
|
18
|
-
const NAME = "
|
|
18
|
+
const NAME = "ticket validate";
|
|
19
19
|
|
|
20
20
|
const DEFAULT_TEMPLATE = `You are a coding agent working unattended in a checkout of the project.
|
|
21
21
|
|
|
22
|
-
This directory is the assembled
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
22
|
+
This directory is the assembled work. It has already passed the checks made on
|
|
23
|
+
the unit itself. Your job now is different: read the diff it carries against
|
|
24
|
+
the work line it will land on, and judge whether the WHOLE meets what was
|
|
25
|
+
asked. Do not redo the work. Do not change anything — this is a
|
|
26
|
+
read-only review, not a repair.
|
|
27
27
|
|
|
28
28
|
## What was asked
|
|
29
29
|
|
|
@@ -34,7 +34,7 @@ anything — this is a read-only review, not a repair.
|
|
|
34
34
|
End your final message with exactly one line in this form, and nothing after
|
|
35
35
|
it:
|
|
36
36
|
|
|
37
|
-
- \`MASON_VERDICT: VALIDATED\` — the assembled
|
|
37
|
+
- \`MASON_VERDICT: VALIDATED\` — the assembled ticket meets what was asked.
|
|
38
38
|
- \`MASON_VERDICT: REFUSED: <one paragraph>\` — it does not, and the paragraph
|
|
39
39
|
says what is wrong, specifically enough for another agent to fix it without
|
|
40
40
|
redoing your review. Keep the whole paragraph on that one line.
|
|
@@ -67,7 +67,7 @@ const report = (take(process.argv, "--report") ?? "").trim();
|
|
|
67
67
|
if (report.length > 0) {
|
|
68
68
|
emitFailure(
|
|
69
69
|
"fail-blocking",
|
|
70
|
-
"
|
|
70
|
+
"ticket.validate is a read-only review; it does not take --report — that is ticket.fix's job.",
|
|
71
71
|
);
|
|
72
72
|
process.exit(1);
|
|
73
73
|
}
|
|
@@ -149,7 +149,7 @@ function answer(run, bin, name) {
|
|
|
149
149
|
// next round gets the same prompt again.
|
|
150
150
|
emitFailure(
|
|
151
151
|
"fail-retryable",
|
|
152
|
-
`
|
|
152
|
+
`ticket.validate found no MASON_VERDICT: line in the answer: ${
|
|
153
153
|
said.length > 0 ? said.slice(0, 500) : "(no answer)"
|
|
154
154
|
}`,
|
|
155
155
|
);
|
|
@@ -160,9 +160,7 @@ function answer(run, bin, name) {
|
|
|
160
160
|
const why = verdict.slice(refused[0].length).trim();
|
|
161
161
|
emitFailure(
|
|
162
162
|
"fail-retryable",
|
|
163
|
-
why.length > 0
|
|
164
|
-
? why
|
|
165
|
-
: "assembly.validate refused the assembled feature, with no reason given.",
|
|
163
|
+
why.length > 0 ? why : "ticket.validate refused the assembled ticket, with no reason given.",
|
|
166
164
|
);
|
|
167
165
|
process.exit(1);
|
|
168
166
|
}
|
|
@@ -171,7 +169,7 @@ function answer(run, bin, name) {
|
|
|
171
169
|
}
|
|
172
170
|
emitFailure(
|
|
173
171
|
"fail-retryable",
|
|
174
|
-
`
|
|
172
|
+
`ticket.validate's MASON_VERDICT: line was neither VALIDATED nor REFUSED: ${verdict.slice(0, 500)}`,
|
|
175
173
|
);
|
|
176
174
|
process.exit(1);
|
|
177
175
|
}
|
package/planners/one-subtask.mjs
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* Bootstrap Planner: one Subtask that is the FeatureStandard itself.
|
|
4
|
-
* FeatureBreakdown appends --intention, --key, --max-units.
|
|
5
|
-
*/
|
|
6
|
-
import { emitPlan, emitRefusal, take } from "@bluewombat/slot-kit";
|
|
7
|
-
|
|
8
|
-
const intention = (take(process.argv, "--intention") ?? "").trim();
|
|
9
|
-
if (intention.length === 0) {
|
|
10
|
-
emitRefusal("The FeatureStandard has no intention to turn into a Subtask.");
|
|
11
|
-
process.exit(0);
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
emitPlan([
|
|
15
|
-
{
|
|
16
|
-
id: "A",
|
|
17
|
-
intention,
|
|
18
|
-
definition_of_done: "The intention is realized in the workspace.",
|
|
19
|
-
depends_on: [],
|
|
20
|
-
},
|
|
21
|
-
]);
|
package/planners/producer.mjs
DELETED
|
@@ -1,233 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
3
|
-
import { tmpdir } from "node:os";
|
|
4
|
-
import { join } from "node:path";
|
|
5
|
-
import {
|
|
6
|
-
checkPlan,
|
|
7
|
-
emitPlan,
|
|
8
|
-
emitRefusal,
|
|
9
|
-
fillPrompt,
|
|
10
|
-
loadPrompt,
|
|
11
|
-
PLANNER_FLAGS,
|
|
12
|
-
parseRole,
|
|
13
|
-
spawnFilled,
|
|
14
|
-
take,
|
|
15
|
-
textOption,
|
|
16
|
-
transcriptArgs,
|
|
17
|
-
writeDiagnostic,
|
|
18
|
-
} from "@bluewombat/slot-kit";
|
|
19
|
-
|
|
20
|
-
const NAME = "planner producer";
|
|
21
|
-
|
|
22
|
-
const DEFAULT_TEMPLATE = `You are splitting one piece of work into an ordered list of subtasks.
|
|
23
|
-
|
|
24
|
-
You are not writing any of it. Do not modify anything in this directory. Read as
|
|
25
|
-
much of it as you need first: the split has to fit the project as it is. Do not
|
|
26
|
-
invent conventions you can discover from the repository.
|
|
27
|
-
|
|
28
|
-
The work:
|
|
29
|
-
{{intention}}
|
|
30
|
-
|
|
31
|
-
Treat that as the source of truth for the desired outcome, not as a finished
|
|
32
|
-
specification. Its wording may be incomplete. Separate what it explicitly
|
|
33
|
-
requires from what the repository or ordinary engineering practice implies.
|
|
34
|
-
Do not invent requirements to make the plan look complete.
|
|
35
|
-
|
|
36
|
-
How the subtasks will be run, which is what makes a good split:
|
|
37
|
-
|
|
38
|
-
- One at a time, never in parallel. Each runs in its own copy of this project,
|
|
39
|
-
and its result is folded back before the next one starts. So a later subtask
|
|
40
|
-
sees the earlier ones' work. If two slices would both change the same path
|
|
41
|
-
(a README, an index or barrel file, a changelog, package.json, or any other
|
|
42
|
-
shared file), make them **one** subtask, or give the second a \`depends_on\` on
|
|
43
|
-
the first and let **only** that second one write the shared path. Do not invent
|
|
44
|
-
a docs-only subtask unless the work asked for documentation.
|
|
45
|
-
- Every subtask is checked by the project's own checks — its tests, its lint —
|
|
46
|
-
on its own. **A subtask must leave the project working.** This is the rule that
|
|
47
|
-
decides your split: never cut by layer. "Add the types", then "add the code",
|
|
48
|
-
then "add the tests" is wrong, because the first two leave the project broken.
|
|
49
|
-
Cut by thin slices that each work end to end. Tests belong in the subtask that
|
|
50
|
-
introduces or changes the behavior they verify.
|
|
51
|
-
- Each subtask is given to an agent with no memory of you and no memory of the
|
|
52
|
-
others. Its intention has to stand alone: no "as discussed above", no knowledge
|
|
53
|
-
of this planning process, no requirement that lives only in the original work
|
|
54
|
-
description. Copy into the subtask everything its agent needs. A pointer at
|
|
55
|
-
existing code is fine if it names the behavior or location clearly enough to
|
|
56
|
-
investigate.
|
|
57
|
-
- Write each intention the way a good commit message is written: one headline
|
|
58
|
-
of at most 72 characters in the imperative ("Add titleCase in
|
|
59
|
-
src/title-case.js"), a blank line, then everything the agent needs. The
|
|
60
|
-
headline is what people see: in the plan on the tracker, in the change list
|
|
61
|
-
of the submission, on the commit.
|
|
62
|
-
- Its definition of done has to be checkable by someone who cannot read your
|
|
63
|
-
mind, and preferably by a test. It describes this subtask's observable result,
|
|
64
|
-
not the whole feature and not an intention.
|
|
65
|
-
- \`depends_on\` means "cannot start before this one is done", and nothing else.
|
|
66
|
-
Sharing a file is a reason not to start side by side: pack those slices into
|
|
67
|
-
one subtask, or make the later one depend on the earlier. The graph describes
|
|
68
|
-
what must land before a merge is safe, not the order you happened to think in.
|
|
69
|
-
Chaining every subtask to the one before it when their files do not overlap
|
|
70
|
-
hides which ones were actually independent.
|
|
71
|
-
|
|
72
|
-
You are choosing between two costs, and both are real.
|
|
73
|
-
|
|
74
|
-
Splitting too finely costs a full agent run per slice, and each slice pays the
|
|
75
|
-
project's whole check sequence again. Do not create a subtask solely to read
|
|
76
|
-
code, decide what to implement, create folders, rename things, add comments,
|
|
77
|
-
write documentation (unless the work asked for it), clean up, or refactor
|
|
78
|
-
speculatively.
|
|
79
|
-
|
|
80
|
-
Not splitting enough costs more, and less visibly. One subtask is one agent run,
|
|
81
|
-
with one budget of attempts and one clock: a subtask too large for that exhausts
|
|
82
|
-
them and the whole piece of work stops. And a subtask that fails goes back to
|
|
83
|
-
nothing, while a subtask that succeeded is never redone. Ten modules in one
|
|
84
|
-
subtask means ten modules redone when the tenth goes wrong.
|
|
85
|
-
|
|
86
|
-
Prefer adapting what the project already does over introducing a parallel
|
|
87
|
-
mechanism. Do not propose an architectural change merely because it might be
|
|
88
|
-
cleaner.
|
|
89
|
-
|
|
90
|
-
So: as few subtasks as the work allows, and none larger than what one competent
|
|
91
|
-
engineer would finish in a single sitting without losing the thread. At most
|
|
92
|
-
{{max_units}}.
|
|
93
|
-
|
|
94
|
-
Write your answer as JSON, and nothing else, to this file:
|
|
95
|
-
|
|
96
|
-
{{out}}
|
|
97
|
-
|
|
98
|
-
It looks like this:
|
|
99
|
-
|
|
100
|
-
{
|
|
101
|
-
"subtasks": [
|
|
102
|
-
{ "id": "s1", "intention": "Headline\\n\\nEverything the agent needs.", "definition_of_done": "...", "depends_on": [] },
|
|
103
|
-
{ "id": "s2", "intention": "Headline\\n\\n...", "definition_of_done": "...", "depends_on": ["s1"] }
|
|
104
|
-
]
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
If the work cannot be split as it is written — contradictory outcomes, a
|
|
108
|
-
critical requirement so ambiguous that the implementation would change with
|
|
109
|
-
the reading, or too little to determine what should be built — write this
|
|
110
|
-
instead, and do not invent a plan to fill the silence. Do not refuse merely
|
|
111
|
-
because some implementation details are unspecified; those come from the
|
|
112
|
-
repository.
|
|
113
|
-
|
|
114
|
-
{ "outcome": "refused", "reason": "..." }
|
|
115
|
-
`;
|
|
116
|
-
|
|
117
|
-
const intention = (take(process.argv, "--intention") ?? "").trim();
|
|
118
|
-
if (intention.length === 0) {
|
|
119
|
-
fail("The Planner received no --intention.");
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
const parsed = parseRole(process.argv, PLANNER_FLAGS, NAME, { "--read": {} });
|
|
123
|
-
if (!parsed.ok) {
|
|
124
|
-
fail(parsed.reason);
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
const template = loadPrompt(parsed.options, DEFAULT_TEMPLATE);
|
|
128
|
-
if (!template.ok) {
|
|
129
|
-
fail(template.reason);
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
const maxUnits = Number.parseInt(take(process.argv, "--max-units") ?? "", 10);
|
|
133
|
-
const scratch = mkdtempSync(join(tmpdir(), "plan-"));
|
|
134
|
-
const out = join(scratch, "plan.json");
|
|
135
|
-
const cwd = textOption(parsed.options, "read") ?? process.cwd();
|
|
136
|
-
|
|
137
|
-
try {
|
|
138
|
-
const rendered = fillPrompt(
|
|
139
|
-
template.value,
|
|
140
|
-
{
|
|
141
|
-
intention,
|
|
142
|
-
max_units: Number.isInteger(maxUnits) ? String(maxUnits) : "a handful",
|
|
143
|
-
out,
|
|
144
|
-
},
|
|
145
|
-
["intention", "out"],
|
|
146
|
-
textOption(parsed.options, "promptFile") ?? "built-in template",
|
|
147
|
-
);
|
|
148
|
-
if (!rendered.ok) {
|
|
149
|
-
fail(rendered.reason);
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
const spawned = await spawnFilled(
|
|
153
|
-
parsed.runner,
|
|
154
|
-
rendered.prompt,
|
|
155
|
-
transcriptArgs(process.argv, {
|
|
156
|
-
id: "breakdown",
|
|
157
|
-
context: take(process.argv, "--key"),
|
|
158
|
-
}),
|
|
159
|
-
cwd,
|
|
160
|
-
);
|
|
161
|
-
if (!spawned.ok) {
|
|
162
|
-
fail(spawned.reason);
|
|
163
|
-
}
|
|
164
|
-
if (spawned.run.error !== undefined) {
|
|
165
|
-
fail(`Could not start the agent at ${spawned.about.bin}: ${spawned.run.error.message}`);
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
const answer = readAnswer(out);
|
|
169
|
-
if (answer === undefined) {
|
|
170
|
-
fail(`The agent wrote no usable plan to ${out}. ${tail(spawned.run.output)}`);
|
|
171
|
-
}
|
|
172
|
-
if (answer.outcome === "refused") {
|
|
173
|
-
const reason =
|
|
174
|
-
typeof answer.reason === "string" && answer.reason.trim().length > 0
|
|
175
|
-
? answer.reason.trim()
|
|
176
|
-
: "The Planner refused without saying why.";
|
|
177
|
-
emitRefusal(reason);
|
|
178
|
-
process.exit(0);
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
const checked = checkPlan(answer.subtasks, maxUnits);
|
|
182
|
-
if (!checked.ok) {
|
|
183
|
-
fail(`The agent returned a plan that cannot be used: ${checked.reason}`);
|
|
184
|
-
}
|
|
185
|
-
emitPlan(
|
|
186
|
-
checked.subtasks.map((subtask) => ({
|
|
187
|
-
id: subtask.id,
|
|
188
|
-
intention: subtask.intention,
|
|
189
|
-
definition_of_done: subtask.definition_of_done,
|
|
190
|
-
depends_on: subtask.depends_on,
|
|
191
|
-
})),
|
|
192
|
-
);
|
|
193
|
-
process.exit(0);
|
|
194
|
-
} finally {
|
|
195
|
-
rmSync(scratch, { recursive: true, force: true });
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
/** @param {string} path @returns {Record<string, unknown> | undefined} */
|
|
199
|
-
function readAnswer(path) {
|
|
200
|
-
let text;
|
|
201
|
-
try {
|
|
202
|
-
text = readFileSync(path, "utf8");
|
|
203
|
-
} catch {
|
|
204
|
-
return undefined;
|
|
205
|
-
}
|
|
206
|
-
const fenced = /```(?:json)?\s*([\s\S]*?)```/.exec(text);
|
|
207
|
-
for (const candidate of [fenced?.[1], text]) {
|
|
208
|
-
if (candidate === undefined) {
|
|
209
|
-
continue;
|
|
210
|
-
}
|
|
211
|
-
try {
|
|
212
|
-
const value = JSON.parse(candidate);
|
|
213
|
-
if (value !== null && typeof value === "object") {
|
|
214
|
-
return value;
|
|
215
|
-
}
|
|
216
|
-
} catch {
|
|
217
|
-
// try the next reading
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
return undefined;
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
/** @param {string | undefined} output */
|
|
224
|
-
function tail(output) {
|
|
225
|
-
const trimmed = (output ?? "").trim();
|
|
226
|
-
return trimmed.length === 0 ? "" : trimmed.slice(-1000);
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
/** @param {string} reason @returns {never} */
|
|
230
|
-
function fail(reason) {
|
|
231
|
-
writeDiagnostic(`${reason}\n`);
|
|
232
|
-
process.exit(1);
|
|
233
|
-
}
|