@bluewombat/slots 0.1.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 +447 -0
- package/agents/claude.mjs +97 -0
- package/agents/cursor.mjs +93 -0
- package/assembly/fix.mjs +102 -0
- package/builders/producer.mjs +85 -0
- package/builders/repair.mjs +103 -0
- package/gates/ci-green.mjs +319 -0
- package/gates/parent-clean.mjs +168 -0
- package/gates/sensitive-path.mjs +105 -0
- package/gates/workspace-changed.mjs +56 -0
- package/lib/fill.mjs +17 -0
- package/messages/conventional.mjs +68 -0
- package/messages/git-producer.mjs +151 -0
- package/package.json +58 -0
- package/planners/one-subtask.mjs +21 -0
- package/planners/producer.mjs +226 -0
- package/publishers/git.mjs +61 -0
- package/refreshers/git.mjs +75 -0
|
@@ -0,0 +1,226 @@
|
|
|
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
|
+
} from "@bluewombat/slot-kit";
|
|
18
|
+
|
|
19
|
+
const NAME = "planner producer";
|
|
20
|
+
|
|
21
|
+
const DEFAULT_TEMPLATE = `You are splitting one piece of work into an ordered list of subtasks.
|
|
22
|
+
|
|
23
|
+
You are not writing any of it. Do not modify anything in this directory. Read as
|
|
24
|
+
much of it as you need first: the split has to fit the project as it is. Do not
|
|
25
|
+
invent conventions you can discover from the repository.
|
|
26
|
+
|
|
27
|
+
The work:
|
|
28
|
+
{{intention}}
|
|
29
|
+
|
|
30
|
+
Treat that as the source of truth for the desired outcome, not as a finished
|
|
31
|
+
specification. Its wording may be incomplete. Separate what it explicitly
|
|
32
|
+
requires from what the repository or ordinary engineering practice implies.
|
|
33
|
+
Do not invent requirements to make the plan look complete.
|
|
34
|
+
|
|
35
|
+
How the subtasks will be run, which is what makes a good split:
|
|
36
|
+
|
|
37
|
+
- One at a time, never in parallel. Each runs in its own copy of this project,
|
|
38
|
+
and its result is folded back before the next one starts. So a later subtask
|
|
39
|
+
sees the earlier ones' work.
|
|
40
|
+
- Every subtask is checked by the project's own checks — its tests, its lint —
|
|
41
|
+
on its own. **A subtask must leave the project working.** This is the rule that
|
|
42
|
+
decides your split: never cut by layer. "Add the types", then "add the code",
|
|
43
|
+
then "add the tests" is wrong, because the first two leave the project broken.
|
|
44
|
+
Cut by thin slices that each work end to end. Tests belong in the subtask that
|
|
45
|
+
introduces or changes the behavior they verify.
|
|
46
|
+
- Each subtask is given to an agent with no memory of you and no memory of the
|
|
47
|
+
others. Its intention has to stand alone: no "as discussed above", no knowledge
|
|
48
|
+
of this planning process, no requirement that lives only in the original work
|
|
49
|
+
description. Copy into the subtask everything its agent needs. A pointer at
|
|
50
|
+
existing code is fine if it names the behavior or location clearly enough to
|
|
51
|
+
investigate.
|
|
52
|
+
- Write each intention the way a good commit message is written: one headline
|
|
53
|
+
of at most 72 characters in the imperative ("Add titleCase in
|
|
54
|
+
src/title-case.js"), a blank line, then everything the agent needs. The
|
|
55
|
+
headline is what people see: in the plan on the tracker, in the change list
|
|
56
|
+
of the submission, on the commit.
|
|
57
|
+
- Its definition of done has to be checkable by someone who cannot read your
|
|
58
|
+
mind, and preferably by a test. It describes this subtask's observable result,
|
|
59
|
+
not the whole feature and not an intention.
|
|
60
|
+
- \`depends_on\` means "cannot start before this one is done", and nothing else.
|
|
61
|
+
List only what a subtask genuinely cannot begin without. Chaining every
|
|
62
|
+
subtask to the one before it, out of habit, describes an order you were never
|
|
63
|
+
asked for and hides which ones were actually independent.
|
|
64
|
+
|
|
65
|
+
You are choosing between two costs, and both are real.
|
|
66
|
+
|
|
67
|
+
Splitting too finely costs a full agent run per slice, and each slice pays the
|
|
68
|
+
project's whole check sequence again. Do not create a subtask solely to read
|
|
69
|
+
code, decide what to implement, create folders, rename things, add comments,
|
|
70
|
+
write documentation (unless the work asked for it), clean up, or refactor
|
|
71
|
+
speculatively.
|
|
72
|
+
|
|
73
|
+
Not splitting enough costs more, and less visibly. One subtask is one agent run,
|
|
74
|
+
with one budget of attempts and one clock: a subtask too large for that exhausts
|
|
75
|
+
them and the whole piece of work stops. And a subtask that fails goes back to
|
|
76
|
+
nothing, while a subtask that succeeded is never redone. Ten modules in one
|
|
77
|
+
subtask means ten modules redone when the tenth goes wrong.
|
|
78
|
+
|
|
79
|
+
Prefer adapting what the project already does over introducing a parallel
|
|
80
|
+
mechanism. Do not propose an architectural change merely because it might be
|
|
81
|
+
cleaner.
|
|
82
|
+
|
|
83
|
+
So: as few subtasks as the work allows, and none larger than what one competent
|
|
84
|
+
engineer would finish in a single sitting without losing the thread. At most
|
|
85
|
+
{{max_units}}.
|
|
86
|
+
|
|
87
|
+
Write your answer as JSON, and nothing else, to this file:
|
|
88
|
+
|
|
89
|
+
{{out}}
|
|
90
|
+
|
|
91
|
+
It looks like this:
|
|
92
|
+
|
|
93
|
+
{
|
|
94
|
+
"subtasks": [
|
|
95
|
+
{ "id": "s1", "intention": "Headline\\n\\nEverything the agent needs.", "definition_of_done": "...", "depends_on": [] },
|
|
96
|
+
{ "id": "s2", "intention": "Headline\\n\\n...", "definition_of_done": "...", "depends_on": ["s1"] }
|
|
97
|
+
]
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
If the work cannot be split as it is written — contradictory outcomes, a
|
|
101
|
+
critical requirement so ambiguous that the implementation would change with
|
|
102
|
+
the reading, or too little to determine what should be built — write this
|
|
103
|
+
instead, and do not invent a plan to fill the silence. Do not refuse merely
|
|
104
|
+
because some implementation details are unspecified; those come from the
|
|
105
|
+
repository.
|
|
106
|
+
|
|
107
|
+
{ "outcome": "refused", "reason": "..." }
|
|
108
|
+
`;
|
|
109
|
+
|
|
110
|
+
const intention = (take(process.argv, "--intention") ?? "").trim();
|
|
111
|
+
if (intention.length === 0) {
|
|
112
|
+
fail("The Planner received no --intention.");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const parsed = parseRole(process.argv, PLANNER_FLAGS, NAME, { "--read": {} });
|
|
116
|
+
if (!parsed.ok) {
|
|
117
|
+
fail(parsed.reason);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const template = loadPrompt(parsed.options, DEFAULT_TEMPLATE);
|
|
121
|
+
if (!template.ok) {
|
|
122
|
+
fail(template.reason);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const maxUnits = Number.parseInt(take(process.argv, "--max-units") ?? "", 10);
|
|
126
|
+
const scratch = mkdtempSync(join(tmpdir(), "plan-"));
|
|
127
|
+
const out = join(scratch, "plan.json");
|
|
128
|
+
const cwd = textOption(parsed.options, "read") ?? process.cwd();
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
const rendered = fillPrompt(
|
|
132
|
+
template.value,
|
|
133
|
+
{
|
|
134
|
+
intention,
|
|
135
|
+
max_units: Number.isInteger(maxUnits) ? String(maxUnits) : "a handful",
|
|
136
|
+
out,
|
|
137
|
+
},
|
|
138
|
+
["intention", "out"],
|
|
139
|
+
textOption(parsed.options, "promptFile") ?? "built-in template",
|
|
140
|
+
);
|
|
141
|
+
if (!rendered.ok) {
|
|
142
|
+
fail(rendered.reason);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const spawned = await spawnFilled(
|
|
146
|
+
parsed.runner,
|
|
147
|
+
rendered.prompt,
|
|
148
|
+
transcriptArgs(process.argv, {
|
|
149
|
+
id: "breakdown",
|
|
150
|
+
context: take(process.argv, "--key"),
|
|
151
|
+
}),
|
|
152
|
+
cwd,
|
|
153
|
+
);
|
|
154
|
+
if (!spawned.ok) {
|
|
155
|
+
fail(spawned.reason);
|
|
156
|
+
}
|
|
157
|
+
if (spawned.run.error !== undefined) {
|
|
158
|
+
fail(`Could not start the agent at ${spawned.about.bin}: ${spawned.run.error.message}`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const answer = readAnswer(out);
|
|
162
|
+
if (answer === undefined) {
|
|
163
|
+
fail(`The agent wrote no usable plan to ${out}. ${tail(spawned.run.output)}`);
|
|
164
|
+
}
|
|
165
|
+
if (answer.outcome === "refused") {
|
|
166
|
+
const reason =
|
|
167
|
+
typeof answer.reason === "string" && answer.reason.trim().length > 0
|
|
168
|
+
? answer.reason.trim()
|
|
169
|
+
: "The Planner refused without saying why.";
|
|
170
|
+
emitRefusal(reason);
|
|
171
|
+
process.exit(0);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const checked = checkPlan(answer.subtasks, maxUnits);
|
|
175
|
+
if (!checked.ok) {
|
|
176
|
+
fail(`The agent returned a plan that cannot be used: ${checked.reason}`);
|
|
177
|
+
}
|
|
178
|
+
emitPlan(
|
|
179
|
+
checked.subtasks.map((subtask) => ({
|
|
180
|
+
id: subtask.id,
|
|
181
|
+
intention: subtask.intention,
|
|
182
|
+
definition_of_done: subtask.definition_of_done,
|
|
183
|
+
depends_on: subtask.depends_on,
|
|
184
|
+
})),
|
|
185
|
+
);
|
|
186
|
+
process.exit(0);
|
|
187
|
+
} finally {
|
|
188
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** @param {string} path @returns {Record<string, unknown> | undefined} */
|
|
192
|
+
function readAnswer(path) {
|
|
193
|
+
let text;
|
|
194
|
+
try {
|
|
195
|
+
text = readFileSync(path, "utf8");
|
|
196
|
+
} catch {
|
|
197
|
+
return undefined;
|
|
198
|
+
}
|
|
199
|
+
const fenced = /```(?:json)?\s*([\s\S]*?)```/.exec(text);
|
|
200
|
+
for (const candidate of [fenced?.[1], text]) {
|
|
201
|
+
if (candidate === undefined) {
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
try {
|
|
205
|
+
const value = JSON.parse(candidate);
|
|
206
|
+
if (value !== null && typeof value === "object") {
|
|
207
|
+
return value;
|
|
208
|
+
}
|
|
209
|
+
} catch {
|
|
210
|
+
// try the next reading
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return undefined;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** @param {string | undefined} output */
|
|
217
|
+
function tail(output) {
|
|
218
|
+
const trimmed = (output ?? "").trim();
|
|
219
|
+
return trimmed.length === 0 ? "" : trimmed.slice(-1000);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** @param {string} reason @returns {never} */
|
|
223
|
+
function fail(reason) {
|
|
224
|
+
process.stderr.write(`${reason}\n`);
|
|
225
|
+
process.exit(1);
|
|
226
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
emitPublication,
|
|
7
|
+
emitPublishRefusal,
|
|
8
|
+
ownArgv,
|
|
9
|
+
PUBLISHER_FLAGS,
|
|
10
|
+
take,
|
|
11
|
+
} from "@bluewombat/slot-kit";
|
|
12
|
+
|
|
13
|
+
// Put the feature's work where the Authority can read it, by pushing the name
|
|
14
|
+
// Isolator gave it to the work line's own remote.
|
|
15
|
+
//
|
|
16
|
+
// A plain push, and no force of any kind. Integrator only ever adds history, so
|
|
17
|
+
// republishing after a refusal is a fast-forward of what was already there. A
|
|
18
|
+
// rejection therefore means the name moved under us — someone else's commit, or
|
|
19
|
+
// a rewrite — and that is exactly the case worth refusing rather than
|
|
20
|
+
// overwriting.
|
|
21
|
+
//
|
|
22
|
+
// The remote is `origin`, and there is no option for it: Host reads the work
|
|
23
|
+
// line back from `origin` after the Authority folds, with no way to learn what
|
|
24
|
+
// this slot was told. An option that publishes to one remote while the refresh
|
|
25
|
+
// reads another is a trap, so it waits for that half to become a slot too.
|
|
26
|
+
//
|
|
27
|
+
// Runs in the work line. mason appends --id, --ref and --target.
|
|
28
|
+
|
|
29
|
+
const cwd = process.cwd();
|
|
30
|
+
const tokens = ownArgv(process.argv, PUBLISHER_FLAGS);
|
|
31
|
+
if (tokens.length > 0) {
|
|
32
|
+
emitPublishRefusal(`git publisher takes no option, got "${tokens[0]}".`);
|
|
33
|
+
process.exit(0);
|
|
34
|
+
}
|
|
35
|
+
const remote = "origin";
|
|
36
|
+
|
|
37
|
+
const ref = take(process.argv, "--ref");
|
|
38
|
+
if (ref === undefined || ref.trim().length === 0) {
|
|
39
|
+
emitPublishRefusal("git publisher was given no --ref to publish.");
|
|
40
|
+
process.exit(0);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (!existsSync(join(cwd, ".git"))) {
|
|
44
|
+
emitPublishRefusal("A Submission needs a git work line; this one has no .git.");
|
|
45
|
+
process.exit(0);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const pushed = spawnSync("git", ["-C", cwd, "push", remote, `${ref}:${ref}`], {
|
|
49
|
+
encoding: "utf8",
|
|
50
|
+
});
|
|
51
|
+
if (pushed.error !== undefined) {
|
|
52
|
+
process.stderr.write(`git publisher cannot run git: ${pushed.error.message}\n`);
|
|
53
|
+
process.exit(1);
|
|
54
|
+
}
|
|
55
|
+
if (pushed.status !== 0) {
|
|
56
|
+
const detail = `${pushed.stderr ?? ""}${pushed.stdout ?? ""}`.trim().slice(-1000);
|
|
57
|
+
emitPublishRefusal(`Could not publish ${ref}: ${detail}`);
|
|
58
|
+
process.exit(0);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
emitPublication(ref);
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
emitRefreshed,
|
|
7
|
+
emitRefreshRefusal,
|
|
8
|
+
ownArgv,
|
|
9
|
+
REFRESHER_FLAGS,
|
|
10
|
+
take,
|
|
11
|
+
} from "@bluewombat/slot-kit";
|
|
12
|
+
|
|
13
|
+
// Bring the work line copy up to what the Authority holds.
|
|
14
|
+
//
|
|
15
|
+
// An accepted Submission moved the reference work line. A copy left behind
|
|
16
|
+
// would make the next Isolation start from a version that no longer exists, so
|
|
17
|
+
// this runs before anything else in a pass.
|
|
18
|
+
//
|
|
19
|
+
// Fast-forward only: this system never rewrites that line. A copy that cannot
|
|
20
|
+
// fast-forward has been written to by somebody else, and that is a person's
|
|
21
|
+
// problem, not a merge to attempt.
|
|
22
|
+
//
|
|
23
|
+
// The remote is `origin`, like the git Publisher's. Takes no option.
|
|
24
|
+
|
|
25
|
+
const cwd = process.cwd();
|
|
26
|
+
const tokens = ownArgv(process.argv, REFRESHER_FLAGS);
|
|
27
|
+
if (tokens.length > 0) {
|
|
28
|
+
emitRefreshRefusal(`git refresher takes no option, got "${tokens[0]}".`);
|
|
29
|
+
process.exit(0);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const target = take(process.argv, "--target");
|
|
33
|
+
if (target === undefined || target.trim().length === 0) {
|
|
34
|
+
emitRefreshRefusal("git refresher was given no --target work line.");
|
|
35
|
+
process.exit(0);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// A plain directory has no reference held anywhere else: it is the work line.
|
|
39
|
+
if (!existsSync(join(cwd, ".git"))) {
|
|
40
|
+
emitRefreshed();
|
|
41
|
+
process.exit(0);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const fetched = git(["fetch", "origin", target]);
|
|
45
|
+
if (fetched.status !== 0) {
|
|
46
|
+
emitRefreshRefusal(
|
|
47
|
+
`Cannot fetch origin/${target} from ${cwd}: ${fetched.stderr.trim().slice(-500)}`,
|
|
48
|
+
);
|
|
49
|
+
process.exit(0);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const merged = git(["merge", "--ff-only", `origin/${target}`]);
|
|
53
|
+
if (merged.status !== 0) {
|
|
54
|
+
// The one way a copy stops fast-forwarding is a commit made into it that
|
|
55
|
+
// the reference does not have. The copy is the system's, not a person's:
|
|
56
|
+
// nothing in it is worth keeping, and the reader needs the way out more
|
|
57
|
+
// than git's own words.
|
|
58
|
+
emitRefreshRefusal(
|
|
59
|
+
[
|
|
60
|
+
`${cwd} has diverged from origin/${target}: it holds commits the reference does not.`,
|
|
61
|
+
"Nothing in this copy is worth keeping — it is the system's, refetched at every start.",
|
|
62
|
+
`Put it back with: git -C ${cwd} reset --hard origin/${target}`,
|
|
63
|
+
"or delete the directory and start again; it is fetched afresh.",
|
|
64
|
+
`git said: ${merged.stderr.trim().slice(-300)}`,
|
|
65
|
+
].join("\n"),
|
|
66
|
+
);
|
|
67
|
+
process.exit(0);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
emitRefreshed();
|
|
71
|
+
|
|
72
|
+
/** @param {string[]} args */
|
|
73
|
+
function git(args) {
|
|
74
|
+
return spawnSync("git", ["-C", cwd, ...args], { encoding: "utf8" });
|
|
75
|
+
}
|