@bluewombat/slots 0.1.1 → 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 +86 -43
- package/agents/claude-extras.mjs +113 -0
- package/agents/claude.mjs +19 -7
- package/agents/cursor-extras.mjs +142 -0
- package/agents/cursor.mjs +17 -7
- package/assembly/validate.mjs +204 -0
- package/builders/producer.mjs +4 -0
- package/messages/conventional.mjs +2 -2
- package/messages/git-producer.mjs +2 -1
- package/package.json +2 -2
- package/planners/producer.mjs +12 -5
- package/publishers/git.mjs +2 -1
package/README.md
CHANGED
|
@@ -58,30 +58,32 @@ these all use it. How Host wires a slot into a run:
|
|
|
58
58
|
|
|
59
59
|
## Catalogue
|
|
60
60
|
|
|
61
|
-
| Slot
|
|
62
|
-
|
|
|
63
|
-
| `agents/cursor.mjs`
|
|
64
|
-
| `agents/claude.mjs`
|
|
65
|
-
| `builders/producer.mjs`
|
|
66
|
-
| `builders/repair.mjs`
|
|
67
|
-
| `assembly/fix.mjs`
|
|
68
|
-
| `
|
|
69
|
-
| `gates/
|
|
70
|
-
| `gates/
|
|
71
|
-
| `gates/
|
|
72
|
-
| `
|
|
73
|
-
| `
|
|
74
|
-
| `
|
|
75
|
-
| `
|
|
76
|
-
| `
|
|
77
|
-
| `messages/
|
|
61
|
+
| Slot | Kind | Answers |
|
|
62
|
+
| ----------------------------- | --------- | ------------------------------------------------------------------------------------------ |
|
|
63
|
+
| `agents/cursor.mjs` | Agent | Runs Cursor CLI with a filled prompt |
|
|
64
|
+
| `agents/claude.mjs` | Agent | Runs Claude Code with a filled prompt |
|
|
65
|
+
| `builders/producer.mjs` | Builder | First pass of a Subtask: fill `{{task}}`, then spawn the agent |
|
|
66
|
+
| `builders/repair.mjs` | Builder | A Gate refused the Subtask: fill `{{task}}` and `{{report}}` |
|
|
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: …` |
|
|
69
|
+
| `gates/workspace-changed.mjs` | Gate | The Attempt left an uncommitted change |
|
|
70
|
+
| `gates/parent-clean.mjs` | Gate | Isolator's Parent working files did not move |
|
|
71
|
+
| `gates/sensitive-path.mjs` | Gate | No path matching a glob was touched |
|
|
72
|
+
| `gates/ci-green.mjs` | Gate | The work line's own checks came back green |
|
|
73
|
+
| `planners/one-subtask.mjs` | Planner | Bootstrap: one Subtask that is the FeatureStandard itself |
|
|
74
|
+
| `publishers/git.mjs` | Publisher | Pushes the feature's branch to the work line's own remote |
|
|
75
|
+
| `refreshers/git.mjs` | Refresher | Fast-forwards the work line copy onto what the Authority holds |
|
|
76
|
+
| `planners/producer.mjs` | Planner | An agent CLI splits the FeatureStandard |
|
|
77
|
+
| `messages/conventional.mjs` | Message | `<type>: <title>` for the feature's fold, no agent; `--scope`, `--type` |
|
|
78
|
+
| `messages/git-producer.mjs` | Describer | An agent reads the git diff and writes the feature's subject and body under `--rules-file` |
|
|
78
79
|
|
|
79
80
|
## Agents
|
|
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
|
"--",
|
|
@@ -92,18 +94,36 @@ and print the serialized run on stdout. They do not know `--intention` or
|
|
|
92
94
|
`cursor.mjs` needs `cursor-agent` on PATH (`cursor-agent login`); `claude.mjs`
|
|
93
95
|
needs `claude`, signed in once.
|
|
94
96
|
|
|
95
|
-
| Option
|
|
96
|
-
|
|
|
97
|
-
| `--prompt TEXT`
|
|
98
|
-
| `--prompt-file PATH`
|
|
99
|
-
| `--bin PATH`
|
|
100
|
-
| `--model NAME`
|
|
101
|
-
| `--output-format FMT`
|
|
102
|
-
| `--permission-mode MODE`
|
|
103
|
-
| `--transcript-dir PATH`
|
|
104
|
-
| `--transcript-part NAME`
|
|
105
|
-
| `--agent-arg VALUE`
|
|
106
|
-
| `--id` / `--attempt` / `--context` | How a transcript is filed. Forwarded by the role
|
|
97
|
+
| Option | Meaning |
|
|
98
|
+
| ---------------------------------- | -------------------------------------------------------------------------------- |
|
|
99
|
+
| `--prompt TEXT` | The filled prompt. Mutually exclusive with `--prompt-file` |
|
|
100
|
+
| `--prompt-file PATH` | The same text, from a file. No interpolation |
|
|
101
|
+
| `--bin PATH` | The CLI to run. Default: the vendor's own name, on PATH |
|
|
102
|
+
| `--model NAME` | Model for that run |
|
|
103
|
+
| `--output-format FMT` | `stream-json` (default), `json`, or `text`. Skill extraction needs `stream-json` |
|
|
104
|
+
| `--permission-mode MODE` | `claude.mjs` only. Default `bypassPermissions` |
|
|
105
|
+
| `--transcript-dir PATH` | Write one file per turn under here. Off unless given |
|
|
106
|
+
| `--transcript-part NAME` | `prompt`, `stdout`, `stderr`, `timing`. Repeatable. Default: all |
|
|
107
|
+
| `--agent-arg VALUE` | Appended to the CLI argv, before the prompt. Repeatable |
|
|
108
|
+
| `--id` / `--attempt` / `--context` | How a transcript is filed. Forwarded by the role |
|
|
109
|
+
|
|
110
|
+
The agent CLI prints **one JSON line** — a `SerializedRun` from
|
|
111
|
+
`@bluewombat/slot-kit` — with the vendor's raw streams plus two extras the
|
|
112
|
+
wrapper fills when it can:
|
|
113
|
+
|
|
114
|
+
| Field | Meaning |
|
|
115
|
+
| -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
116
|
+
| `skills` | `null` = unknown (not stream-json, or unparseable). `[]` = looked, none used. Otherwise skill names in order |
|
|
117
|
+
| `usage` | `null` = unknown. Otherwise token counts (`input`, `output`, `cacheRead`, `cacheWrite`) and `costUsd` (`null` when the vendor did not report a cost) |
|
|
118
|
+
|
|
119
|
+
Cursor extracts skills from `stream-json` `readToolCall` paths ending in
|
|
120
|
+
`/skills/<name>/SKILL.md`. Claude extracts them from `tool_use` blocks named
|
|
121
|
+
`Skill` (`input.skill`), and adds `--verbose` whenever the format is
|
|
122
|
+
`stream-json`. A future vendor agent (`codex.mjs`, …) uses the same fields:
|
|
123
|
+
leave them `null` when it cannot tell.
|
|
124
|
+
|
|
125
|
+
When `--transcript-dir` is set, the transcript header repeats `skills` and
|
|
126
|
+
`usage` (`unknown`, `(none)`, or the values).
|
|
107
127
|
|
|
108
128
|
A global npm install belongs to one Node version: after `nvm use`, a `claude`
|
|
109
129
|
installed under another version is off PATH. Give `--bin` the absolute path, or
|
|
@@ -159,11 +179,12 @@ never places, is `fail-blocking`. `{{done_when}}` is `Done when:` plus the
|
|
|
159
179
|
definition of done, and vanishes whole when there is none — which is every
|
|
160
180
|
assembly, so `assembly/fix` does not fill it at all.
|
|
161
181
|
|
|
162
|
-
| Role
|
|
163
|
-
|
|
|
164
|
-
| `builders/producer`
|
|
165
|
-
| `builders/repair`
|
|
166
|
-
| `assembly/fix`
|
|
182
|
+
| Role | Required | Also fills |
|
|
183
|
+
| ------------------- | ------------------------------------- | ---------------------------------------------------------- |
|
|
184
|
+
| `builders/producer` | `{{task}}`, `{{rules}}` | `{{done_when}}`, `{{id}}`, `{{attempt}}` |
|
|
185
|
+
| `builders/repair` | `{{task}}`, `{{report}}`, `{{rules}}` | `{{refused_by}}`, `{{done_when}}`, `{{id}}`, `{{attempt}}` |
|
|
186
|
+
| `assembly/fix` | `{{task}}`, `{{report}}`, `{{rules}}` | `{{refused_by}}`, `{{id}}`, `{{attempt}}` |
|
|
187
|
+
| `assembly/validate` | `{{task}}`, `{{rules}}` | `{{id}}`, `{{attempt}}` |
|
|
167
188
|
|
|
168
189
|
A first-pass command that receives `--report` is `fail-blocking`: that report
|
|
169
190
|
belongs to the repair command. A repair or fix command without `--report` is the same.
|
|
@@ -185,6 +206,27 @@ The units already passed. `assembly/fix.mjs` tells the agent to change what the
|
|
|
185
206
|
refusal names and leave the rest. Same placeholders as repair except
|
|
186
207
|
`{{done_when}}`, which an assembly has nothing to fill.
|
|
187
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
|
+
|
|
188
230
|
### The rules
|
|
189
231
|
|
|
190
232
|
`{{rules}}` holds the bounds of the slot: one directory, work left uncommitted,
|
|
@@ -206,9 +248,10 @@ but inside the slot, and by the time a result reaches Host it is one word. So a
|
|
|
206
248
|
loop that misbehaves cannot be read back, only guessed at.
|
|
207
249
|
|
|
208
250
|
`--transcript-dir` writes one Markdown file per turn: the invocation, the prompt
|
|
209
|
-
as sent, stdout, stderr,
|
|
210
|
-
|
|
211
|
-
|
|
251
|
+
as sent, stdout, stderr, how long it took, and — when the agent wrapper passed
|
|
252
|
+
them — `skills` and `usage` (`unknown`, `(none)`, or the values). Files are
|
|
253
|
+
filed under the Feature the Task belongs to, named for the Subtask and the
|
|
254
|
+
Attempt. It belongs on the **agent** command, after `--`.
|
|
212
255
|
|
|
213
256
|
```json
|
|
214
257
|
"builder": {
|
|
@@ -345,10 +388,10 @@ on the role; the vendor is named after `--`, the same way a Builder names it.
|
|
|
345
388
|
}
|
|
346
389
|
```
|
|
347
390
|
|
|
348
|
-
| Option
|
|
349
|
-
|
|
|
350
|
-
| `--read PATH`
|
|
351
|
-
| `--prompt-file PATH`
|
|
391
|
+
| Option | Meaning |
|
|
392
|
+
| -------------------- | ------------------------------------------------------------- |
|
|
393
|
+
| `--read PATH` | The project the agent may read before splitting. Default: cwd |
|
|
394
|
+
| `--prompt-file PATH` | Prompt template. Must place `{{intention}}` and `{{out}}` |
|
|
352
395
|
|
|
353
396
|
`--bin`, `--model`, `--transcript-dir` and `--agent-arg` are the agent's, after
|
|
354
397
|
`--`. Optional placeholder: `{{max_units}}`.
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extract mason AgentExtras from a Claude Code CLI stdout blob.
|
|
3
|
+
*
|
|
4
|
+
* Skills: stream-json assistant messages whose content has a `tool_use` named
|
|
5
|
+
* `Skill` (Claude tools reference). The skill id is `input.skill` as emitted
|
|
6
|
+
* by Claude Code (e.g. `planner:thin-slice`). Ignore `system/init.skills`
|
|
7
|
+
* (available catalogue, not used).
|
|
8
|
+
*
|
|
9
|
+
* Usage: terminal `result` documents `usage` (`input_tokens`, …) and
|
|
10
|
+
* `total_cost_usd`.
|
|
11
|
+
*
|
|
12
|
+
* `skills` is `null` when stdout is not NDJSON we can parse; `[]` when parsed
|
|
13
|
+
* with no Skill tool_use.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { readResult } from "@bluewombat/slot-kit";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param {string} stdout
|
|
20
|
+
* @param {string} outputFormat
|
|
21
|
+
* @returns {{ skills: string[] | null, usage: import("@bluewombat/slot-kit").AgentUsage | null }}
|
|
22
|
+
*/
|
|
23
|
+
export function extrasFromClaude(stdout, outputFormat) {
|
|
24
|
+
return {
|
|
25
|
+
skills: outputFormat === "stream-json" ? skillsFromClaude(stdout) : null,
|
|
26
|
+
usage: usageFromClaude(stdout),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @param {string} stdout
|
|
32
|
+
* @returns {string[] | null}
|
|
33
|
+
*/
|
|
34
|
+
export function skillsFromClaude(stdout) {
|
|
35
|
+
const lines = stdout.split("\n").filter((line) => line.trim().length > 0);
|
|
36
|
+
if (lines.length === 0) {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
/** @type {string[]} */
|
|
40
|
+
const skills = [];
|
|
41
|
+
/** @type {Set<string>} */
|
|
42
|
+
const seen = new Set();
|
|
43
|
+
let parsedAny = false;
|
|
44
|
+
for (const line of lines) {
|
|
45
|
+
let event;
|
|
46
|
+
try {
|
|
47
|
+
event = JSON.parse(line);
|
|
48
|
+
} catch {
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (event === null || typeof event !== "object" || Array.isArray(event)) {
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
parsedAny = true;
|
|
55
|
+
if (event.type !== "assistant") {
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
const content = event.message?.content;
|
|
59
|
+
if (!Array.isArray(content)) {
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
for (const block of content) {
|
|
63
|
+
if (block === null || typeof block !== "object" || Array.isArray(block)) {
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (block.type !== "tool_use" || block.name !== "Skill") {
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
const input = block.input;
|
|
70
|
+
if (input === null || typeof input !== "object" || Array.isArray(input)) {
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
const name = /** @type {Record<string, unknown>} */ (input).skill;
|
|
74
|
+
if (typeof name !== "string" || name.length === 0 || seen.has(name)) {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
seen.add(name);
|
|
78
|
+
skills.push(name);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return parsedAny ? skills : null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* @param {string} stdout
|
|
86
|
+
* @returns {import("@bluewombat/slot-kit").AgentUsage | null}
|
|
87
|
+
*/
|
|
88
|
+
export function usageFromClaude(stdout) {
|
|
89
|
+
const result = readResult(stdout);
|
|
90
|
+
if (result === undefined) {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
const usage = result.usage;
|
|
94
|
+
if (usage === null || typeof usage !== "object" || Array.isArray(usage)) {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
const record = /** @type {Record<string, unknown>} */ (usage);
|
|
98
|
+
if (
|
|
99
|
+
typeof record.input_tokens !== "number" ||
|
|
100
|
+
typeof record.output_tokens !== "number" ||
|
|
101
|
+
typeof record.cache_creation_input_tokens !== "number" ||
|
|
102
|
+
typeof record.cache_read_input_tokens !== "number"
|
|
103
|
+
) {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
input: record.input_tokens,
|
|
108
|
+
output: record.output_tokens,
|
|
109
|
+
cacheRead: record.cache_read_input_tokens,
|
|
110
|
+
cacheWrite: record.cache_creation_input_tokens,
|
|
111
|
+
costUsd: typeof result.total_cost_usd === "number" ? result.total_cost_usd : null,
|
|
112
|
+
};
|
|
113
|
+
}
|
package/agents/claude.mjs
CHANGED
|
@@ -11,7 +11,9 @@ import {
|
|
|
11
11
|
serializeRun,
|
|
12
12
|
textOption,
|
|
13
13
|
transcriptFor,
|
|
14
|
+
writeContract,
|
|
14
15
|
} from "@bluewombat/slot-kit";
|
|
16
|
+
import { extrasFromClaude } from "./claude-extras.mjs";
|
|
15
17
|
|
|
16
18
|
// Claude Code as a vendor agent. The prompt is already filled: this command
|
|
17
19
|
// does not interpolate. Own options only — a role slot strips Implementer's
|
|
@@ -19,7 +21,7 @@ import {
|
|
|
19
21
|
// --prompt TEXT / --prompt-file PATH the filled prompt. One of the two
|
|
20
22
|
// --bin PATH the Claude CLI. Default: claude, found on PATH
|
|
21
23
|
// --model NAME alias (opus, sonnet) or full name
|
|
22
|
-
// --output-format FMT json (default),
|
|
24
|
+
// --output-format FMT stream-json (default), json, or text
|
|
23
25
|
// --permission-mode MODE bypassPermissions (default), acceptEdits, …
|
|
24
26
|
// --transcript-dir PATH write one file per turn. Off by default
|
|
25
27
|
// --transcript-part NAME prompt, stdout, stderr, timing. Repeatable
|
|
@@ -59,12 +61,16 @@ if (bin === undefined) {
|
|
|
59
61
|
}
|
|
60
62
|
|
|
61
63
|
const model = textOption(options, "model");
|
|
64
|
+
/** @type {string} */
|
|
65
|
+
const outputFormat = /** @type {string} */ (options.outputFormat);
|
|
62
66
|
const args = [
|
|
63
67
|
"-p",
|
|
64
68
|
"--permission-mode",
|
|
65
69
|
/** @type {string} */ (options.permissionMode),
|
|
66
70
|
"--output-format",
|
|
67
|
-
|
|
71
|
+
outputFormat,
|
|
72
|
+
// Claude's stream-json only emits tool events with --verbose (headless docs).
|
|
73
|
+
...(outputFormat === "stream-json" ? ["--verbose"] : []),
|
|
68
74
|
...(model === undefined ? [] : ["--model", model]),
|
|
69
75
|
...listOption(options, "agentArg"),
|
|
70
76
|
prompt,
|
|
@@ -77,21 +83,27 @@ const transcript = transcriptFor(process.argv, options, {
|
|
|
77
83
|
prompt,
|
|
78
84
|
});
|
|
79
85
|
const run = await runAgent({ file: bin, args, cwd });
|
|
80
|
-
|
|
81
|
-
|
|
86
|
+
const extras = extrasFromClaude(run.stdout, outputFormat);
|
|
87
|
+
transcript.write(run, extras);
|
|
88
|
+
answer(run, bin, extras);
|
|
82
89
|
|
|
83
90
|
/** @param {string} reason @returns {never} */
|
|
84
91
|
function refuse(reason) {
|
|
85
|
-
|
|
92
|
+
writeContract(`${JSON.stringify({ error: reason })}\n`);
|
|
86
93
|
process.exit(1);
|
|
87
94
|
}
|
|
88
95
|
|
|
89
96
|
/**
|
|
90
97
|
* @param {import("@bluewombat/slot-kit").AgentRun} run
|
|
91
98
|
* @param {string} binPath
|
|
99
|
+
* @param {import("@bluewombat/slot-kit").AgentExtras} extras
|
|
92
100
|
* @returns {never}
|
|
93
101
|
*/
|
|
94
|
-
function answer(run, binPath) {
|
|
95
|
-
|
|
102
|
+
function answer(run, binPath, extras) {
|
|
103
|
+
writeContract(
|
|
104
|
+
`${JSON.stringify(
|
|
105
|
+
serializeRun(run, { name: NAME, bin: binPath, skills: extras.skills, usage: extras.usage }),
|
|
106
|
+
)}\n`,
|
|
107
|
+
);
|
|
96
108
|
process.exit(0);
|
|
97
109
|
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extract mason AgentExtras from a Cursor CLI stdout blob.
|
|
3
|
+
*
|
|
4
|
+
* Skills: documented stream-json `tool_call` / `readToolCall` with a path that
|
|
5
|
+
* ends in `/skills/<name>/SKILL.md` (Cursor skills are SKILL.md files the
|
|
6
|
+
* agent reads). Count each `call_id` once, on `started`.
|
|
7
|
+
*
|
|
8
|
+
* Usage: last JSON object (the terminal `result`) may carry a `usage` object
|
|
9
|
+
* with camelCase token fields (observed; not in the published json schema).
|
|
10
|
+
* No cost field is documented for Cursor — `costUsd` stays null.
|
|
11
|
+
*
|
|
12
|
+
* `skills` is `null` when the stdout is not NDJSON we can parse (unknown),
|
|
13
|
+
* `[]` when we parsed and found no skill reads.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { readResult } from "@bluewombat/slot-kit";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param {string} stdout
|
|
20
|
+
* @param {string} outputFormat
|
|
21
|
+
* @returns {{ skills: string[] | null, usage: import("@bluewombat/slot-kit").AgentUsage | null }}
|
|
22
|
+
*/
|
|
23
|
+
export function extrasFromCursor(stdout, outputFormat) {
|
|
24
|
+
return {
|
|
25
|
+
skills: outputFormat === "stream-json" ? skillsFromCursor(stdout) : null,
|
|
26
|
+
usage: usageFromCursor(stdout),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @param {string} stdout
|
|
32
|
+
* @returns {string[] | null}
|
|
33
|
+
*/
|
|
34
|
+
export function skillsFromCursor(stdout) {
|
|
35
|
+
const lines = stdout.split("\n").filter((line) => line.trim().length > 0);
|
|
36
|
+
if (lines.length === 0) {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
/** @type {string[]} */
|
|
40
|
+
const skills = [];
|
|
41
|
+
/** @type {Set<string>} */
|
|
42
|
+
const seenCalls = new Set();
|
|
43
|
+
/** @type {Set<string>} */
|
|
44
|
+
const seenNames = new Set();
|
|
45
|
+
let parsedAny = false;
|
|
46
|
+
for (const line of lines) {
|
|
47
|
+
let event;
|
|
48
|
+
try {
|
|
49
|
+
event = JSON.parse(line);
|
|
50
|
+
} catch {
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (event === null || typeof event !== "object" || Array.isArray(event)) {
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
parsedAny = true;
|
|
57
|
+
if (event.type !== "tool_call" || event.subtype !== "started") {
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
const callId = typeof event.call_id === "string" ? event.call_id : "";
|
|
61
|
+
if (callId.length > 0 && seenCalls.has(callId)) {
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
const path = readToolPath(event.tool_call);
|
|
65
|
+
const name = skillNameFromPath(path);
|
|
66
|
+
if (name === undefined) {
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (callId.length > 0) {
|
|
70
|
+
seenCalls.add(callId);
|
|
71
|
+
}
|
|
72
|
+
if (seenNames.has(name)) {
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
seenNames.add(name);
|
|
76
|
+
skills.push(name);
|
|
77
|
+
}
|
|
78
|
+
return parsedAny ? skills : null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* @param {unknown} toolCall
|
|
83
|
+
* @returns {string | undefined}
|
|
84
|
+
*/
|
|
85
|
+
function readToolPath(toolCall) {
|
|
86
|
+
if (toolCall === null || typeof toolCall !== "object" || Array.isArray(toolCall)) {
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
const read = /** @type {Record<string, unknown>} */ (toolCall).readToolCall;
|
|
90
|
+
if (read === null || typeof read !== "object" || Array.isArray(read)) {
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
const args = /** @type {Record<string, unknown>} */ (read).args;
|
|
94
|
+
if (args === null || typeof args !== "object" || Array.isArray(args)) {
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
const path = /** @type {Record<string, unknown>} */ (args).path;
|
|
98
|
+
return typeof path === "string" ? path : undefined;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* @param {string | undefined} path
|
|
103
|
+
* @returns {string | undefined}
|
|
104
|
+
*/
|
|
105
|
+
export function skillNameFromPath(path) {
|
|
106
|
+
if (path === undefined) {
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
const match = path.match(/(?:^|[/\\])skills[/\\]([^/\\]+)[/\\]SKILL\.md$/i);
|
|
110
|
+
return match?.[1];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* @param {string} stdout
|
|
115
|
+
* @returns {import("@bluewombat/slot-kit").AgentUsage | null}
|
|
116
|
+
*/
|
|
117
|
+
export function usageFromCursor(stdout) {
|
|
118
|
+
const result = readResult(stdout);
|
|
119
|
+
if (result === undefined) {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
const usage = result.usage;
|
|
123
|
+
if (usage === null || typeof usage !== "object" || Array.isArray(usage)) {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
const record = /** @type {Record<string, unknown>} */ (usage);
|
|
127
|
+
if (
|
|
128
|
+
typeof record.inputTokens !== "number" ||
|
|
129
|
+
typeof record.outputTokens !== "number" ||
|
|
130
|
+
typeof record.cacheReadTokens !== "number" ||
|
|
131
|
+
typeof record.cacheWriteTokens !== "number"
|
|
132
|
+
) {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
return {
|
|
136
|
+
input: record.inputTokens,
|
|
137
|
+
output: record.outputTokens,
|
|
138
|
+
cacheRead: record.cacheReadTokens,
|
|
139
|
+
cacheWrite: record.cacheWriteTokens,
|
|
140
|
+
costUsd: null,
|
|
141
|
+
};
|
|
142
|
+
}
|
package/agents/cursor.mjs
CHANGED
|
@@ -11,7 +11,9 @@ import {
|
|
|
11
11
|
serializeRun,
|
|
12
12
|
textOption,
|
|
13
13
|
transcriptFor,
|
|
14
|
+
writeContract,
|
|
14
15
|
} from "@bluewombat/slot-kit";
|
|
16
|
+
import { extrasFromCursor } from "./cursor-extras.mjs";
|
|
15
17
|
|
|
16
18
|
// Cursor CLI as a vendor agent. The prompt is already filled: this command
|
|
17
19
|
// does not interpolate. Own options only — a role slot strips Implementer's
|
|
@@ -19,7 +21,7 @@ import {
|
|
|
19
21
|
// --prompt TEXT / --prompt-file PATH the filled prompt. One of the two
|
|
20
22
|
// --bin PATH the Cursor CLI. Default: cursor-agent, found
|
|
21
23
|
// --model NAME
|
|
22
|
-
// --output-format FMT json (default),
|
|
24
|
+
// --output-format FMT stream-json (default), json, or text
|
|
23
25
|
// --transcript-dir PATH write one file per turn. Off by default
|
|
24
26
|
// --transcript-part NAME prompt, stdout, stderr, timing. Repeatable
|
|
25
27
|
// --agent-arg VALUE appended before the prompt. Repeatable
|
|
@@ -51,6 +53,8 @@ if (bin === undefined) {
|
|
|
51
53
|
}
|
|
52
54
|
|
|
53
55
|
const model = textOption(options, "model");
|
|
56
|
+
/** @type {string} */
|
|
57
|
+
const outputFormat = /** @type {string} */ (options.outputFormat);
|
|
54
58
|
const args = [
|
|
55
59
|
"-p",
|
|
56
60
|
"--force",
|
|
@@ -60,7 +64,7 @@ const args = [
|
|
|
60
64
|
"--sandbox",
|
|
61
65
|
"disabled",
|
|
62
66
|
"--output-format",
|
|
63
|
-
|
|
67
|
+
outputFormat,
|
|
64
68
|
...(model === undefined ? [] : ["--model", model]),
|
|
65
69
|
...listOption(options, "agentArg"),
|
|
66
70
|
prompt,
|
|
@@ -73,21 +77,27 @@ const transcript = transcriptFor(process.argv, options, {
|
|
|
73
77
|
prompt,
|
|
74
78
|
});
|
|
75
79
|
const run = await runAgent({ file: bin, args, cwd });
|
|
76
|
-
|
|
77
|
-
|
|
80
|
+
const extras = extrasFromCursor(run.stdout, outputFormat);
|
|
81
|
+
transcript.write(run, extras);
|
|
82
|
+
answer(run, bin, extras);
|
|
78
83
|
|
|
79
84
|
/** @param {string} reason @returns {never} */
|
|
80
85
|
function refuse(reason) {
|
|
81
|
-
|
|
86
|
+
writeContract(`${JSON.stringify({ error: reason })}\n`);
|
|
82
87
|
process.exit(1);
|
|
83
88
|
}
|
|
84
89
|
|
|
85
90
|
/**
|
|
86
91
|
* @param {import("@bluewombat/slot-kit").AgentRun} run
|
|
87
92
|
* @param {string} binPath
|
|
93
|
+
* @param {import("@bluewombat/slot-kit").AgentExtras} extras
|
|
88
94
|
* @returns {never}
|
|
89
95
|
*/
|
|
90
|
-
function answer(run, binPath) {
|
|
91
|
-
|
|
96
|
+
function answer(run, binPath, extras) {
|
|
97
|
+
writeContract(
|
|
98
|
+
`${JSON.stringify(
|
|
99
|
+
serializeRun(run, { name: NAME, bin: binPath, skills: extras.skills, usage: extras.usage }),
|
|
100
|
+
)}\n`,
|
|
101
|
+
);
|
|
92
102
|
process.exit(0);
|
|
93
103
|
}
|
|
@@ -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
|
+
}
|
package/builders/producer.mjs
CHANGED
|
@@ -24,6 +24,10 @@ Task:
|
|
|
24
24
|
|
|
25
25
|
{{done_when}}
|
|
26
26
|
|
|
27
|
+
This checkout already includes any earlier subtasks that were folded into the
|
|
28
|
+
feature. Do not rewrite that work. Add or change only what this task names, and
|
|
29
|
+
leave the project's own checks green on the whole tree.
|
|
30
|
+
|
|
27
31
|
Rules of this run:
|
|
28
32
|
{{rules}}
|
|
29
33
|
`;
|
|
@@ -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";
|
|
@@ -36,7 +37,11 @@ How the subtasks will be run, which is what makes a good split:
|
|
|
36
37
|
|
|
37
38
|
- One at a time, never in parallel. Each runs in its own copy of this project,
|
|
38
39
|
and its result is folded back before the next one starts. So a later subtask
|
|
39
|
-
sees the earlier ones' work.
|
|
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.
|
|
40
45
|
- Every subtask is checked by the project's own checks — its tests, its lint —
|
|
41
46
|
on its own. **A subtask must leave the project working.** This is the rule that
|
|
42
47
|
decides your split: never cut by layer. "Add the types", then "add the code",
|
|
@@ -58,9 +63,11 @@ How the subtasks will be run, which is what makes a good split:
|
|
|
58
63
|
mind, and preferably by a test. It describes this subtask's observable result,
|
|
59
64
|
not the whole feature and not an intention.
|
|
60
65
|
- \`depends_on\` means "cannot start before this one is done", and nothing else.
|
|
61
|
-
|
|
62
|
-
subtask
|
|
63
|
-
|
|
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.
|
|
64
71
|
|
|
65
72
|
You are choosing between two costs, and both are real.
|
|
66
73
|
|
|
@@ -221,6 +228,6 @@ function tail(output) {
|
|
|
221
228
|
|
|
222
229
|
/** @param {string} reason @returns {never} */
|
|
223
230
|
function fail(reason) {
|
|
224
|
-
|
|
231
|
+
writeDiagnostic(`${reason}\n`);
|
|
225
232
|
process.exit(1);
|
|
226
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) {
|