@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.
@@ -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
+ }