@welluable/orch 1.0.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,66 @@
1
+ import { Agent } from './agent.js';
2
+ import { normalizeClaudeToolEvent } from './tool-status.js';
3
+
4
+ export class AgentClaude extends Agent {
5
+ getSpawnConfig(promptToSend) {
6
+ const args = this.readOnly
7
+ ? [
8
+ '-p',
9
+ '--output-format',
10
+ 'stream-json',
11
+ '--verbose',
12
+ '--permission-mode',
13
+ 'plan',
14
+ promptToSend,
15
+ ]
16
+ : [
17
+ '-p',
18
+ '--output-format',
19
+ 'stream-json',
20
+ '--verbose',
21
+ '--dangerously-skip-permissions',
22
+ promptToSend,
23
+ ];
24
+ return {
25
+ command: 'claude',
26
+ args,
27
+ options: {
28
+ cwd: this.cwd,
29
+ stdio: ['ignore', 'pipe', 'pipe'],
30
+ env: process.env,
31
+ },
32
+ };
33
+ }
34
+
35
+ handleStreamEvent(event, { finish }) {
36
+ switch (event.type) {
37
+ case 'system': {
38
+ if (event.subtype === 'init') {
39
+ this.setStatus('connected');
40
+ }
41
+ break;
42
+ }
43
+ case 'assistant': {
44
+ const toolEvents = normalizeClaudeToolEvent(event);
45
+ if (toolEvents.length > 0) {
46
+ toolEvents.forEach((toolEvent) => this.onToolEvent(toolEvent));
47
+ } else if (this.activeTools.size === 0) {
48
+ this.setStatus('composing response…');
49
+ }
50
+ break;
51
+ }
52
+ case 'user': {
53
+ const toolEvents = normalizeClaudeToolEvent(event);
54
+ toolEvents.forEach((toolEvent) => this.onToolEvent(toolEvent));
55
+ break;
56
+ }
57
+ case 'result': {
58
+ this.settleResult(event, finish);
59
+ break;
60
+ }
61
+ // Ignore rate_limit_event and other non-UI events for v1
62
+ default:
63
+ break;
64
+ }
65
+ }
66
+ }
@@ -0,0 +1,59 @@
1
+ import { Agent } from './agent.js';
2
+ import { normalizeCursorToolEvent } from './tool-status.js';
3
+
4
+ export class AgentCursor extends Agent {
5
+ getSpawnConfig(promptToSend) {
6
+ const args = this.readOnly
7
+ ? ['-p', '--mode', 'ask', '--output-format', 'stream-json', promptToSend]
8
+ : ['-p', '--force', '--output-format', 'stream-json', promptToSend];
9
+ return {
10
+ command: 'agent',
11
+ args,
12
+ options: {
13
+ cwd: this.cwd,
14
+ stdio: ['ignore', 'pipe', 'pipe'],
15
+ env: process.env,
16
+ },
17
+ };
18
+ }
19
+
20
+ handleStreamEvent(event, { verbose, finish }) {
21
+ switch (event.type) {
22
+ case 'system': {
23
+ this.setStatus('connected');
24
+ break;
25
+ }
26
+ case 'thinking': {
27
+ switch (event.subtype) {
28
+ case 'delta': {
29
+ if (verbose) {
30
+ process.stderr.write(event.text ?? '');
31
+ } else if (this.activeTools.size === 0) {
32
+ this.setStatus('thinking…');
33
+ }
34
+ break;
35
+ }
36
+ case 'completed': {
37
+ break;
38
+ }
39
+ }
40
+ break;
41
+ }
42
+ case 'tool_call': {
43
+ const toolEvent = normalizeCursorToolEvent(event);
44
+ if (toolEvent) this.onToolEvent(toolEvent);
45
+ break;
46
+ }
47
+ case 'assistant': {
48
+ if (this.activeTools.size === 0) {
49
+ this.setStatus('composing response…');
50
+ }
51
+ break;
52
+ }
53
+ case 'result': {
54
+ this.settleResult(event, finish);
55
+ break;
56
+ }
57
+ }
58
+ }
59
+ }
package/lib/agent.js ADDED
@@ -0,0 +1,326 @@
1
+ import { spawn } from 'child_process';
2
+ import path from 'path';
3
+ import ora from 'ora';
4
+ import { formatActiveTools } from './tool-status.js';
5
+
6
+ /**
7
+ * Every child process spawned by an agent, tracked so we can reap them all
8
+ * when the orchestrator is interrupted (Ctrl+C) or otherwise exits.
9
+ */
10
+ const liveChildren = new Set();
11
+
12
+ /**
13
+ * Terminate a child and everything it spawned. Children are launched
14
+ * `detached`, so each is its own process-group leader and `-pid` targets the
15
+ * whole group (agent CLI + any grandchildren). Falls back to killing just the
16
+ * child if the group is already gone.
17
+ */
18
+ function killChildGroup(child, signal) {
19
+ try {
20
+ process.kill(-child.pid, signal);
21
+ } catch {
22
+ try {
23
+ child.kill(signal);
24
+ } catch {
25
+ // already dead
26
+ }
27
+ }
28
+ }
29
+
30
+ let shuttingDown = false;
31
+
32
+ /** Conventional shell status: 128 + signal number. */
33
+ export function exitCodeForSignal(signal) {
34
+ if (signal === 'SIGINT') return 130;
35
+ if (signal === 'SIGHUP') return 129;
36
+ return 143; // SIGTERM (and any other mapped interrupt)
37
+ }
38
+
39
+ /**
40
+ * Reap every tracked child group, then exit. `exit` is injectable so tests can
41
+ * assert without tearing down the runner.
42
+ */
43
+ export function shutdown(signal, { exit = (code) => process.exit(code) } = {}) {
44
+ if (shuttingDown) return;
45
+ shuttingDown = true;
46
+
47
+ for (const child of liveChildren) killChildGroup(child, 'SIGTERM');
48
+
49
+ const exitCode = exitCodeForSignal(signal);
50
+
51
+ let poll;
52
+ // …or force-kill any stragglers after a short grace period.
53
+ const forceKill = setTimeout(() => {
54
+ clearInterval(poll);
55
+ for (const child of liveChildren) killChildGroup(child, 'SIGKILL');
56
+ exit(exitCode);
57
+ }, 2000);
58
+ forceKill.unref();
59
+
60
+ // Exit as soon as every child is gone…
61
+ poll = setInterval(() => {
62
+ if (liveChildren.size === 0) {
63
+ clearInterval(poll);
64
+ clearTimeout(forceKill);
65
+ exit(exitCode);
66
+ }
67
+ }, 50);
68
+ }
69
+
70
+ /** Track a child the way Agent.run does — for interrupt/reap tests. */
71
+ export function trackLiveChild(child) {
72
+ liveChildren.add(child);
73
+ child.once('close', () => liveChildren.delete(child));
74
+ child.once('error', () => liveChildren.delete(child));
75
+ }
76
+
77
+ /** Reset shutdown latch + child set between tests. */
78
+ export function resetShutdownState() {
79
+ shuttingDown = false;
80
+ liveChildren.clear();
81
+ }
82
+
83
+ /**
84
+ * Tracks whether the `model:` banner line has been printed yet. A shared,
85
+ * mutable object (rather than a plain flag) so tests can reset it between cases.
86
+ */
87
+ export const modelPrintState = { printed: false };
88
+
89
+ /**
90
+ * If `event` is the first `system`/`init` stream event carrying a `model`,
91
+ * print an aligned `model: <event.model>` line (pausing/resuming `spinner` so
92
+ * ora doesn't garble it) and latch `modelPrintState.printed`. No-op otherwise,
93
+ * including on every event after the first one printed.
94
+ */
95
+ export function maybePrintModelLine(event, spinner) {
96
+ if (
97
+ modelPrintState.printed ||
98
+ event.type !== 'system' ||
99
+ event.subtype !== 'init' ||
100
+ typeof event.model !== 'string' ||
101
+ !event.model
102
+ ) {
103
+ return;
104
+ }
105
+ modelPrintState.printed = true;
106
+ const wasSpinning = spinner?.isSpinning;
107
+ if (wasSpinning) spinner.stop();
108
+ console.log(`model: ${event.model}`);
109
+ if (wasSpinning) spinner.start();
110
+ }
111
+
112
+ let handlersRegistered = false;
113
+
114
+ function registerShutdownHandlers() {
115
+ if (handlersRegistered) return;
116
+ handlersRegistered = true;
117
+
118
+ process.once('SIGINT', () => shutdown('SIGINT'));
119
+ process.once('SIGTERM', () => shutdown('SIGTERM'));
120
+ process.once('SIGHUP', () => shutdown('SIGHUP'));
121
+ // Last-resort reap on any exit path we didn't handle explicitly.
122
+ process.on('exit', () => {
123
+ for (const child of liveChildren) killChildGroup(child, 'SIGKILL');
124
+ });
125
+ }
126
+
127
+ /** Format milliseconds as `{s}s` or `{m}m {s}s` (whole seconds, no leading 0m). */
128
+ export function formatElapsed(ms) {
129
+ const totalSec = Math.floor(ms / 1000);
130
+ const m = Math.floor(totalSec / 60);
131
+ const s = totalSec % 60;
132
+ return m > 0 ? `${m}m ${s}s` : `${s}s`;
133
+ }
134
+
135
+ export class Agent {
136
+ constructor(name, instructions, prompt, { cwd, readOnly = false } = {}) {
137
+ this.name = name;
138
+ this.instructions = instructions;
139
+ this.prompt = prompt;
140
+ this.cwd = cwd ? path.resolve(cwd) : process.cwd();
141
+ this.readOnly = Boolean(readOnly);
142
+ this.process = null;
143
+ this.spinner = null;
144
+ this.startedAt = 0;
145
+ this.statusText = '';
146
+ this.elapsedTimer = null;
147
+ /** @type {Map<string, { name: string, args: Record<string, unknown> }>} */
148
+ this.activeTools = new Map();
149
+ }
150
+
151
+ setStatus(text) {
152
+ this.statusText = text;
153
+ this.refreshSpinnerText();
154
+ }
155
+
156
+ /** @param {{ name: string, args: Record<string, unknown>, phase: 'started'|'completed', callId: string }} toolEvent */
157
+ onToolEvent({ name, args, phase, callId }) {
158
+ if (phase === 'started') {
159
+ this.activeTools.set(callId, { name, args });
160
+ } else if (phase === 'completed') {
161
+ this.activeTools.delete(callId);
162
+ }
163
+ this.refreshToolStatus();
164
+ }
165
+
166
+ refreshToolStatus() {
167
+ if (this.activeTools.size === 0) {
168
+ this.setStatus('working…');
169
+ return;
170
+ }
171
+ this.setStatus(formatActiveTools(this.activeTools));
172
+ }
173
+
174
+ refreshSpinnerText() {
175
+ if (!this.spinner) return;
176
+ const elapsed = formatElapsed(Date.now() - this.startedAt);
177
+ this.spinner.text = `[${this.name}] ${this.statusText} · ${elapsed}`;
178
+ }
179
+
180
+ startElapsedTimer() {
181
+ this.stopElapsedTimer();
182
+ if (!process.stdout.isTTY) return;
183
+ this.elapsedTimer = setInterval(() => this.refreshSpinnerText(), 1000);
184
+ }
185
+
186
+ stopElapsedTimer() {
187
+ if (this.elapsedTimer != null) {
188
+ clearInterval(this.elapsedTimer);
189
+ this.elapsedTimer = null;
190
+ }
191
+ }
192
+
193
+ /** @returns {{ command: string, args: string[], options: object }} */
194
+ getSpawnConfig(_promptToSend) {
195
+ throw new Error('getSpawnConfig must be implemented by subclass');
196
+ }
197
+
198
+ /**
199
+ * @param {object} event
200
+ * @param {{ verbose: boolean, finish: (err: Error|null, value?: object) => void }} ctx
201
+ */
202
+ handleStreamEvent(_event, _ctx) {
203
+ throw new Error('handleStreamEvent must be implemented by subclass');
204
+ }
205
+
206
+ settleResult(event, finish) {
207
+ this.stopElapsedTimer();
208
+ this.activeTools.clear();
209
+ const ok = !event.is_error;
210
+ const durationMs = event.duration_ms;
211
+ this.ok = ok;
212
+ const elapsed = formatElapsed(Date.now() - this.startedAt);
213
+ const msg = ok
214
+ ? `[${this.name}] done in ${elapsed}`
215
+ : `[${this.name}] failed in ${elapsed}`;
216
+ if (ok) this.spinner.succeed(msg);
217
+ else this.spinner.fail(msg);
218
+ finish(null, { ok, result: event.result, durationMs });
219
+ }
220
+
221
+ async run({ verbose = false } = {}) {
222
+ const promptToSend = `
223
+ [SYSTEM INSTRUCTIONS]
224
+ ${this.instructions}
225
+ [/SYSTEM INSTRUCTIONS]
226
+
227
+ [USER PROMPT]
228
+ ${this.prompt}
229
+ [/USER PROMPT]
230
+ `;
231
+
232
+ registerShutdownHandlers();
233
+
234
+ const { command, args, options } = this.getSpawnConfig(promptToSend);
235
+ // `detached` makes the child its own process-group leader so we can kill
236
+ // it and any grandchildren it spawns as a group on shutdown.
237
+ this.process = spawn(command, args, { ...options, detached: true });
238
+ const child = this.process;
239
+ liveChildren.add(child);
240
+
241
+ this.startedAt = Date.now();
242
+ this.statusText = 'starting…';
243
+ // discardStdin: false keeps stdin cooked so Ctrl+C delivers a real SIGINT
244
+ // (ora's default raw-mode discarder swallows it in some IDEs / PTYs).
245
+ this.spinner = ora({
246
+ text: `[${this.name}] starting…`,
247
+ isEnabled: process.stdout.isTTY,
248
+ discardStdin: false,
249
+ }).start();
250
+ this.refreshSpinnerText();
251
+ this.startElapsedTimer();
252
+
253
+ let buf = '';
254
+ let settled = false;
255
+
256
+ return new Promise((resolve, reject) => {
257
+ const finish = (err, value) => {
258
+ if (settled) return;
259
+ settled = true;
260
+ if (err) reject(err);
261
+ else resolve(value);
262
+ };
263
+
264
+ this.process.stdout.on('data', (chunk) => {
265
+ buf += chunk;
266
+ const lines = buf.split('\n');
267
+ buf = lines.pop();
268
+
269
+ lines.forEach((line) => {
270
+ if (!line.trim()) return;
271
+
272
+ let event;
273
+ try {
274
+ event = JSON.parse(line);
275
+ } catch {
276
+ return;
277
+ }
278
+
279
+ if (process.env.ORCH_DEBUG) {
280
+ process.stderr.write(JSON.stringify(event) + '\n');
281
+ }
282
+
283
+ maybePrintModelLine(event, this.spinner);
284
+
285
+ this.handleStreamEvent(event, { verbose, finish });
286
+ });
287
+ });
288
+
289
+ this.process.on('error', (err) => {
290
+ liveChildren.delete(child);
291
+ this.process = null;
292
+ this.stopElapsedTimer();
293
+ if (this.spinner?.isSpinning) {
294
+ this.spinner.fail(`[${this.name}] failed`);
295
+ }
296
+ finish(err);
297
+ });
298
+
299
+ this.process.on('close', (code) => {
300
+ liveChildren.delete(child);
301
+ this.process = null;
302
+ if (!settled) {
303
+ this.stopElapsedTimer();
304
+ if (this.spinner?.isSpinning) {
305
+ this.spinner.fail(`[${this.name}] exited ${code}`);
306
+ }
307
+ finish(new Error(`[${this.name}] exited ${code} before result`));
308
+ }
309
+ });
310
+ });
311
+ }
312
+
313
+ async stop() {
314
+ if (!this.process) return;
315
+ const proc = this.process;
316
+ killChildGroup(proc, 'SIGTERM');
317
+ await new Promise((resolve) => proc.once('close', resolve));
318
+ liveChildren.delete(proc);
319
+ this.process = null;
320
+ }
321
+
322
+ async restart() {
323
+ this.stop();
324
+ this.run();
325
+ }
326
+ }
package/lib/commit.js ADDED
@@ -0,0 +1,35 @@
1
+ import { execFileSync as nodeExecFileSync } from 'node:child_process';
2
+
3
+ function defaultExecFile(command, args, options = {}) {
4
+ return nodeExecFileSync(command, args, { encoding: 'utf8', ...options });
5
+ }
6
+
7
+ function runGit(execFile, args) {
8
+ try {
9
+ return execFile('git', args);
10
+ } catch (err) {
11
+ const detail = err.stderr || err.message;
12
+ throw new Error(`git ${args.join(' ')} failed: ${detail}`);
13
+ }
14
+ }
15
+
16
+ /**
17
+ * Commits all changes in a run's worktree as a single, deterministic commit
18
+ * owned by orch (never the agent). Skips committing when the tree is clean
19
+ * so no empty commit is created. Does not bypass hooks (`--no-verify`) and
20
+ * does not attempt destructive recovery (`reset`/`clean`) on failure;
21
+ * `execFile` is injectable and defaults to a `child_process.execFileSync`
22
+ * wrapper.
23
+ */
24
+ export function commitWorktree({ worktreePath, branch, message, execFile = defaultExecFile }) {
25
+ const status = runGit(execFile, ['-C', worktreePath, 'status', '--porcelain']);
26
+ if (status.trim() === '') {
27
+ return { committed: false, sha: null, branch };
28
+ }
29
+
30
+ runGit(execFile, ['-C', worktreePath, 'add', '-A']);
31
+ runGit(execFile, ['-C', worktreePath, 'commit', '-m', message]);
32
+ const sha = runGit(execFile, ['-C', worktreePath, 'rev-parse', 'HEAD']).trim();
33
+
34
+ return { committed: true, sha, branch };
35
+ }
@@ -0,0 +1,33 @@
1
+ /** Parse triage agent final message as JSON. Returns null on any failure. */
2
+ export function parseTriageJson(result) {
3
+ if (typeof result !== 'string') return null;
4
+
5
+ const trimmed = result.trim();
6
+ if (!trimmed) return null;
7
+
8
+ const tryParse = (text) => {
9
+ try {
10
+ return JSON.parse(text);
11
+ } catch {
12
+ return null;
13
+ }
14
+ };
15
+
16
+ let parsed = tryParse(trimmed);
17
+ if (parsed && typeof parsed === 'object') return parsed;
18
+
19
+ const fenceMatch = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i);
20
+ if (fenceMatch) {
21
+ parsed = tryParse(fenceMatch[1].trim());
22
+ if (parsed && typeof parsed === 'object') return parsed;
23
+ }
24
+
25
+ const start = trimmed.indexOf('{');
26
+ const end = trimmed.lastIndexOf('}');
27
+ if (start !== -1 && end > start) {
28
+ parsed = tryParse(trimmed.slice(start, end + 1));
29
+ if (parsed && typeof parsed === 'object') return parsed;
30
+ }
31
+
32
+ return null;
33
+ }
@@ -0,0 +1,25 @@
1
+ import { parseTriageJson } from './parse-triage-json.js';
2
+
3
+ const UNPARSEABLE = { passed: false, summary: 'unparseable verdict' };
4
+
5
+ /**
6
+ * Parse a critic/runner final message as a pass/fail verdict.
7
+ * Reuses triage JSON extraction; requires `typeof passed === 'boolean'`.
8
+ */
9
+ export function parseVerdict(result) {
10
+ const parsed = parseTriageJson(result);
11
+ if (!parsed || typeof parsed.passed !== 'boolean') {
12
+ return { ...UNPARSEABLE };
13
+ }
14
+
15
+ const verdict = {
16
+ passed: parsed.passed,
17
+ summary: typeof parsed.summary === 'string' ? parsed.summary : '',
18
+ };
19
+
20
+ if (Array.isArray(parsed.failures)) {
21
+ verdict.failures = parsed.failures;
22
+ }
23
+
24
+ return verdict;
25
+ }
@@ -0,0 +1,33 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { generateSlug as defaultGenerateSlug } from './slug.js';
4
+
5
+ /**
6
+ * Creates `<cwd>/.orch/<slug>/` for a new run and returns absolute paths to
7
+ * its artifacts. Retries with a fresh slug a bounded number of times if the
8
+ * generated directory already exists; throws once attempts are exhausted
9
+ * rather than reusing or repairing an existing directory.
10
+ */
11
+ export function createRunContext({ cwd, generateSlug = defaultGenerateSlug, maxAttempts = 5 } = {}) {
12
+ const absCwd = path.resolve(cwd);
13
+ const orchDir = path.join(absCwd, '.orch');
14
+
15
+ for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
16
+ const slug = generateSlug();
17
+ const artifactDir = path.join(orchDir, slug);
18
+
19
+ if (fs.existsSync(artifactDir)) continue;
20
+
21
+ fs.mkdirSync(artifactDir, { recursive: true });
22
+
23
+ return {
24
+ slug,
25
+ artifactDir,
26
+ researchPath: path.join(artifactDir, 'research.md'),
27
+ taskPath: path.join(artifactDir, 'task.md'),
28
+ statusPath: path.join(artifactDir, 'status.md'),
29
+ };
30
+ }
31
+
32
+ throw new Error(`createRunContext: exhausted ${maxAttempts} attempts to allocate a unique run directory under ${orchDir}`);
33
+ }
package/lib/slug.js ADDED
@@ -0,0 +1,47 @@
1
+ import crypto from 'node:crypto';
2
+
3
+ const ADJECTIVES = [
4
+ 'calm', 'bright', 'quiet', 'brave', 'swift', 'gentle', 'bold', 'clever',
5
+ 'eager', 'fuzzy', 'lively', 'merry', 'noble', 'plucky', 'quirky', 'rapid',
6
+ 'sunny', 'tidy', 'vivid', 'wise',
7
+ ];
8
+
9
+ const NOUNS = [
10
+ 'otter', 'pine', 'falcon', 'meadow', 'harbor', 'ember', 'boulder', 'cedar',
11
+ 'delta', 'forest', 'glacier', 'heron', 'island', 'jasper', 'kestrel',
12
+ 'lagoon', 'marsh', 'nectar', 'oasis', 'pebble',
13
+ ];
14
+
15
+ /** Pure formatter: joins the parts with hyphens, no normalization. */
16
+ export function formatSlug(adjective, noun, hex) {
17
+ return `${adjective}-${noun}-${hex}`;
18
+ }
19
+
20
+ /** Default random source: uniform float in [0, 1), backed by node:crypto. */
21
+ function defaultRandom() {
22
+ return crypto.randomBytes(4).readUInt32BE(0) / 0x100000000;
23
+ }
24
+
25
+ function pick(list, random) {
26
+ return list[Math.floor(random() * list.length)];
27
+ }
28
+
29
+ function randomHex(random) {
30
+ let hex = '';
31
+ for (let i = 0; i < 4; i += 1) {
32
+ hex += Math.floor(random() * 16).toString(16);
33
+ }
34
+ return hex;
35
+ }
36
+
37
+ /**
38
+ * Generates an `<adjective>-<noun>-<4-lowercase-hex>` slug. `random` follows
39
+ * the `Math.random()`-style `() => number in [0, 1)` contract and defaults to
40
+ * a node:crypto-backed source.
41
+ */
42
+ export function generateSlug({ random = defaultRandom } = {}) {
43
+ const adjective = pick(ADJECTIVES, random);
44
+ const noun = pick(NOUNS, random);
45
+ const hex = randomHex(random);
46
+ return formatSlug(adjective, noun, hex);
47
+ }
@@ -0,0 +1,34 @@
1
+ const SUMMARY_DELIMITER = '<<<SUMMARY>>>';
2
+
3
+ /**
4
+ * Split a stage's final message into its required content and the optional
5
+ * natural-language summary paragraph appended after the last `<<<SUMMARY>>>`
6
+ * delimiter. Falls back to treating the whole input as content (with an
7
+ * empty summary) when the delimiter is absent or the input isn't a string,
8
+ * so older/unmodified agent output degrades gracefully instead of crashing.
9
+ */
10
+ export function splitStageSummary(raw) {
11
+ if (typeof raw !== 'string') {
12
+ return { content: raw, summary: '' };
13
+ }
14
+
15
+ const idx = raw.lastIndexOf(SUMMARY_DELIMITER);
16
+ if (idx === -1) {
17
+ return { content: raw, summary: '' };
18
+ }
19
+
20
+ return {
21
+ content: raw.slice(0, idx).trim(),
22
+ summary: raw.slice(idx + SUMMARY_DELIMITER.length).trim(),
23
+ };
24
+ }
25
+
26
+ /**
27
+ * Print a blank line followed by `[label] summary: <summary>`. No-op when
28
+ * summary is empty so stages that return no summary don't print a broken line.
29
+ */
30
+ export function printStageSummary(label, summary) {
31
+ if (!summary) return;
32
+ console.log();
33
+ console.log(`[${label}] summary: ${summary}`);
34
+ }