@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.
@@ -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 feature's commit in the Conventional Commits shape, from the issue's
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 feature's commit message — and the pull request's description with it
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 feature. A role slot like the builders: it fills a template
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.8.0",
3
+ "version": "0.10.0",
4
4
  "type": "module",
5
- "description": "Shipped mason slots: Planners, Builders, Gates, publishers, and refreshers a Project names in its config.",
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
- "./assembly/*": "./assembly/*",
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
- "assembly",
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.8.0",
57
- "@pinkpuma/deck": "1.0.0"
55
+ "@bluewombat/slot-kit": "^0.10.0",
56
+ "@pinkpuma/deck": "1.1.0"
58
57
  }
59
58
  }
@@ -11,7 +11,7 @@ import {
11
11
  writeDiagnostic,
12
12
  } from "@bluewombat/slot-kit";
13
13
 
14
- // Put the feature's work where the Authority can read it, by pushing the name
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
@@ -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
- const fetched = git(["fetch", "origin", target]);
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 merged = git(["merge", "--ff-only", `origin/${target}`]);
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 = "assembly fix";
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 feature. The units of work that made it have
23
- already been validated. An earlier judgement of the whole refused it. Do not
24
- redo those units. Fix what the refusal names.
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 assembly fix command needs --report.");
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 = "assembly validate";
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 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.
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 feature meets what was asked.
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
- "assembly.validate is a read-only review; it does not take --report — that is assembly.fix's job.",
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
- `assembly.validate found no MASON_VERDICT: line in the answer: ${
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
- `assembly.validate's MASON_VERDICT: line was neither VALIDATED nor REFUSED: ${verdict.slice(0, 500)}`,
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
  }
@@ -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
- ]);
@@ -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
- }