@jaybeeuu/agent-cortex 2.9.0 → 2.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.
- package/CHANGELOG.md +131 -0
- package/README.md +8 -4
- package/agents/README.md +5 -0
- package/agents/guppi/agent.md +70 -1
- package/bin/installers/claude.mjs +7 -3
- package/package.json +1 -1
- package/scripts/lib/compose-agent.mjs +45 -7
- package/skills/review/review-defects/SKILL.md +2 -1
- package/skills/review/review-report/EXAMPLES.md +7 -3
- package/skills/workflow/hitl-queue/BOUNDARY.md +122 -0
- package/skills/workflow/hitl-queue/DEADLINES.md +59 -0
- package/skills/workflow/hitl-queue/RECOMMEND.md +89 -0
- package/skills/workflow/hitl-queue/SCOPES.md +118 -0
- package/skills/workflow/hitl-queue/SKILL.md +48 -32
- package/skills/workflow/hitl-queue/scripts/boundary.mjs +493 -0
- package/skills/workflow/hitl-queue/scripts/queue.mjs +464 -78
- package/skills/workflow/hitl-queue/scripts/recommend.mjs +492 -0
- package/skills/workflow/hitl-queue/scripts/scopes.mjs +479 -0
- package/skills/workflow/prioritise-work/SKILL.md +9 -9
- package/skills/workflow/prioritise-work/scripts/strategy.mjs +191 -1
- package/skills/workflow/prioritise-work/templates/strategy.md +27 -1
- package/skills/workflow/ralph/REFERENCE.md +6 -1
- package/skills/workflow/ralph/scripts/prune-worktrees.mjs +185 -10
- package/token-map.README.md +14 -2
- package/token-map.json +9 -1
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// A boundary re-rank: the theme's next item against the queue's top item, both scores on screen.
|
|
3
|
+
//
|
|
4
|
+
// node boundary.mjs boundary --theme <epic-id> [--next <id>] [--decide <stay|switch|move>]
|
|
5
|
+
// [--reason "<what the decision buys>"] [--file <path>]
|
|
6
|
+
// [--include-ideas] [--cwd <dir>] [--json]
|
|
7
|
+
//
|
|
8
|
+
// guppi works one theme — one epic — and re-ranks at every item boundary. The comparison is the
|
|
9
|
+
// only thing that may justify leaving: this script prints the theme's next item and the queue's
|
|
10
|
+
// top item with the totals the queue computed, and says which way those two numbers point.
|
|
11
|
+
// It adds no arithmetic, so the number a reader checks is the number the queue ranked by.
|
|
12
|
+
//
|
|
13
|
+
// It recommends; it does not switch. A run with no `--decide` writes nothing, and a run that
|
|
14
|
+
// names a decision writes exactly one trail entry on the theme bead, carrying the timestamp,
|
|
15
|
+
// both scores and the reason, so a later session reads the comparison rather than a claim.
|
|
16
|
+
// A decision that contradicts the recommendation needs `--reason`: a move is a judgement about
|
|
17
|
+
// what the break buys, and a switch against the scores is a Strategy change, not a queue result.
|
|
18
|
+
//
|
|
19
|
+
// Zero runtime dependencies, async fs only, every child process with an argv array.
|
|
20
|
+
//
|
|
21
|
+
// Exit codes: 0 an answer was printed (a comparison or a stated condition), 1 a tracker call
|
|
22
|
+
// failed, 2 the caller asked for something malformed.
|
|
23
|
+
|
|
24
|
+
import { pathToFileURL } from 'node:url';
|
|
25
|
+
|
|
26
|
+
import { DEFAULT_FILE } from '../../prioritise-work/scripts/strategy.mjs';
|
|
27
|
+
import { BD_PATH, errorMessage, makeRunner } from '../../score-bead/scripts/rice.mjs';
|
|
28
|
+
import { HITL_LABEL, IDEA_LABEL, UsageError, buildQueue } from './queue.mjs';
|
|
29
|
+
|
|
30
|
+
/** @typedef {Awaited<ReturnType<typeof buildQueue>>} QueueReport */
|
|
31
|
+
/** @typedef {QueueReport['rows'][number]} QueueRow */
|
|
32
|
+
|
|
33
|
+
// ─── Contract ─────────────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The three outcomes of one boundary decision, and no fourth. `stay` continues in the theme,
|
|
37
|
+
* `switch` leaves it because higher-priority work waits elsewhere, and `move` leaves it because
|
|
38
|
+
* a break improves the next item. Only the first two can be read off the scores; `move` is a
|
|
39
|
+
* judgement the contributor makes, so it always carries a reason of its own.
|
|
40
|
+
*/
|
|
41
|
+
export const BOUNDARY_OUTCOME = /** @type {const} */ ({
|
|
42
|
+
stay: 'stay',
|
|
43
|
+
switch: 'switch',
|
|
44
|
+
move: 'move',
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The answers that are stated rather than guessed: an empty queue, a theme with nothing
|
|
49
|
+
* scored, and a next item the queue does not hold. Each names its condition and one next step.
|
|
50
|
+
*/
|
|
51
|
+
export const BOUNDARY_STATE = /** @type {const} */ ({
|
|
52
|
+
boundary: 'boundary',
|
|
53
|
+
empty: 'empty',
|
|
54
|
+
noThemeItem: 'no-theme-item',
|
|
55
|
+
nextNotQueued: 'next-not-queued',
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The whole answer.
|
|
60
|
+
* @typedef {object} BoundaryDecision
|
|
61
|
+
* @property {'stay' | 'switch' | 'move'} outcome
|
|
62
|
+
* @property {string} reason
|
|
63
|
+
* @property {string} note The trail entry as it was written.
|
|
64
|
+
* @property {string} at The timestamp the entry carries, ISO 8601 UTC.
|
|
65
|
+
*/
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* @typedef {object} BoundaryAnswer
|
|
69
|
+
* @property {string} state One of BOUNDARY_STATE.
|
|
70
|
+
* @property {string} theme The epic the session is working in.
|
|
71
|
+
* @property {string} [next] The next item the caller named.
|
|
72
|
+
* @property {QueueReport} report
|
|
73
|
+
* @property {number | undefined} strategyVersion
|
|
74
|
+
* @property {QueueRow} [row] The theme's next item.
|
|
75
|
+
* @property {QueueRow} [top] The queue's top item.
|
|
76
|
+
* @property {'stay' | 'switch'} [recommendation] Which way the two scores point.
|
|
77
|
+
* @property {string} [reason] Why the scores point that way.
|
|
78
|
+
* @property {BoundaryDecision} [decided] The decision the human made, when one was named.
|
|
79
|
+
*/
|
|
80
|
+
|
|
81
|
+
/** The three outcome names, for reading a caller's argument against them. @type {string[]} */
|
|
82
|
+
const OUTCOMES = Object.values(BOUNDARY_OUTCOME);
|
|
83
|
+
|
|
84
|
+
// ─── Numbers the queue already computed ───────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The stored total of a ranked row. A row with no total is not ranked and never reaches here;
|
|
88
|
+
* the cast is the filter's promise, not a second computation.
|
|
89
|
+
* @param {QueueRow} row @returns {number}
|
|
90
|
+
*/
|
|
91
|
+
function totalOf(row) {
|
|
92
|
+
return /** @type {number} */ (row.total);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The score as the queue prints it: one decimal, the number the rank was decided by.
|
|
97
|
+
* @param {QueueRow} row @returns {string}
|
|
98
|
+
*/
|
|
99
|
+
function formatTotal(row) {
|
|
100
|
+
return `total ${totalOf(row).toFixed(1)}`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** @param {string} value @returns {string} */
|
|
104
|
+
function collapse(value) {
|
|
105
|
+
return value.replace(/\s+/g, ' ').trim();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Why the two scores point where they point, in words that carry both numbers. A reader can
|
|
110
|
+
* check the sentence against the two totals on the block; nothing here decides a priority the
|
|
111
|
+
* block has not displayed.
|
|
112
|
+
* @param {QueueRow} row @param {QueueRow} top @returns {string}
|
|
113
|
+
*/
|
|
114
|
+
export function compareReason(row, top) {
|
|
115
|
+
const mine = totalOf(row).toFixed(1);
|
|
116
|
+
const theirs = totalOf(top).toFixed(1);
|
|
117
|
+
if (row.id === top.id) {
|
|
118
|
+
return `the theme's next item is the queue's top item at ${mine}, so higher-priority work waits nowhere else`;
|
|
119
|
+
}
|
|
120
|
+
if (totalOf(top) > totalOf(row)) {
|
|
121
|
+
return `the queue's top item scores ${theirs} against the theme's next ${mine}, so higher-priority work waits elsewhere`;
|
|
122
|
+
}
|
|
123
|
+
return `the theme's next item scores ${mine} against the queue's top ${theirs}, so nothing outside the theme outranks it`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ─── The theme ────────────────────────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The ids of one theme's children, read from the tracker. The theme's next item is the
|
|
130
|
+
* highest-ranked of these in the queue, so the comparison cannot be steered by naming a weak
|
|
131
|
+
* item: the item compared is the one the theme itself would do next.
|
|
132
|
+
* @param {(args: string[]) => Promise<string>} bd @param {string} theme
|
|
133
|
+
* @returns {Promise<Set<string>>}
|
|
134
|
+
*/
|
|
135
|
+
async function readThemeItems(bd, theme) {
|
|
136
|
+
const stdout = await bd(['children', theme, '--json']);
|
|
137
|
+
/** @type {unknown} */
|
|
138
|
+
let parsed;
|
|
139
|
+
try {
|
|
140
|
+
parsed = JSON.parse(stdout === '' ? '[]' : stdout);
|
|
141
|
+
} catch {
|
|
142
|
+
throw new UsageError(`bd children ${theme} --json did not print JSON`);
|
|
143
|
+
}
|
|
144
|
+
if (!Array.isArray(parsed)) throw new UsageError(`bd children ${theme} --json did not print a list`);
|
|
145
|
+
return new Set(parsed.flatMap((entry) => {
|
|
146
|
+
const id = typeof entry === 'object' && entry !== null ? /** @type {Record<string, unknown>} */ (entry).id : undefined;
|
|
147
|
+
return typeof id === 'string' ? [id] : [];
|
|
148
|
+
}));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ─── The comparison ───────────────────────────────────────────────────────────
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Read the queue, place the theme's next item against the queue's top item, and record the
|
|
155
|
+
* decision when the caller names one.
|
|
156
|
+
*
|
|
157
|
+
* The recommendation is derived from the two stored totals and nothing else. The decision is
|
|
158
|
+
* the caller's: it is written as one trail entry on the theme bead, which is the parent of the
|
|
159
|
+
* items, so the reason survives the session that made it.
|
|
160
|
+
*
|
|
161
|
+
* @param {object} options
|
|
162
|
+
* @param {(args: string[], scope?: { name: string }) => Promise<string>} options.bd
|
|
163
|
+
* @param {string} options.theme The epic the session is working in.
|
|
164
|
+
* @param {string | undefined} [options.next] The next item, when the caller names it.
|
|
165
|
+
* @param {'stay' | 'switch' | 'move' | undefined} [options.decide]
|
|
166
|
+
* @param {string | undefined} [options.reason]
|
|
167
|
+
* @param {string | undefined} [options.file] Strategy path; defaults to the documented default.
|
|
168
|
+
* @param {string} [options.cwd]
|
|
169
|
+
* @param {boolean} [options.includeIdeas]
|
|
170
|
+
* @param {number} [options.now] Epoch milliseconds the trail entry is stamped with.
|
|
171
|
+
* @returns {Promise<BoundaryAnswer>}
|
|
172
|
+
*/
|
|
173
|
+
export async function buildBoundary({
|
|
174
|
+
bd,
|
|
175
|
+
theme,
|
|
176
|
+
next,
|
|
177
|
+
decide,
|
|
178
|
+
reason,
|
|
179
|
+
file,
|
|
180
|
+
cwd = process.cwd(),
|
|
181
|
+
includeIdeas = false,
|
|
182
|
+
now = Date.now(),
|
|
183
|
+
}) {
|
|
184
|
+
const report = await buildQueue({ bd, cwd, includeIdeas, ...(file === undefined ? {} : { file }) });
|
|
185
|
+
const strategyVersion = report.strategy.version;
|
|
186
|
+
const ranked = report.rows.filter((row) => row.total !== undefined);
|
|
187
|
+
|
|
188
|
+
if (ranked.length === 0) {
|
|
189
|
+
return { state: BOUNDARY_STATE.empty, theme, report, strategyVersion };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const top = /** @type {QueueRow} */ (ranked[0]);
|
|
193
|
+
|
|
194
|
+
/** @type {QueueRow | undefined} */
|
|
195
|
+
let row;
|
|
196
|
+
if (next !== undefined) {
|
|
197
|
+
row = ranked.find((each) => each.id === next);
|
|
198
|
+
if (row === undefined) {
|
|
199
|
+
return { state: BOUNDARY_STATE.nextNotQueued, theme, next, report, top, strategyVersion };
|
|
200
|
+
}
|
|
201
|
+
} else {
|
|
202
|
+
const items = await readThemeItems(bd, theme);
|
|
203
|
+
row = ranked.find((each) => items.has(each.id));
|
|
204
|
+
if (row === undefined) {
|
|
205
|
+
return { state: BOUNDARY_STATE.noThemeItem, theme, report, top, strategyVersion };
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const recommendation = top.id !== row.id && totalOf(top) > totalOf(row) ? 'switch' : 'stay';
|
|
210
|
+
const derived = compareReason(row, top);
|
|
211
|
+
|
|
212
|
+
if (decide === undefined) {
|
|
213
|
+
return {
|
|
214
|
+
state: BOUNDARY_STATE.boundary,
|
|
215
|
+
theme,
|
|
216
|
+
report,
|
|
217
|
+
strategyVersion,
|
|
218
|
+
row,
|
|
219
|
+
top,
|
|
220
|
+
recommendation,
|
|
221
|
+
reason: derived,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (!OUTCOMES.includes(decide)) {
|
|
226
|
+
throw new UsageError(`--decide takes ${OUTCOMES.join(', ')}, not "${decide}"`);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const given = reason === undefined ? '' : collapse(reason);
|
|
230
|
+
const chosen = given === '' ? (decide === recommendation ? derived : undefined) : given;
|
|
231
|
+
if (chosen === undefined) {
|
|
232
|
+
throw new UsageError(
|
|
233
|
+
`--decide ${decide} contradicts the recommendation (${recommendation}), so it needs --reason: name what the decision buys`,
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const at = new Date(now).toISOString();
|
|
238
|
+
const note = `[${at}] boundary: ${decide} · theme next ${row.id} ${formatTotal(row)}` +
|
|
239
|
+
` vs queue top ${top.id} ${formatTotal(top)} · reason: ${chosen}`;
|
|
240
|
+
await bd(['note', theme, note]);
|
|
241
|
+
|
|
242
|
+
return {
|
|
243
|
+
state: BOUNDARY_STATE.boundary,
|
|
244
|
+
theme,
|
|
245
|
+
report,
|
|
246
|
+
strategyVersion,
|
|
247
|
+
row,
|
|
248
|
+
top,
|
|
249
|
+
recommendation,
|
|
250
|
+
reason: derived,
|
|
251
|
+
decided: { outcome: decide, reason: chosen, note, at },
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ─── Render ───────────────────────────────────────────────────────────────────
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* One side of the comparison: the bead, its total and its title. Both sides are printed on
|
|
259
|
+
* every comparison, so leaving the theme is always a choice between two numbers on screen.
|
|
260
|
+
* @param {string} label @param {QueueRow} row @returns {string}
|
|
261
|
+
*/
|
|
262
|
+
function side(label, row) {
|
|
263
|
+
return `${label} ${row.id} · ${formatTotal(row)} · ${row.title}`;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Render one answer. A comparison is the recommendation with its reason, both scores, and
|
|
268
|
+
* then either the decision that was recorded or the one step that asks for it. Every other
|
|
269
|
+
* state leads with `No boundary`, names the condition and offers the one step that clears it.
|
|
270
|
+
* @param {BoundaryAnswer} answer @returns {string}
|
|
271
|
+
*/
|
|
272
|
+
export function renderBoundary(answer) {
|
|
273
|
+
const version = answer.strategyVersion === undefined
|
|
274
|
+
? 'strategy unknown'
|
|
275
|
+
: `strategy v${answer.strategyVersion}`;
|
|
276
|
+
|
|
277
|
+
if (answer.state === BOUNDARY_STATE.boundary) {
|
|
278
|
+
const row = /** @type {QueueRow} */ (answer.row);
|
|
279
|
+
const top = /** @type {QueueRow} */ (answer.top);
|
|
280
|
+
const lines = [
|
|
281
|
+
`Boundary · theme ${answer.theme} · ${version}`,
|
|
282
|
+
`recommendation: ${answer.recommendation} — ${answer.reason}`,
|
|
283
|
+
side('theme next ', row),
|
|
284
|
+
side('queue top ', top),
|
|
285
|
+
];
|
|
286
|
+
if (answer.decided === undefined) {
|
|
287
|
+
lines.push("next: stay, switch or move is the human's call — nothing is recorded until they answer");
|
|
288
|
+
} else {
|
|
289
|
+
lines.push(
|
|
290
|
+
`decision: ${answer.decided.outcome} — ${answer.decided.reason}`,
|
|
291
|
+
`trail: ${answer.theme} · ${answer.decided.note}`,
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
return `${lines.join('\n')}\n`;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (answer.state === BOUNDARY_STATE.empty) {
|
|
298
|
+
return `${
|
|
299
|
+
[
|
|
300
|
+
'No boundary · the queue is empty',
|
|
301
|
+
` no open ${HITL_LABEL} beads`,
|
|
302
|
+
`next: file the theme's next item as an ${HITL_LABEL} bead, or lift a deferral with bd update <id> --status open`,
|
|
303
|
+
].join('\n')
|
|
304
|
+
}\n`;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (answer.state === BOUNDARY_STATE.nextNotQueued) {
|
|
308
|
+
const named = answer.next ?? 'that bead';
|
|
309
|
+
const top = /** @type {QueueRow} */ (answer.top);
|
|
310
|
+
return `${
|
|
311
|
+
[
|
|
312
|
+
`No boundary · ${named} is not in the queue`,
|
|
313
|
+
` ${named} is not an open ${HITL_LABEL} bead carrying a score — check it with bd show ${named}`,
|
|
314
|
+
` ${side('queue top ', top)}`,
|
|
315
|
+
`next: name a scored bead from the queue, or score ${named} with score-bead`,
|
|
316
|
+
].join('\n')
|
|
317
|
+
}\n`;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const top = /** @type {QueueRow} */ (answer.top);
|
|
321
|
+
return `${
|
|
322
|
+
[
|
|
323
|
+
`No boundary · theme ${answer.theme} has no ranked item`,
|
|
324
|
+
` no scored ${HITL_LABEL} bead sits under ${answer.theme}`,
|
|
325
|
+
` ${side('queue top ', top)}`,
|
|
326
|
+
`next: name the theme's next item with --next <id>, or score it with score-bead`,
|
|
327
|
+
].join('\n')
|
|
328
|
+
}\n`;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// ─── CLI ──────────────────────────────────────────────────────────────────────
|
|
332
|
+
|
|
333
|
+
/** @type {readonly string[]} */
|
|
334
|
+
export const COMMANDS = /** @type {const} */ (['boundary']);
|
|
335
|
+
|
|
336
|
+
const USAGE = `Usage: node boundary.mjs boundary --theme <epic-id> [--next <id>] [--decide <outcome>]
|
|
337
|
+
[--reason "<what the decision buys>"] [--file <path>]
|
|
338
|
+
[--include-ideas] [--cwd <dir>] [--json]
|
|
339
|
+
|
|
340
|
+
boundary Compare the theme's next item against the queue's top item, print both totals and
|
|
341
|
+
the recommendation the two numbers point to, and record the decision on the theme
|
|
342
|
+
bead when one is named. Without --decide nothing is written: the human decides.
|
|
343
|
+
|
|
344
|
+
Options:
|
|
345
|
+
--theme <epic-id> The theme the session is working in; its children are the theme's items
|
|
346
|
+
--next <id> The theme's next item, when the caller names it instead
|
|
347
|
+
--decide <outcome> ${Object.values(BOUNDARY_OUTCOME).join(', ')} — record the human's decision as a trail entry
|
|
348
|
+
--reason "<text>" Why that decision: required when it contradicts the recommendation
|
|
349
|
+
--file <path> Strategy file (default: ${DEFAULT_FILE}, relative to the repository root)
|
|
350
|
+
--include-ideas Rank ${IDEA_LABEL} beads too (they are excluded by default)
|
|
351
|
+
--cwd <dir> Directory the strategy path and bd resolve from
|
|
352
|
+
--json Print the answer as JSON
|
|
353
|
+
-h, --help Show this text
|
|
354
|
+
|
|
355
|
+
Environment:
|
|
356
|
+
BD_PATH bd binary (default: bd)
|
|
357
|
+
`;
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Parse the CLI's argv.
|
|
361
|
+
* @param {string[]} argv
|
|
362
|
+
* @returns {{ command: string, theme: string, next?: string, decide?: 'stay' | 'switch' | 'move', reason?: string, file?: string, includeIdeas: boolean, cwd: string, json: boolean }}
|
|
363
|
+
*/
|
|
364
|
+
export function parseArgs(argv) {
|
|
365
|
+
/** @type {string | undefined} */
|
|
366
|
+
let command;
|
|
367
|
+
/** @type {string | undefined} */
|
|
368
|
+
let theme;
|
|
369
|
+
/** @type {string | undefined} */
|
|
370
|
+
let next;
|
|
371
|
+
/** @type {string | undefined} */
|
|
372
|
+
let decide;
|
|
373
|
+
/** @type {string | undefined} */
|
|
374
|
+
let reason;
|
|
375
|
+
/** @type {string | undefined} */
|
|
376
|
+
let file;
|
|
377
|
+
let cwd = process.cwd();
|
|
378
|
+
let json = false;
|
|
379
|
+
let includeIdeas = false;
|
|
380
|
+
|
|
381
|
+
for (let i = 0; i < argv.length; i++) {
|
|
382
|
+
const arg = argv[i];
|
|
383
|
+
if (arg === '--json') {
|
|
384
|
+
json = true;
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
if (arg === '--include-ideas') {
|
|
388
|
+
includeIdeas = true;
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
if (arg === '--file' || arg === '--cwd' || arg === '--theme' || arg === '--next' || arg === '--decide' || arg === '--reason') {
|
|
392
|
+
const value = argv[++i];
|
|
393
|
+
if (value === undefined) throw new UsageError(`${arg} needs a value`);
|
|
394
|
+
if (arg === '--cwd') cwd = value;
|
|
395
|
+
else if (arg === '--theme') theme = value;
|
|
396
|
+
else if (arg === '--next') next = value;
|
|
397
|
+
else if (arg === '--decide') decide = value;
|
|
398
|
+
else if (arg === '--reason') reason = value;
|
|
399
|
+
else file = value;
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
if (arg.startsWith('-')) throw new UsageError(`unexpected argument: ${arg}`);
|
|
403
|
+
if (command === undefined) {
|
|
404
|
+
if (!COMMANDS.includes(arg)) throw new UsageError(`unknown command "${arg}"`);
|
|
405
|
+
command = arg;
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
throw new UsageError(`unexpected argument: ${arg}`);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
if (command === undefined) throw new UsageError(`a command is required: ${COMMANDS.join(', ')}`);
|
|
412
|
+
if (theme === undefined) throw new UsageError('--theme is required: the epic the session is working in');
|
|
413
|
+
if (decide !== undefined && !OUTCOMES.includes(decide)) {
|
|
414
|
+
throw new UsageError(`--decide takes ${Object.values(BOUNDARY_OUTCOME).join(', ')}, not "${decide}"`);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
return {
|
|
418
|
+
command,
|
|
419
|
+
theme,
|
|
420
|
+
...(next === undefined ? {} : { next }),
|
|
421
|
+
...(decide === undefined ? {} : { decide: /** @type {'stay' | 'switch' | 'move'} */ (decide) }),
|
|
422
|
+
...(reason === undefined ? {} : { reason }),
|
|
423
|
+
...(file === undefined ? {} : { file }),
|
|
424
|
+
includeIdeas,
|
|
425
|
+
cwd,
|
|
426
|
+
json,
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* @param {object} options
|
|
432
|
+
* @param {string[]} options.argv CLI arguments (without node/script).
|
|
433
|
+
* @param {(chunk: string) => void} [options.write] stdout sink; injectable for tests.
|
|
434
|
+
* @param {(chunk: string) => void} [options.writeError] stderr sink; injectable for tests.
|
|
435
|
+
* @param {{ bd?: (args: string[], scope?: { name: string }) => Promise<string> }} [options.deps]
|
|
436
|
+
* Collaborators; injectable for tests.
|
|
437
|
+
* @returns {Promise<number>} process exit code.
|
|
438
|
+
*/
|
|
439
|
+
export async function runCli({
|
|
440
|
+
argv,
|
|
441
|
+
write = (chunk) => process.stdout.write(chunk),
|
|
442
|
+
writeError = (chunk) => process.stderr.write(chunk),
|
|
443
|
+
deps = {},
|
|
444
|
+
}) {
|
|
445
|
+
if (argv.includes('--help') || argv.includes('-h')) {
|
|
446
|
+
write(USAGE);
|
|
447
|
+
return 0;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/** @type {ReturnType<typeof parseArgs>} */
|
|
451
|
+
let options;
|
|
452
|
+
try {
|
|
453
|
+
options = parseArgs(argv);
|
|
454
|
+
} catch (error) {
|
|
455
|
+
writeError(`boundary: ${errorMessage(error)}\n\n${USAGE}`);
|
|
456
|
+
return 2;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const bd = deps.bd ?? makeRunner(BD_PATH, options.cwd);
|
|
460
|
+
try {
|
|
461
|
+
const answer = await buildBoundary({
|
|
462
|
+
bd,
|
|
463
|
+
theme: options.theme,
|
|
464
|
+
cwd: options.cwd,
|
|
465
|
+
includeIdeas: options.includeIdeas,
|
|
466
|
+
...(options.next === undefined ? {} : { next: options.next }),
|
|
467
|
+
...(options.decide === undefined ? {} : { decide: options.decide }),
|
|
468
|
+
...(options.reason === undefined ? {} : { reason: options.reason }),
|
|
469
|
+
...(options.file === undefined ? {} : { file: options.file }),
|
|
470
|
+
});
|
|
471
|
+
write(options.json ? `${JSON.stringify(answer, null, 2)}\n` : renderBoundary(answer));
|
|
472
|
+
return 0;
|
|
473
|
+
} catch (error) {
|
|
474
|
+
if (error instanceof UsageError) {
|
|
475
|
+
writeError(`boundary: ${errorMessage(error)}\n`);
|
|
476
|
+
return 2;
|
|
477
|
+
}
|
|
478
|
+
writeError(`boundary failed: ${errorMessage(error)}\n`);
|
|
479
|
+
return 1;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// Only run the CLI when invoked directly, so tests can import the module.
|
|
484
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
485
|
+
runCli({ argv: process.argv.slice(2) })
|
|
486
|
+
.then((code) => {
|
|
487
|
+
process.exitCode = code;
|
|
488
|
+
})
|
|
489
|
+
.catch((error) => {
|
|
490
|
+
process.stderr.write(`${errorMessage(error)}\n`);
|
|
491
|
+
process.exitCode = 1;
|
|
492
|
+
});
|
|
493
|
+
}
|