@jaybeeuu/agent-cortex 2.8.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.
Files changed (52) hide show
  1. package/CHANGELOG.md +304 -0
  2. package/README.md +30 -3
  3. package/agents/README.md +7 -2
  4. package/agents/guppi/agent.md +231 -0
  5. package/agents/guppi/claude/frontmatter.json +6 -0
  6. package/agents/guppi/pi/frontmatter.json +6 -0
  7. package/agents/plan/agent.md +6 -0
  8. package/agents/ralph-plan/agent.md +6 -0
  9. package/bin/installers/claude.mjs +8 -23
  10. package/bin/installers/pi.mjs +8 -2
  11. package/extensions/interrupt-bookmark/README.md +129 -0
  12. package/extensions/interrupt-bookmark/index.test.ts +717 -0
  13. package/extensions/interrupt-bookmark/index.ts +354 -0
  14. package/extensions/interrupt-bookmark/package.json +20 -0
  15. package/extensions/skill-stats/README.md +84 -3
  16. package/extensions/skill-stats/index.ts +62 -3
  17. package/extensions/skill-stats/package.json +1 -1
  18. package/extensions/skill-stats/rollup.test.ts +643 -0
  19. package/extensions/skill-stats/rollup.ts +415 -0
  20. package/extensions/skill-stats/timeline.ts +101 -0
  21. package/package.json +1 -1
  22. package/scripts/lib/compose-agent.mjs +45 -7
  23. package/scripts/lib/manifest.mjs +29 -0
  24. package/skills/review/review-defects/EXAMPLES.md +80 -4
  25. package/skills/review/review-defects/SKILL.md +47 -16
  26. package/skills/review/review-report/EXAMPLES.md +118 -0
  27. package/skills/review/review-report/SKILL.md +138 -0
  28. package/skills/review/review-report/scripts/report.mjs +1032 -0
  29. package/skills/workflow/hitl-queue/BOUNDARY.md +122 -0
  30. package/skills/workflow/hitl-queue/DEADLINES.md +59 -0
  31. package/skills/workflow/hitl-queue/RECOMMEND.md +89 -0
  32. package/skills/workflow/hitl-queue/SCOPES.md +118 -0
  33. package/skills/workflow/hitl-queue/SKILL.md +150 -0
  34. package/skills/workflow/hitl-queue/scripts/boundary.mjs +493 -0
  35. package/skills/workflow/hitl-queue/scripts/queue.mjs +1012 -0
  36. package/skills/workflow/hitl-queue/scripts/recommend.mjs +492 -0
  37. package/skills/workflow/hitl-queue/scripts/scopes.mjs +479 -0
  38. package/skills/workflow/plan/SKILL.md +13 -0
  39. package/skills/workflow/prioritise-work/CHALLENGE.md +139 -0
  40. package/skills/workflow/prioritise-work/SKILL.md +150 -0
  41. package/skills/workflow/prioritise-work/scripts/challenge.mjs +505 -0
  42. package/skills/workflow/prioritise-work/scripts/strategy.mjs +756 -0
  43. package/skills/workflow/prioritise-work/templates/strategy.md +87 -0
  44. package/skills/workflow/ralph/REFERENCE.md +6 -1
  45. package/skills/workflow/ralph/scripts/prune-worktrees.mjs +185 -10
  46. package/skills/workflow/run-pipeline-stage/playbooks/review.md +5 -2
  47. package/skills/workflow/score-bead/FORMAT.md +73 -0
  48. package/skills/workflow/score-bead/SKILL.md +112 -0
  49. package/skills/workflow/score-bead/scripts/rice.mjs +664 -0
  50. package/skills/workflow/using-agent-skills/SKILL.md +6 -1
  51. package/token-map.README.md +14 -2
  52. package/token-map.json +17 -3
@@ -0,0 +1,1012 @@
1
+ // @ts-check
2
+ // The ranked HITL queue: one flat list of the beads that are waiting for a human
3
+ // answer, ordered by how much each answer unblocks.
4
+ //
5
+ // node queue.mjs queue [--scopes <path>] [--shared] [--file <strategy>]
6
+ // [--include-ideas] [--cwd <dir>] [--json]
7
+ //
8
+ // The list spans several bd workspaces — the *scopes* — and tags every row with the scope
9
+ // it came from. See `scopes.mjs` for the list, the read-only guard and the daily note.
10
+ //
11
+ // One multiplier does the load-bearing work: a bead that unblocks three others outranks
12
+ // an otherwise identical bead that unblocks none, and the count is **transitive** — a
13
+ // bead blocking A, which blocks B, which blocks C, unblocks three. The walk collects
14
+ // distinct beads, never paths, so a diamond (one bead reached by two routes) counts once.
15
+ // The walk runs inside the candidate's own workspace, so two scopes may reuse a bead id
16
+ // without their graphs mixing.
17
+ //
18
+ // A bd `--due` date adds a second factor, and its numbers live in the strategy's
19
+ // `## Deadline tiers` table rather than here: the narrower the remaining time, the higher
20
+ // the factor, and a passed deadline is flagged `OVERDUE` in its row instead of being
21
+ // lifted in silence. `now` is a parameter of the build, never read deep inside the
22
+ // ranking, so a boundary case is deterministic.
23
+ //
24
+ // The RICE score is read from the bead's own metadata, written by the score-bead skill.
25
+ // This script never recomputes it and never invents one: a bead with no score is listed
26
+ // as `unscored` with the reason, because a queue that guessed would hide the gap.
27
+ //
28
+ // Zero runtime dependencies, async fs only, every child process with an argv array.
29
+ // `skills/*/scripts` sits outside the check-no-sync scan; this script stays async anyway,
30
+ // so the rule holds everywhere. BD_PATH pins a bd binary the way the score script does.
31
+ //
32
+ // Exit codes: 0 the queue was built — a scope it could not read is reported as
33
+ // `unavailable`, never fatal; 1 an internal failure; 2 the caller asked for something
34
+ // malformed.
35
+
36
+ import { readFile } from 'node:fs/promises';
37
+ import { pathToFileURL } from 'node:url';
38
+
39
+ import { DEFAULT_FILE, resolveStrategyPath, validateStrategy } from '../../prioritise-work/scripts/strategy.mjs';
40
+ import { DEFAULT_DEADLINE_TIERS, deadlineMultiplier, parseDeadlineTiers } from '../../prioritise-work/scripts/strategy.mjs';
41
+ import { BD_PATH, RICE_KEY, errorMessage, makeRunner, storedRice } from '../../score-bead/scripts/rice.mjs';
42
+ import {
43
+ DEFAULT_SCOPES_FILE,
44
+ HOME_ENV,
45
+ PERSONAL_KIND,
46
+ SHARED_ENV,
47
+ ScopeError,
48
+ ScopeWriteError,
49
+ emptyNote,
50
+ guardRunner,
51
+ readDailyNote,
52
+ readScopeConfig,
53
+ unavailableReason,
54
+ } from './scopes.mjs';
55
+
56
+ /** @typedef {import('../../score-bead/scripts/rice.mjs').StoredRice} StoredRice */
57
+ /** @typedef {import('../../prioritise-work/scripts/strategy.mjs').DeadlineTier} DeadlineTier */
58
+ /** @typedef {import('./scopes.mjs').DailyNote} DailyNote */
59
+ /** @typedef {import('./scopes.mjs').Scope} Scope */
60
+ /** @typedef {(args: string[]) => Promise<string>} Runner */
61
+ /** @typedef {(args: string[], scope: Scope) => Promise<string>} ScopedRunner */
62
+
63
+ // ─── Contract ─────────────────────────────────────────────────────────────────
64
+
65
+ /** The label that marks a bead as human-required. The queue is that label's backlog. */
66
+ export const HITL_LABEL = 'execution:hitl';
67
+
68
+ /** Ideas are HITL by convention, and ranked only when asked for. */
69
+ export const IDEA_LABEL = 'kind:idea';
70
+
71
+ /** The status that says work has started, and so earns the work-in-progress multiplier. */
72
+ export const IN_PROGRESS_STATUS = 'in_progress';
73
+
74
+ /** A bead the owner has parked. Deferring is an answer, so the queue leaves it out. */
75
+ export const DEFERRED_STATUS = 'deferred';
76
+
77
+ /** Work already finished cannot be unblocked, so it never counts toward the multiplier. */
78
+ export const CLOSED_STATUS = 'closed';
79
+
80
+ /** An in-progress bead outranks an equal not-started one: the queue drains WIP first. */
81
+ export const WIP_MULTIPLIER = 1.5;
82
+
83
+ /** The dependency type that blocks. A "related" edge is not an answer anyone waits on. */
84
+ export const BLOCKS_TYPE = 'blocks';
85
+
86
+ /** How many dependent ids a row names before it summarises the rest as a count. */
87
+ const NAMED_DEPENDENTS = 5;
88
+
89
+ /** How many milliseconds a minute holds, for showing a deadline as days and hours. */
90
+ const MS_PER_MINUTE = 60_000;
91
+
92
+ /** A usage mistake: the caller asked for something malformed. Exit code 2. */
93
+ export class UsageError extends Error {}
94
+
95
+ /** The run refused: the tracker printed something this script cannot read. Exit code 1. */
96
+ export class QueueError extends Error {}
97
+
98
+ // ─── Types ────────────────────────────────────────────────────────────────────
99
+
100
+ /**
101
+ * One bead as `bd list` reports it, plus the scope it was read from.
102
+ * @typedef {object} Candidate
103
+ * @property {string} id
104
+ * @property {string} title
105
+ * @property {string} status
106
+ * @property {string} scope
107
+ * @property {StoredRice | undefined} rice
108
+ * @property {number | undefined} dueAt The `--due` moment, epoch milliseconds.
109
+ * @property {number | undefined} deferUntil The `--defer` wake moment, epoch milliseconds.
110
+ */
111
+
112
+ /**
113
+ * One line of the queue: a candidate plus everything the ranking derived from it.
114
+ * @typedef {object} QueueRow
115
+ * @property {number | undefined} rank Undefined for an unscored row.
116
+ * @property {string} id
117
+ * @property {string} scope
118
+ * @property {string} title
119
+ * @property {string} status
120
+ * @property {boolean} inProgress
121
+ * @property {number | undefined} score The stored RICE total, when there is one.
122
+ * @property {StoredRice | undefined} rice
123
+ * @property {number} dependents Distinct transitive dependents, unclosed.
124
+ * @property {string[]} dependentIds Those dependents, sorted, closed excluded.
125
+ * @property {number} dependentMultiplier 1 + ln(1 + dependents).
126
+ * @property {number} wipMultiplier WIP_MULTIPLIER, or 1.
127
+ * @property {number | undefined} dueAt The `--due` moment, epoch milliseconds.
128
+ * @property {number | undefined} remainingMs Distance to the deadline; negative once past.
129
+ * @property {number} deadlineFactor The strategy's factor; 1 without a deadline.
130
+ * @property {boolean} overdue The deadline has passed.
131
+ * @property {number | undefined} total The rounded ranking key; undefined if unscored.
132
+ * @property {number | undefined} scoredAtVersion
133
+ * @property {boolean} stale Scored against an older strategy version.
134
+ * @property {string | undefined} unscoredReason Why this bead holds no rank.
135
+ */
136
+
137
+ /**
138
+ * One configured scope, as the report publishes it. `name` and `path` are null for a
139
+ * withheld scope: they identify a workspace the reader may not see.
140
+ * @typedef {object} ScopeStatus
141
+ * @property {string | null} name
142
+ * @property {string} kind
143
+ * @property {boolean} readOnly
144
+ * @property {boolean} writable
145
+ * @property {string | null} path
146
+ * @property {'available' | 'unavailable' | 'withheld'} status
147
+ * @property {string | undefined} reason
148
+ */
149
+
150
+ /**
151
+ * The whole answer.
152
+ * @typedef {object} QueueReport
153
+ * @property {{ file: string, version: number | undefined, deadlineTiers: DeadlineTier[] }} strategy
154
+ * @property {'local' | 'shared'} invocation
155
+ * @property {ScopeStatus[]} scopes
156
+ * @property {DailyNote} today
157
+ * @property {{ ranked: number, unscored: number, excludedIdeas: number, excludedDeferred: number, availableScopes: number, unavailableScopes: number, withheldScopes: number }} counts
158
+ * @property {QueueRow[]} rows Ranked rows first, then unscored rows.
159
+ */
160
+
161
+ // ─── Arithmetic ───────────────────────────────────────────────────────────────
162
+
163
+ /** @param {number} value @returns {number} */
164
+ function round1(value) {
165
+ return Math.round(value * 10) / 10;
166
+ }
167
+
168
+ /**
169
+ * How much one answer is worth in dependents: 1 + ln(1 + n). Zero dependents scores 1,
170
+ * so a bead that unblocks nothing is not penalised — it is simply not lifted. The curve
171
+ * is logarithmic so unblocking thirty chores does not drown the queue under one row.
172
+ * @param {number} count Distinct transitively blocked beads.
173
+ * @returns {number}
174
+ */
175
+ export function dependentMultiplier(count) {
176
+ return 1 + Math.log1p(count);
177
+ }
178
+
179
+ /**
180
+ * The ranking key: RICE × dependent multiplier × work-in-progress multiplier × deadline
181
+ * multiplier, rounded to one decimal. The rounded figure is the sort key as well as the
182
+ * printed one, so the number a reader sees is the number the order was decided by.
183
+ * @param {object} input
184
+ * @param {number} input.score
185
+ * @param {number} input.dependents
186
+ * @param {boolean} input.inProgress
187
+ * @param {number} [input.deadline] The strategy's factor at the bead's remaining time; 1
188
+ * when the bead has no deadline.
189
+ * @returns {number}
190
+ */
191
+ export function queueTotal({ score, dependents, inProgress, deadline = 1 }) {
192
+ return round1(score * dependentMultiplier(dependents) * (inProgress ? WIP_MULTIPLIER : 1) * deadline);
193
+ }
194
+
195
+ // ─── Tracker ──────────────────────────────────────────────────────────────────
196
+
197
+ /**
198
+ * @param {string} stdout @param {string} what @returns {unknown[]}
199
+ */
200
+ function parseJsonArray(stdout, what) {
201
+ let parsed;
202
+ try {
203
+ parsed = JSON.parse(stdout);
204
+ } catch {
205
+ throw new QueueError(`${what} did not print JSON`);
206
+ }
207
+ if (!Array.isArray(parsed)) throw new QueueError(`${what} did not print a JSON array`);
208
+ return parsed;
209
+ }
210
+
211
+ /** @param {unknown} value @returns {Record<string, unknown> | undefined} */
212
+ function asRecord(value) {
213
+ return typeof value === 'object' && value !== null ? /** @type {Record<string, unknown>} */ (value) : undefined;
214
+ }
215
+
216
+ /** @param {unknown} value @returns {string | undefined} */
217
+ function asString(value) {
218
+ return typeof value === 'string' ? value : undefined;
219
+ }
220
+
221
+ /**
222
+ * An ISO instant as epoch milliseconds, or undefined when the field is absent or unreadable.
223
+ * `bd` writes both `due_at` and `defer_until` as UTC ISO strings.
224
+ * @param {unknown} value @returns {number | undefined}
225
+ */
226
+ function instantOf(value) {
227
+ if (typeof value !== 'string') return undefined;
228
+ const parsed = Date.parse(value);
229
+ return Number.isNaN(parsed) ? undefined : parsed;
230
+ }
231
+
232
+ /**
233
+ * A dependent edge: the bead, and the status it is in, so the count can drop finished work.
234
+ * @typedef {object} Dependent
235
+ * @property {string} id
236
+ * @property {string} status
237
+ */
238
+
239
+ /**
240
+ * Read the dependency graph upwards, once per bead.
241
+ *
242
+ * `bd dep list <id> --direction up` names the beads that depend on `<id>` — the ones an
243
+ * answer unblocks. The walk is transitive: from every dependent it asks again, so a chain
244
+ * of three counts three rather than one. Two properties are deliberate:
245
+ *
246
+ * • **Distinct, not paths.** The result is a map keyed by bead id, so a diamond — one
247
+ * bead reached by two routes — is counted once. Paths would inflate a reconverging
248
+ * graph, and the multiplier would then measure the shape rather than the work.
249
+ * • **Walked through everything, counted only while live.** A closed bead is still a
250
+ * link in the chain, so the walk passes through it; but a closed bead is not work an
251
+ * answer unblocks, so it is left out of the result.
252
+ *
253
+ * Only `blocks` edges count: a "related" edge is not something anyone waits on. Each
254
+ * bead's direct edges are read once and remembered, so a shared subtree is fetched once
255
+ * however many roots reach it.
256
+ *
257
+ * @param {Runner} bd
258
+ * @returns {(beadId: string) => Promise<Map<string, Dependent>>}
259
+ */
260
+ export function createDependentReader(bd) {
261
+ /** @type {Map<string, Promise<Dependent[]>>} */
262
+ const edges = new Map();
263
+
264
+ /** @param {string} beadId @returns {Promise<Dependent[]>} */
265
+ function direct(beadId) {
266
+ const cached = edges.get(beadId);
267
+ if (cached !== undefined) return cached;
268
+ const pending = bd(['dep', 'list', beadId, '--direction', 'up', '--json']).then((stdout) => {
269
+ const rows = parseJsonArray(stdout, `bd dep list ${beadId} --direction up --json`);
270
+ /** @type {Dependent[]} */
271
+ const out = [];
272
+ for (const row of rows) {
273
+ const record = asRecord(row);
274
+ if (record === undefined) continue;
275
+ if (record.dependency_type !== BLOCKS_TYPE) continue;
276
+ const id = asString(record.id);
277
+ if (id === undefined) continue;
278
+ out.push({ id, status: asString(record.status) ?? 'open' });
279
+ }
280
+ return out;
281
+ });
282
+ edges.set(beadId, pending);
283
+ return pending;
284
+ }
285
+
286
+ /**
287
+ * Every bead that depends on `beadId`, directly or through another bead. Level by level
288
+ * so one level's edges are fetched together; the frontier is a set, so a reconverging
289
+ * graph cannot make the walk repeat itself.
290
+ * @param {string} beadId
291
+ * @returns {Promise<Map<string, Dependent>>}
292
+ */
293
+ return async function dependentsOf(beadId) {
294
+ /** @type {Map<string, Dependent>} */
295
+ const found = new Map();
296
+ const seen = new Set([beadId]);
297
+ let frontier = [beadId];
298
+
299
+ while (frontier.length > 0) {
300
+ const level = await Promise.all(frontier.map((id) => direct(id)));
301
+ /** @type {string[]} */
302
+ const next = [];
303
+ for (const list of level) {
304
+ for (const dependent of list) {
305
+ found.set(dependent.id, dependent);
306
+ if (seen.has(dependent.id)) continue;
307
+ seen.add(dependent.id);
308
+ next.push(dependent.id);
309
+ }
310
+ }
311
+ frontier = next;
312
+ }
313
+
314
+ found.delete(beadId);
315
+ return found;
316
+ };
317
+ }
318
+
319
+ /**
320
+ * The candidates in one scope: every open bead carrying `execution:hitl`. Closed beads are
321
+ * bd's own default filter; an idea is unshaped, so it is counted and left out unless asked
322
+ * for.
323
+ *
324
+ * A deferral is bd's own field and bd's own rule, so the queue reads it rather than
325
+ * inventing a second one. `bd ready` wakes a bead whose `defer_until` has passed, but
326
+ * `bd list` keeps reporting the stale `deferred` status the moment it was parked in, so
327
+ * applying the wake rule to `defer_until` here keeps this list equal to bd's own view.
328
+ * A `deferred` bead with no readable wake moment stays out: nothing says it woke.
329
+ * @param {object} options
330
+ * @param {Runner} options.bd
331
+ * @param {string} options.scope The scope name every row is tagged with.
332
+ * @param {boolean} options.includeIdeas
333
+ * @param {number} options.now Epoch milliseconds the deferral is judged against.
334
+ * @returns {Promise<{ candidates: Candidate[], excludedIdeas: number, excludedDeferred: number }>}
335
+ */
336
+ async function readCandidates({ bd, scope, includeIdeas, now }) {
337
+ const stdout = await bd(['list', '-l', HITL_LABEL, '--json', '--limit', '0']);
338
+ const rows = parseJsonArray(stdout, `bd list -l ${HITL_LABEL} --json`);
339
+
340
+ /** @type {Candidate[]} */
341
+ const candidates = [];
342
+ let excludedIdeas = 0;
343
+ let excludedDeferred = 0;
344
+
345
+ for (const row of rows) {
346
+ const record = asRecord(row);
347
+ if (record === undefined) continue;
348
+ const id = asString(record.id);
349
+ if (id === undefined) continue;
350
+ const status = asString(record.status) ?? 'open';
351
+ if (status === CLOSED_STATUS) continue;
352
+ const labels = Array.isArray(record.labels) ? record.labels.filter((label) => typeof label === 'string') : [];
353
+ const deferUntil = instantOf(record.defer_until);
354
+ if (!includeIdeas && labels.includes(IDEA_LABEL)) {
355
+ excludedIdeas += 1;
356
+ continue;
357
+ }
358
+ if (status === DEFERRED_STATUS && (deferUntil === undefined || deferUntil > now)) {
359
+ excludedDeferred += 1;
360
+ continue;
361
+ }
362
+ candidates.push({
363
+ id,
364
+ scope,
365
+ title: asString(record.title) ?? id,
366
+ status,
367
+ rice: storedRice(record.metadata),
368
+ dueAt: instantOf(record.due_at),
369
+ deferUntil,
370
+ });
371
+ }
372
+
373
+ return { candidates, excludedIdeas, excludedDeferred };
374
+ }
375
+
376
+ /**
377
+ * The strategy a run ranks against: the version a score is compared with, and the deadline
378
+ * tiers its factor comes from. A missing or malformed strategy does not stop the queue:
379
+ * the scores are already on the beads, and refusing to rank them would hide the backlog
380
+ * behind a file. The version is then reported for what it is — unknown — and the tiers
381
+ * fall back to their documented default, which is a value a reader can look up.
382
+ * @param {object} options
383
+ * @param {string | undefined} options.file
384
+ * @param {string} options.cwd
385
+ * @returns {Promise<{ file: string, version: number | undefined, deadlineTiers: DeadlineTier[] }>}
386
+ */
387
+ async function readStrategy({ file, cwd }) {
388
+ const path = await resolveStrategyPath({ ...(file === undefined ? {} : { file }), cwd });
389
+ const text = await readFile(path, 'utf8').catch(() => undefined);
390
+ if (text === undefined) {
391
+ return { file: path, version: undefined, deadlineTiers: [...DEFAULT_DEADLINE_TIERS] };
392
+ }
393
+ const strategy = validateStrategy(text);
394
+ const tiers = parseDeadlineTiers(text);
395
+ return {
396
+ file: path,
397
+ version: strategy.ok ? strategy.version : undefined,
398
+ deadlineTiers: tiers.ok ? tiers.tiers : [...DEFAULT_DEADLINE_TIERS],
399
+ };
400
+ }
401
+
402
+ // ─── Scopes ───────────────────────────────────────────────────────────────────
403
+
404
+ /**
405
+ * One scope's half of the report: what it contributed, or why it contributed nothing.
406
+ * @typedef {object} ScopeRead
407
+ * @property {Scope} scope
408
+ * @property {'available' | 'unavailable' | 'withheld'} status
409
+ * @property {string | undefined} reason
410
+ * @property {Candidate[]} candidates
411
+ * @property {number} excludedIdeas
412
+ * @property {number} excludedDeferred
413
+ */
414
+
415
+ /**
416
+ * Read every configured scope.
417
+ *
418
+ * Two scopes never reach the tracker: a scope whose workspace is missing or uninitialised
419
+ * is `unavailable` with the reason, and a personal scope is `withheld` in a shared
420
+ * invocation — not read, so nothing personal is even loaded, let alone printed. A bd call
421
+ * that fails marks its own scope unavailable and leaves the other scopes alone. A refused
422
+ * write is the one error that propagates: it is a defect in this script, not in the machine.
423
+ *
424
+ * @param {object} options
425
+ * @param {ScopedRunner} options.bd
426
+ * @param {Scope[]} options.scopes
427
+ * @param {boolean} options.includeIdeas
428
+ * @param {number} options.now Epoch milliseconds the deferral is judged against.
429
+ * @param {boolean} options.shared
430
+ * @returns {Promise<ScopeRead[]>}
431
+ */
432
+ async function readScopes({ bd, scopes, includeIdeas, now, shared }) {
433
+ /** @type {ScopeRead[]} */
434
+ const reads = [];
435
+
436
+ for (const scope of scopes) {
437
+ /** @type {ScopeRead} */
438
+ const empty = { scope, status: 'unavailable', reason: undefined, candidates: [], excludedIdeas: 0, excludedDeferred: 0 };
439
+
440
+ if (shared && scope.kind === PERSONAL_KIND) {
441
+ reads.push({ ...empty, status: 'withheld', reason: 'personal scope, withheld in a shared invocation' });
442
+ continue;
443
+ }
444
+
445
+ const reason = await unavailableReason(scope);
446
+ if (reason !== undefined) {
447
+ reads.push({ ...empty, reason });
448
+ continue;
449
+ }
450
+
451
+ try {
452
+ const read = await readCandidates({ bd: guardRunner(scope, bd), scope: scope.name, includeIdeas, now });
453
+ reads.push({ ...empty, ...read, status: 'available' });
454
+ } catch (error) {
455
+ if (error instanceof ScopeWriteError) throw error;
456
+ reads.push({ ...empty, reason: `bd failed: ${errorMessage(error)}` });
457
+ }
458
+ }
459
+
460
+ return reads;
461
+ }
462
+
463
+ // ─── Queue ────────────────────────────────────────────────────────────────────
464
+
465
+ /**
466
+ * One scope as the report publishes it.
467
+ *
468
+ * A withheld scope publishes no `name` and no `path`. Both identify the workspace a shared
469
+ * invocation is not allowed to see: a path encodes the home directory, and a name is
470
+ * whatever the owner called that workspace, so publishing either one while claiming the
471
+ * scope is withheld is a partial disclosure. `kind`, `readOnly` and `writable` stay,
472
+ * because the withholding rule keys on `kind` and the header already counts the withheld
473
+ * scopes — those disclose only that a personal scope exists, which the count says anyway.
474
+ * @param {ScopeRead} read @returns {ScopeStatus}
475
+ */
476
+ function publishedScope(read) {
477
+ const withheld = read.status === 'withheld';
478
+ return {
479
+ name: withheld ? null : read.scope.name,
480
+ kind: read.scope.kind,
481
+ readOnly: read.scope.readOnly,
482
+ writable: read.scope.writable,
483
+ path: withheld ? null : read.scope.path,
484
+ status: read.status,
485
+ reason: read.reason,
486
+ };
487
+ }
488
+
489
+ /**
490
+ * Build the queue: read every scope, derive each row, then order them.
491
+ *
492
+ * The order is total (desc), then stored score (desc), then scope name, then id (both
493
+ * asc). A tie is reachable because the total is rounded and because the logarithm
494
+ * flattens: `ln` maps different dependent counts onto the same one-decimal total. The
495
+ * tie-break is fixed, and the scope name breaks it before the id because two workspaces
496
+ * may hold the same bead id, so two runs over one backlog print byte-identical output — a
497
+ * queue that reordered itself between reads could not be trusted to say what to do first.
498
+ *
499
+ * An unscored row keeps its place in the list, after the ranked rows, and carries the
500
+ * reason. Nothing is dropped silently and no score is invented: the gap is the answer.
501
+ *
502
+ * @param {object} options
503
+ * @param {ScopedRunner} [options.bd] The tracker; defaults to one bd call per scope.
504
+ * @param {string | undefined} [options.file] Strategy path; defaults to the documented default.
505
+ * @param {string | undefined} [options.scopesFile] Scope list; defaults to the documented default.
506
+ * @param {string} [options.cwd]
507
+ * @param {boolean} [options.includeIdeas]
508
+ * @param {number} [options.now] Epoch milliseconds the deadlines are measured from.
509
+ * Injectable so a tier boundary test is deterministic.
510
+ * @param {Scope[]} [options.scopes] An already-resolved scope list, for a caller.
511
+ * @param {boolean} [options.shared] Output that will leave this machine.
512
+ * @returns {Promise<QueueReport>}
513
+ */
514
+ export async function buildQueue({
515
+ bd,
516
+ file,
517
+ scopesFile,
518
+ cwd = process.cwd(),
519
+ includeIdeas = false,
520
+ scopes,
521
+ shared = false,
522
+ now = Date.now(),
523
+ }) {
524
+ const runBd = bd ?? (async (args, scope) => await makeRunner(BD_PATH, scope.path)(args));
525
+ const scopeList = scopes ??
526
+ (await readScopeConfig({ cwd, ...(scopesFile === undefined ? {} : { file: scopesFile }) })).scopes;
527
+
528
+ const [strategy, reads, today] = await Promise.all([
529
+ readStrategy({ file, cwd }),
530
+ readScopes({ bd: runBd, scopes: scopeList, includeIdeas, shared, now }),
531
+ shared ? Promise.resolve({ ...emptyNote('withheld in a shared invocation'), withheld: true }) : readDailyNote(scopeList),
532
+
533
+ ]);
534
+
535
+ /** @type {Map<string, ReturnType<typeof createDependentReader>>} */
536
+ const readers = new Map();
537
+ for (const read of reads) {
538
+ if (read.status !== 'available') continue;
539
+ readers.set(read.scope.name, createDependentReader(guardRunner(read.scope, runBd)));
540
+ }
541
+
542
+ const candidates = reads.flatMap((read) => read.candidates);
543
+ const excludedIdeas = reads.reduce((total, read) => total + read.excludedIdeas, 0);
544
+ const excludedDeferred = reads.reduce((total, read) => total + read.excludedDeferred, 0);
545
+
546
+ /** @type {QueueRow[]} */
547
+ const rows = [];
548
+ for (const candidate of candidates) {
549
+ const dependentsOf = /** @type {ReturnType<typeof createDependentReader>} */ (readers.get(candidate.scope));
550
+ const dependents = await dependentsOf(candidate.id);
551
+ const live = [...dependents.values()]
552
+ .filter((dependent) => dependent.status !== CLOSED_STATUS)
553
+ .map((dependent) => dependent.id)
554
+ .sort();
555
+ const inProgress = candidate.status === IN_PROGRESS_STATUS;
556
+ const score = candidate.rice?.score;
557
+ const remainingMs = candidate.dueAt === undefined ? undefined : candidate.dueAt - now;
558
+ const deadline = remainingMs === undefined
559
+ ? { factor: 1, overdue: false }
560
+ : deadlineMultiplier(remainingMs, strategy.deadlineTiers);
561
+ const scoredAtVersion = candidate.rice?.strategyVersion;
562
+ const stale =
563
+ score !== undefined &&
564
+ scoredAtVersion !== undefined &&
565
+ strategy.version !== undefined &&
566
+ scoredAtVersion !== strategy.version;
567
+
568
+ rows.push({
569
+ rank: undefined,
570
+ id: candidate.id,
571
+ scope: candidate.scope,
572
+ title: candidate.title.replace(/\s+/g, ' ').trim(),
573
+ status: candidate.status,
574
+ inProgress,
575
+ score,
576
+ rice: candidate.rice,
577
+ dependents: live.length,
578
+ dependentIds: live,
579
+ dependentMultiplier: dependentMultiplier(live.length),
580
+ wipMultiplier: inProgress ? WIP_MULTIPLIER : 1,
581
+ dueAt: candidate.dueAt,
582
+ remainingMs,
583
+ deadlineFactor: deadline.factor,
584
+ overdue: deadline.overdue,
585
+ total: score === undefined
586
+ ? undefined
587
+ : queueTotal({ score, dependents: live.length, inProgress, deadline: deadline.factor }),
588
+ scoredAtVersion,
589
+ stale,
590
+ unscoredReason: score === undefined
591
+ ? candidate.rice === undefined
592
+ ? `no ${RICE_KEY} metadata`
593
+ : `no score in its ${RICE_KEY} record`
594
+ : undefined,
595
+ });
596
+ }
597
+
598
+ const ranked = rows
599
+ .filter((row) => row.total !== undefined)
600
+ .sort((a, b) => {
601
+ const byTotal = /** @type {number} */ (b.total) - /** @type {number} */ (a.total);
602
+ if (byTotal !== 0) return byTotal;
603
+ const byScore = /** @type {number} */ (b.score) - /** @type {number} */ (a.score);
604
+ if (byScore !== 0) return byScore;
605
+ return compareScopeThenId(a, b);
606
+ });
607
+ ranked.forEach((row, index) => {
608
+ row.rank = index + 1;
609
+ });
610
+
611
+ const unscored = rows
612
+ .filter((row) => row.total === undefined)
613
+ .sort(compareScopeThenId);
614
+
615
+ const available = reads.filter((read) => read.status === 'available').length;
616
+
617
+ return {
618
+ strategy,
619
+ invocation: shared ? 'shared' : 'local',
620
+ scopes: reads.map(publishedScope),
621
+ today,
622
+ counts: {
623
+ ranked: ranked.length,
624
+ unscored: unscored.length,
625
+ excludedIdeas,
626
+ excludedDeferred,
627
+ availableScopes: available,
628
+ unavailableScopes: reads.filter((read) => read.status === 'unavailable').length,
629
+ withheldScopes: reads.filter((read) => read.status === 'withheld').length,
630
+ },
631
+ rows: [...ranked, ...unscored],
632
+ };
633
+ }
634
+
635
+ /**
636
+ * The tie-break two workspaces both reach: the scope name first, because two workspaces may
637
+ * hold the same bead id, then the id. Stable and total, so one backlog prints one order.
638
+ * @param {QueueRow} a @param {QueueRow} b @returns {number}
639
+ */
640
+ function compareScopeThenId(a, b) {
641
+ if (a.scope !== b.scope) return a.scope < b.scope ? -1 : 1;
642
+ return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
643
+ }
644
+
645
+ // ─── Render ───────────────────────────────────────────────────────────────────
646
+
647
+ /** @param {number} count @param {string} noun @returns {string} */
648
+ function plural(count, noun) {
649
+ return `${count} ${noun}${count === 1 ? '' : 's'}`;
650
+ }
651
+
652
+ /** @param {string} text @param {number} width @returns {string} */
653
+ function right(text, width) {
654
+ return text.padStart(width);
655
+ }
656
+
657
+ /**
658
+ * The components as the score record holds them, so a reader can check the arithmetic
659
+ * without opening the bead. Exported so the one-item recommendation prints the same
660
+ * breakdown rather than a second copy of it that could drift.
661
+ * @param {StoredRice} rice @returns {string}
662
+ */
663
+ export function formatComponents(rice) {
664
+ const part = (/** @type {number | undefined} */ value, /** @type {string} */ letter) =>
665
+ `${letter}${value === undefined ? '?' : value}`;
666
+ return `${part(rice.reach, 'R')} · ${part(rice.impact, 'I')} · ${part(rice.confidence, 'C')} · ${part(rice.effort, 'E')}`;
667
+ }
668
+
669
+ /**
670
+ * A span of milliseconds as the largest units that fit, so a row reads `2d 23h` instead of
671
+ * a count of milliseconds. Rounded down: the row shows the time left, never a figure the
672
+ * deadline has not reached.
673
+ * @param {number} ms @returns {string}
674
+ */
675
+ function formatSpan(ms) {
676
+ const totalMinutes = Math.floor(Math.abs(ms) / MS_PER_MINUTE);
677
+ const days = Math.floor(totalMinutes / 1440);
678
+ const hours = Math.floor((totalMinutes % 1440) / 60);
679
+ const parts = [];
680
+ if (days > 0) parts.push(`${days}d`);
681
+ if (hours > 0) parts.push(`${hours}h`);
682
+ if (parts.length === 0) parts.push(`${totalMinutes % 60}m`);
683
+ return parts.join(' ');
684
+ }
685
+
686
+ /**
687
+ * The due moment in UTC, to the minute: a fixed form, so two runs print it identically.
688
+ * @param {number} instant @returns {string}
689
+ */
690
+ function formatInstant(instant) {
691
+ return `${new Date(instant).toISOString().slice(0, 16)}Z`;
692
+ }
693
+
694
+ /**
695
+ * The distance and the due moment, with the word `OVERDUE` once the moment has passed.
696
+ * Empty for a bead with no deadline. No factor here: the scored clause adds one.
697
+ * @param {QueueRow} row @returns {string}
698
+ */
699
+ function dueClause(row) {
700
+ if (row.dueAt === undefined || row.remainingMs === undefined) return '';
701
+ const due = formatInstant(row.dueAt);
702
+ const span = formatSpan(row.remainingMs);
703
+ return row.overdue ? `OVERDUE by ${span} (due ${due})` : `in ${span} (due ${due})`;
704
+ }
705
+
706
+ /**
707
+ * The deadline as one clause: the factor and the due clause. Empty for a bead with no
708
+ * deadline, which keeps the factor 1 and is not ranked down for lacking one.
709
+ * @param {QueueRow} row @returns {string}
710
+ */
711
+ function deadlineClause(row) {
712
+ const due = dueClause(row);
713
+ return due === '' ? '' : ` deadline ×${row.deadlineFactor} ${due}`;
714
+ }
715
+
716
+ /**
717
+ * The same moment for a row with no total. An unscored row has no product to scale, so it
718
+ * shows the due moment and the slip and claims no factor — the deadline is still visible.
719
+ * @param {QueueRow} row @returns {string}
720
+ */
721
+ function unscoredDueClause(row) {
722
+ const due = dueClause(row);
723
+ if (due === '') return '';
724
+ return row.overdue ? ` ${due}` : ` due ${due}`;
725
+ }
726
+
727
+ /**
728
+ * What the answer unblocks, in one clause: named ids while they fit, a count after that.
729
+ * @param {QueueRow} row @returns {string}
730
+ */
731
+ export function formatUnblocks(row) {
732
+ if (row.dependentIds.length === 0) return 'unblocks nothing';
733
+ const named = row.dependentIds.slice(0, NAMED_DEPENDENTS);
734
+ const rest = row.dependentIds.length - named.length;
735
+ return `unblocks ${named.join(', ')}${rest > 0 ? ` +${rest} more` : ''}`;
736
+ }
737
+
738
+ /**
739
+ * The scope summary for the header: how many scopes the run had, and how many it could not
740
+ * read. A scope that could not be read is counted in the open, never quietly dropped.
741
+ * @param {QueueReport['counts']} counts @returns {string}
742
+ */
743
+ function scopeSummary(counts) {
744
+ const total = counts.availableScopes + counts.unavailableScopes + counts.withheldScopes;
745
+ const parts = [plural(total, 'scope')];
746
+ if (counts.unavailableScopes > 0) parts.push(`${counts.unavailableScopes} unavailable`);
747
+ if (counts.withheldScopes > 0) parts.push(`${counts.withheldScopes} withheld`);
748
+ return parts.join(', ');
749
+ }
750
+
751
+ /**
752
+ * The daily scope note, above the list: which scopes the day belongs to, and the strategy
753
+ * line that says so. A shared invocation withholds it, because the quote is personal
754
+ * strategy text; nothing personal is read, let alone printed, off this machine.
755
+ * @param {DailyNote} today @returns {string}
756
+ */
757
+ export function renderToday(today) {
758
+ if (today.withheld === true) return 'today · withheld (shared invocation) — personal strategy text';
759
+ if (today.reason !== undefined) return `today · no daily note — ${today.reason}`;
760
+
761
+ const names = today.scopes.length === 0 ? 'unnamed scopes' : today.scopes.join(', ');
762
+ const version = today.strategyVersion === undefined ? 'strategy unknown' : `strategy v${today.strategyVersion}`;
763
+ const section = today.section === undefined ? '' : `, "## ${today.section}"`;
764
+ return `today · ${names} — "${today.line}" (${version}${section}, scope ${today.scopeName})`;
765
+ }
766
+
767
+ /**
768
+ * The scopes that contributed nothing, one clause each, so a missing personal workspace is
769
+ * visible instead of looking like an empty backlog.
770
+ * @param {QueueReport} report @returns {string}
771
+ */
772
+ function unavailableLine(report) {
773
+ const quiet = report.scopes.filter((scope) => scope.status === 'unavailable');
774
+ if (quiet.length === 0) return '';
775
+ return `unavailable: ${quiet.map((scope) => `${scope.name} (${scope.path}) — ${scope.reason ?? 'unknown'}`).join('; ')}\n`;
776
+ }
777
+
778
+ /**
779
+ * Render the queue as text: a header naming the formula and the scopes, the daily scope
780
+ * note, then one block per row — its scope tag and numbers, then its title and what the
781
+ * answer unblocks.
782
+ * @param {QueueReport} report @returns {string}
783
+ */
784
+ export function renderQueue(report) {
785
+ const { counts, strategy, rows, today } = report;
786
+ const strategyLabel = strategy.version === undefined ? 'strategy unknown' : `strategy v${strategy.version}`;
787
+ const scopes = scopeSummary(counts);
788
+
789
+ const head = rows.length === 0
790
+ ? [`HITL decision queue · no open ${HITL_LABEL} beads · ${scopes}`, renderToday(today)]
791
+ : [
792
+ `HITL decision queue · ${counts.ranked} ranked, ${counts.unscored} unscored · ${scopes} · ${strategyLabel}`,
793
+ 'total = rice × (1 + ln(1 + dependents)) × 1.5 in-progress × deadline tier · ties break on rice, then scope, then id · unscored last',
794
+ renderToday(today),
795
+ ];
796
+
797
+ const excluded = formatExcluded(counts);
798
+ const unavailable = unavailableLine(report);
799
+ const hasNoScore = counts.ranked === 0 && rows.length > 0;
800
+ for (const line of [excluded, unavailable]) if (line !== '') head.push(line.trimEnd());
801
+ if (hasNoScore) {
802
+ head.push('no RICE score on any candidate — nothing can be ranked; score one with score-bead, then re-run');
803
+ }
804
+ if (rows.length === 0) return `${head.join('\n')}\n`;
805
+
806
+ const rankWidth = Math.max(1, String(counts.ranked).length);
807
+ const scopeWidth = Math.max(...rows.map((row) => row.scope.length));
808
+ const idWidth = Math.max(...rows.map((row) => row.id.length));
809
+ const totalWidth = Math.max(...rows.map((row) => (row.total === undefined ? 1 : row.total.toFixed(1).length)));
810
+
811
+ const lines = [...head, ''];
812
+
813
+ for (const row of rows) {
814
+ const rank = row.rank === undefined ? '-' : String(row.rank);
815
+ const total = row.total === undefined ? '—' : row.total.toFixed(1);
816
+ const numbers = row.score === undefined
817
+ ? `unscored — ${row.unscoredReason ?? 'no score'} (score it with score-bead)` +
818
+ unscoredDueClause(row)
819
+ : formatNumbers(row);
820
+ const stale = row.stale ? ` (scored at strategy v${row.scoredAtVersion}, current v${strategy.version})` : '';
821
+ lines.push(
822
+ `${right(rank, rankWidth)} ${row.scope.padEnd(scopeWidth)} ${row.id.padEnd(idWidth)} ` +
823
+ `${right(total, totalWidth)} ${numbers}${stale}`,
824
+ );
825
+ lines.push(` ${row.title} — ${formatUnblocks(row)}`);
826
+ }
827
+
828
+ return `${lines.join('\n')}\n`;
829
+ }
830
+
831
+ /**
832
+ * The score line of one row: the stored RICE total, the four components it was computed
833
+ * from, and both multipliers. Exported so the one-item recommendation prints the queue's
834
+ * own numbers instead of deriving them a second time.
835
+ * @param {QueueRow} row @returns {string}
836
+ */
837
+ export function formatNumbers(row) {
838
+ return `rice ${/** @type {number} */ (row.score).toFixed(1)} = ${formatComponents(/** @type {StoredRice} */ (row.rice))}` +
839
+ ` ×${row.dependentMultiplier.toFixed(2)} dependents(${row.dependents})` +
840
+ ` ×${row.wipMultiplier.toFixed(1)}${row.inProgress ? ' in-progress' : ''}` +
841
+ deadlineClause(row);
842
+ }
843
+
844
+ /**
845
+ * The excluded counts, so nothing leaves the list without a trace.
846
+ * @param {QueueReport['counts']} counts @returns {string}
847
+ */
848
+ export function formatExcluded(counts) {
849
+ if (counts.excludedIdeas === 0 && counts.excludedDeferred === 0) return '';
850
+ const parts = [];
851
+ if (counts.excludedIdeas > 0) {
852
+ parts.push(`${plural(counts.excludedIdeas, IDEA_LABEL + ' bead')} (--include-ideas ranks them)`);
853
+ }
854
+ if (counts.excludedDeferred > 0) {
855
+ parts.push(`${plural(counts.excludedDeferred, DEFERRED_STATUS + ' bead')}`);
856
+ }
857
+ return `excluded: ${parts.join('; ')}\n`;
858
+ }
859
+
860
+ // ─── CLI ──────────────────────────────────────────────────────────────────────
861
+
862
+ /** @type {readonly string[]} */
863
+ export const COMMANDS = /** @type {const} */ (['queue']);
864
+
865
+ const USAGE = `Usage: node queue.mjs queue [--scopes <path>] [--shared] [--file <path>]
866
+ [--include-ideas] [--cwd <dir>] [--json]
867
+
868
+ queue Rank the open ${HITL_LABEL} beads from every configured scope into one list:
869
+ RICE × (1 + ln(1 + dependents)) × 1.5 when in progress, × the strategy's
870
+ deadline factor at the bead's --due. Each row carries the scope it came from.
871
+ A bead with no score is listed as unscored, never guessed.
872
+
873
+ Options:
874
+ --scopes <path> Scope list (default: ${DEFAULT_SCOPES_FILE} from the repository root,
875
+ then ~/.agent-cortex/scopes.json, then the current workspace alone)
876
+ --shared Output that leaves this machine: personal scopes are not read
877
+ --file <path> Strategy file (default: ${DEFAULT_FILE}, relative to the repository root)
878
+ --include-ideas Rank ${IDEA_LABEL} beads too (they are excluded by default)
879
+ --cwd <dir> Directory the strategy, scope paths and bd resolve from
880
+ --json Print the report as JSON
881
+ -h, --help Show this text
882
+
883
+ Environment:
884
+ BD_PATH bd binary (default: bd)
885
+ ${HOME_ENV} Install root the user-level scope list is read from
886
+ ${SHARED_ENV} Set to 1 to run as a shared invocation
887
+ `;
888
+
889
+ /**
890
+ * Parse the CLI's argv.
891
+ * @param {string[]} argv
892
+ * @returns {{ command: string, file?: string, scopesFile?: string, includeIdeas: boolean, shared: boolean, cwd: string, json: boolean }}
893
+ */
894
+ export function parseArgs(argv) {
895
+ /** @type {string | undefined} */
896
+ let command;
897
+ /** @type {string | undefined} */
898
+ let file;
899
+ /** @type {string | undefined} */
900
+ let scopesFile;
901
+ let cwd = process.cwd();
902
+ let json = false;
903
+ let includeIdeas = false;
904
+ let shared = false;
905
+
906
+ for (let i = 0; i < argv.length; i++) {
907
+ const arg = argv[i];
908
+ if (arg === '--json') {
909
+ json = true;
910
+ continue;
911
+ }
912
+ if (arg === '--include-ideas') {
913
+ includeIdeas = true;
914
+ continue;
915
+ }
916
+ if (arg === '--shared') {
917
+ shared = true;
918
+ continue;
919
+ }
920
+ if (arg === '--file' || arg === '--cwd' || arg === '--scopes') {
921
+ const value = argv[++i];
922
+ if (value === undefined) throw new UsageError(`${arg} needs a value`);
923
+ if (arg === '--cwd') cwd = value;
924
+ else if (arg === '--scopes') scopesFile = value;
925
+ else file = value;
926
+ continue;
927
+ }
928
+ if (arg.startsWith('-')) throw new UsageError(`unexpected argument: ${arg}`);
929
+ if (command === undefined) {
930
+ if (!COMMANDS.includes(arg)) throw new UsageError(`unknown command "${arg}"`);
931
+ command = arg;
932
+ continue;
933
+ }
934
+ throw new UsageError(`unexpected argument: ${arg}`);
935
+ }
936
+
937
+ if (command === undefined) throw new UsageError(`a command is required: ${COMMANDS.join(', ')}`);
938
+ return {
939
+ command,
940
+ ...(file === undefined ? {} : { file }),
941
+ ...(scopesFile === undefined ? {} : { scopesFile }),
942
+ includeIdeas,
943
+ shared,
944
+ cwd,
945
+ json,
946
+ };
947
+ }
948
+
949
+ /**
950
+ * @param {object} options
951
+ * @param {string[]} options.argv CLI arguments (without node/script).
952
+ * @param {(chunk: string) => void} [options.write] stdout sink; injectable for tests.
953
+ * @param {(chunk: string) => void} [options.writeError] stderr sink; injectable for tests.
954
+ * @param {{ bd?: ScopedRunner }} [options.deps] Collaborators; injectable for tests.
955
+ * @returns {Promise<number>} process exit code.
956
+ */
957
+ export async function runCli({
958
+ argv,
959
+ write = (chunk) => process.stdout.write(chunk),
960
+ writeError = (chunk) => process.stderr.write(chunk),
961
+ deps = {},
962
+ }) {
963
+ if (argv.includes('--help') || argv.includes('-h')) {
964
+ write(USAGE);
965
+ return 0;
966
+ }
967
+
968
+ let options;
969
+ try {
970
+ options = parseArgs(argv);
971
+ } catch (error) {
972
+ writeError(`queue: ${errorMessage(error)}\n\n${USAGE}`);
973
+ return 2;
974
+ }
975
+
976
+ const bd = deps.bd ?? (async (args, scope) => await makeRunner(BD_PATH, scope.path)(args));
977
+ const shared = options.shared || isTruthy(process.env[SHARED_ENV]);
978
+
979
+ try {
980
+ const report = await buildQueue({
981
+ bd,
982
+ cwd: options.cwd,
983
+ includeIdeas: options.includeIdeas,
984
+ shared,
985
+ ...(options.file === undefined ? {} : { file: options.file }),
986
+ ...(options.scopesFile === undefined ? {} : { scopesFile: options.scopesFile }),
987
+ });
988
+ write(options.json ? `${JSON.stringify(report, null, 2)}\n` : renderQueue(report));
989
+ return 0;
990
+ } catch (error) {
991
+ // A scope list that cannot be read is the caller's mistake; anything else is a defect.
992
+ writeError(`queue failed: ${errorMessage(error)}\n`);
993
+ return error instanceof ScopeError ? 2 : 1;
994
+ }
995
+ }
996
+
997
+ /** @param {string | undefined} value @returns {boolean} */
998
+ function isTruthy(value) {
999
+ return value !== undefined && ['1', 'true', 'yes'].includes(value.trim().toLowerCase());
1000
+ }
1001
+
1002
+ // Only run the CLI when invoked directly, so tests can import the module.
1003
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
1004
+ runCli({ argv: process.argv.slice(2) })
1005
+ .then((code) => {
1006
+ process.exitCode = code;
1007
+ })
1008
+ .catch((error) => {
1009
+ process.stderr.write(`${errorMessage(error)}\n`);
1010
+ process.exitCode = 1;
1011
+ });
1012
+ }