@jaybeeuu/agent-cortex 2.8.0 → 2.9.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 (41) hide show
  1. package/CHANGELOG.md +173 -0
  2. package/README.md +26 -3
  3. package/agents/README.md +2 -2
  4. package/agents/guppi/agent.md +162 -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 +1 -20
  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/manifest.mjs +29 -0
  23. package/skills/review/review-defects/EXAMPLES.md +80 -4
  24. package/skills/review/review-defects/SKILL.md +45 -15
  25. package/skills/review/review-report/EXAMPLES.md +114 -0
  26. package/skills/review/review-report/SKILL.md +138 -0
  27. package/skills/review/review-report/scripts/report.mjs +1032 -0
  28. package/skills/workflow/hitl-queue/SKILL.md +134 -0
  29. package/skills/workflow/hitl-queue/scripts/queue.mjs +626 -0
  30. package/skills/workflow/plan/SKILL.md +13 -0
  31. package/skills/workflow/prioritise-work/CHALLENGE.md +139 -0
  32. package/skills/workflow/prioritise-work/SKILL.md +150 -0
  33. package/skills/workflow/prioritise-work/scripts/challenge.mjs +505 -0
  34. package/skills/workflow/prioritise-work/scripts/strategy.mjs +566 -0
  35. package/skills/workflow/prioritise-work/templates/strategy.md +61 -0
  36. package/skills/workflow/run-pipeline-stage/playbooks/review.md +5 -2
  37. package/skills/workflow/score-bead/FORMAT.md +73 -0
  38. package/skills/workflow/score-bead/SKILL.md +112 -0
  39. package/skills/workflow/score-bead/scripts/rice.mjs +664 -0
  40. package/skills/workflow/using-agent-skills/SKILL.md +6 -1
  41. package/token-map.json +8 -2
@@ -0,0 +1,626 @@
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 [--file <strategy>] [--include-ideas] [--cwd <dir>] [--json]
6
+ //
7
+ // One multiplier does the load-bearing work: a bead that unblocks three others outranks
8
+ // an otherwise identical bead that unblocks none, and the count is **transitive** — a
9
+ // bead blocking A, which blocks B, which blocks C, unblocks three. The walk collects
10
+ // distinct beads, never paths, so a diamond (one bead reached by two routes) counts once.
11
+ //
12
+ // The RICE score is read from the bead's own metadata, written by the score-bead skill.
13
+ // This script never recomputes it and never invents one: a bead with no score is listed
14
+ // as `unscored` with the reason, because a queue that guessed would hide the gap.
15
+ //
16
+ // Zero runtime dependencies, async fs only, every child process with an argv array.
17
+ // `skills/*/scripts` sits outside the check-no-sync scan; this script stays async anyway,
18
+ // so the rule holds everywhere. BD_PATH pins a bd binary the way the score script does.
19
+ //
20
+ // Exit codes: 0 the queue was built, 1 the tracker call failed, 2 the caller asked for
21
+ // something malformed.
22
+
23
+ import { readFile } from 'node:fs/promises';
24
+ import { pathToFileURL } from 'node:url';
25
+
26
+ import { DEFAULT_FILE, resolveStrategyPath, validateStrategy } from '../../prioritise-work/scripts/strategy.mjs';
27
+ import { BD_PATH, RICE_KEY, errorMessage, makeRunner, storedRice } from '../../score-bead/scripts/rice.mjs';
28
+
29
+ /** @typedef {import('../../score-bead/scripts/rice.mjs').StoredRice} StoredRice */
30
+ /** @typedef {(args: string[]) => Promise<string>} Runner */
31
+
32
+ // ─── Contract ─────────────────────────────────────────────────────────────────
33
+
34
+ /** The label that marks a bead as human-required. The queue is that label's backlog. */
35
+ export const HITL_LABEL = 'execution:hitl';
36
+
37
+ /** Ideas are HITL by convention, and ranked only when asked for. */
38
+ export const IDEA_LABEL = 'kind:idea';
39
+
40
+ /** The status that says work has started, and so earns the work-in-progress multiplier. */
41
+ export const IN_PROGRESS_STATUS = 'in_progress';
42
+
43
+ /** A bead the owner has parked. Deferring is an answer, so the queue leaves it out. */
44
+ export const DEFERRED_STATUS = 'deferred';
45
+
46
+ /** Work already finished cannot be unblocked, so it never counts toward the multiplier. */
47
+ export const CLOSED_STATUS = 'closed';
48
+
49
+ /** An in-progress bead outranks an equal not-started one: the queue drains WIP first. */
50
+ export const WIP_MULTIPLIER = 1.5;
51
+
52
+ /** The dependency type that blocks. A "related" edge is not an answer anyone waits on. */
53
+ export const BLOCKS_TYPE = 'blocks';
54
+
55
+ /** How many dependent ids a row names before it summarises the rest as a count. */
56
+ const NAMED_DEPENDENTS = 5;
57
+
58
+ /** A usage mistake: the caller asked for something malformed. Exit code 2. */
59
+ export class UsageError extends Error {}
60
+
61
+ /** The run refused: the tracker printed something this script cannot read. Exit code 1. */
62
+ export class QueueError extends Error {}
63
+
64
+ // ─── Types ────────────────────────────────────────────────────────────────────
65
+
66
+ /**
67
+ * One bead as `bd list` reports it.
68
+ * @typedef {object} Candidate
69
+ * @property {string} id
70
+ * @property {string} title
71
+ * @property {string} status
72
+ * @property {StoredRice | undefined} rice
73
+ */
74
+
75
+ /**
76
+ * One line of the queue: a candidate plus everything the ranking derived from it.
77
+ * @typedef {object} QueueRow
78
+ * @property {number | undefined} rank Undefined for an unscored row.
79
+ * @property {string} id
80
+ * @property {string} title
81
+ * @property {string} status
82
+ * @property {boolean} inProgress
83
+ * @property {number | undefined} score The stored RICE total, when there is one.
84
+ * @property {StoredRice | undefined} rice
85
+ * @property {number} dependents Distinct transitive dependents, unclosed.
86
+ * @property {string[]} dependentIds Those dependents, sorted, closed excluded.
87
+ * @property {number} dependentMultiplier 1 + ln(1 + dependents).
88
+ * @property {number} wipMultiplier WIP_MULTIPLIER, or 1.
89
+ * @property {number | undefined} total The rounded ranking key; undefined if unscored.
90
+ * @property {number | undefined} scoredAtVersion
91
+ * @property {boolean} stale Scored against an older strategy version.
92
+ * @property {string | undefined} unscoredReason Why this bead holds no rank.
93
+ */
94
+
95
+ /**
96
+ * The whole answer.
97
+ * @typedef {object} QueueReport
98
+ * @property {{ file: string, version: number | undefined }} strategy
99
+ * @property {{ ranked: number, unscored: number, excludedIdeas: number, excludedDeferred: number }} counts
100
+ * @property {QueueRow[]} rows Ranked rows first, then unscored rows.
101
+ */
102
+
103
+ // ─── Arithmetic ───────────────────────────────────────────────────────────────
104
+
105
+ /** @param {number} value @returns {number} */
106
+ function round1(value) {
107
+ return Math.round(value * 10) / 10;
108
+ }
109
+
110
+ /**
111
+ * How much one answer is worth in dependents: 1 + ln(1 + n). Zero dependents scores 1,
112
+ * so a bead that unblocks nothing is not penalised — it is simply not lifted. The curve
113
+ * is logarithmic so unblocking thirty chores does not drown the queue under one row.
114
+ * @param {number} count Distinct transitively blocked beads.
115
+ * @returns {number}
116
+ */
117
+ export function dependentMultiplier(count) {
118
+ return 1 + Math.log1p(count);
119
+ }
120
+
121
+ /**
122
+ * The ranking key: RICE × dependent multiplier × work-in-progress multiplier, rounded to
123
+ * one decimal. The rounded figure is the sort key as well as the printed one, so the
124
+ * number a reader sees is the number the order was decided by.
125
+ * @param {object} input
126
+ * @param {number} input.score
127
+ * @param {number} input.dependents
128
+ * @param {boolean} input.inProgress
129
+ * @returns {number}
130
+ */
131
+ export function queueTotal({ score, dependents, inProgress }) {
132
+ return round1(score * dependentMultiplier(dependents) * (inProgress ? WIP_MULTIPLIER : 1));
133
+ }
134
+
135
+ // ─── Tracker ──────────────────────────────────────────────────────────────────
136
+
137
+ /**
138
+ * @param {string} stdout @param {string} what @returns {unknown[]}
139
+ */
140
+ function parseJsonArray(stdout, what) {
141
+ let parsed;
142
+ try {
143
+ parsed = JSON.parse(stdout);
144
+ } catch {
145
+ throw new QueueError(`${what} did not print JSON`);
146
+ }
147
+ if (!Array.isArray(parsed)) throw new QueueError(`${what} did not print a JSON array`);
148
+ return parsed;
149
+ }
150
+
151
+ /** @param {unknown} value @returns {Record<string, unknown> | undefined} */
152
+ function asRecord(value) {
153
+ return typeof value === 'object' && value !== null ? /** @type {Record<string, unknown>} */ (value) : undefined;
154
+ }
155
+
156
+ /** @param {unknown} value @returns {string | undefined} */
157
+ function asString(value) {
158
+ return typeof value === 'string' ? value : undefined;
159
+ }
160
+
161
+ /**
162
+ * A dependent edge: the bead, and the status it is in, so the count can drop finished work.
163
+ * @typedef {object} Dependent
164
+ * @property {string} id
165
+ * @property {string} status
166
+ */
167
+
168
+ /**
169
+ * Read the dependency graph upwards, once per bead.
170
+ *
171
+ * `bd dep list <id> --direction up` names the beads that depend on `<id>` — the ones an
172
+ * answer unblocks. The walk is transitive: from every dependent it asks again, so a chain
173
+ * of three counts three rather than one. Two properties are deliberate:
174
+ *
175
+ * • **Distinct, not paths.** The result is a map keyed by bead id, so a diamond — one
176
+ * bead reached by two routes — is counted once. Paths would inflate a reconverging
177
+ * graph, and the multiplier would then measure the shape rather than the work.
178
+ * • **Walked through everything, counted only while live.** A closed bead is still a
179
+ * link in the chain, so the walk passes through it; but a closed bead is not work an
180
+ * answer unblocks, so it is left out of the result.
181
+ *
182
+ * Only `blocks` edges count: a "related" edge is not something anyone waits on. Each
183
+ * bead's direct edges are read once and remembered, so a shared subtree is fetched once
184
+ * however many roots reach it.
185
+ *
186
+ * @param {Runner} bd
187
+ * @returns {(beadId: string) => Promise<Map<string, Dependent>>}
188
+ */
189
+ export function createDependentReader(bd) {
190
+ /** @type {Map<string, Promise<Dependent[]>>} */
191
+ const edges = new Map();
192
+
193
+ /** @param {string} beadId @returns {Promise<Dependent[]>} */
194
+ function direct(beadId) {
195
+ const cached = edges.get(beadId);
196
+ if (cached !== undefined) return cached;
197
+ const pending = bd(['dep', 'list', beadId, '--direction', 'up', '--json']).then((stdout) => {
198
+ const rows = parseJsonArray(stdout, `bd dep list ${beadId} --direction up --json`);
199
+ /** @type {Dependent[]} */
200
+ const out = [];
201
+ for (const row of rows) {
202
+ const record = asRecord(row);
203
+ if (record === undefined) continue;
204
+ if (record.dependency_type !== BLOCKS_TYPE) continue;
205
+ const id = asString(record.id);
206
+ if (id === undefined) continue;
207
+ out.push({ id, status: asString(record.status) ?? 'open' });
208
+ }
209
+ return out;
210
+ });
211
+ edges.set(beadId, pending);
212
+ return pending;
213
+ }
214
+
215
+ /**
216
+ * Every bead that depends on `beadId`, directly or through another bead. Level by level
217
+ * so one level's edges are fetched together; the frontier is a set, so a reconverging
218
+ * graph cannot make the walk repeat itself.
219
+ * @param {string} beadId
220
+ * @returns {Promise<Map<string, Dependent>>}
221
+ */
222
+ return async function dependentsOf(beadId) {
223
+ /** @type {Map<string, Dependent>} */
224
+ const found = new Map();
225
+ const seen = new Set([beadId]);
226
+ let frontier = [beadId];
227
+
228
+ while (frontier.length > 0) {
229
+ const level = await Promise.all(frontier.map((id) => direct(id)));
230
+ /** @type {string[]} */
231
+ const next = [];
232
+ for (const list of level) {
233
+ for (const dependent of list) {
234
+ found.set(dependent.id, dependent);
235
+ if (seen.has(dependent.id)) continue;
236
+ seen.add(dependent.id);
237
+ next.push(dependent.id);
238
+ }
239
+ }
240
+ frontier = next;
241
+ }
242
+
243
+ found.delete(beadId);
244
+ return found;
245
+ };
246
+ }
247
+
248
+ /**
249
+ * The candidates: every open bead carrying `execution:hitl`. Closed beads are bd's own
250
+ * default filter; deferred beads are parked by the owner and an idea is unshaped, so both
251
+ * are counted and left out unless asked for.
252
+ * @param {object} options
253
+ * @param {Runner} options.bd
254
+ * @param {boolean} options.includeIdeas
255
+ * @returns {Promise<{ candidates: Candidate[], excludedIdeas: number, excludedDeferred: number }>}
256
+ */
257
+ async function readCandidates({ bd, includeIdeas }) {
258
+ const stdout = await bd(['list', '-l', HITL_LABEL, '--json', '--limit', '0']);
259
+ const rows = parseJsonArray(stdout, `bd list -l ${HITL_LABEL} --json`);
260
+
261
+ /** @type {Candidate[]} */
262
+ const candidates = [];
263
+ let excludedIdeas = 0;
264
+ let excludedDeferred = 0;
265
+
266
+ for (const row of rows) {
267
+ const record = asRecord(row);
268
+ if (record === undefined) continue;
269
+ const id = asString(record.id);
270
+ if (id === undefined) continue;
271
+ const status = asString(record.status) ?? 'open';
272
+ if (status === CLOSED_STATUS) continue;
273
+ const labels = Array.isArray(record.labels) ? record.labels.filter((label) => typeof label === 'string') : [];
274
+ if (!includeIdeas && labels.includes(IDEA_LABEL)) {
275
+ excludedIdeas += 1;
276
+ continue;
277
+ }
278
+ if (status === DEFERRED_STATUS) {
279
+ excludedDeferred += 1;
280
+ continue;
281
+ }
282
+ candidates.push({
283
+ id,
284
+ title: asString(record.title) ?? id,
285
+ status,
286
+ rice: storedRice(record.metadata),
287
+ });
288
+ }
289
+
290
+ return { candidates, excludedIdeas, excludedDeferred };
291
+ }
292
+
293
+ /**
294
+ * The strategy version a score is compared against. A missing or malformed strategy does
295
+ * not stop the queue: the scores are already on the beads, and refusing to rank them
296
+ * would hide the backlog behind a file. The version is reported for what it is — unknown.
297
+ * @param {object} options
298
+ * @param {string | undefined} options.file
299
+ * @param {string} options.cwd
300
+ * @returns {Promise<{ file: string, version: number | undefined }>}
301
+ */
302
+ async function readStrategyVersion({ file, cwd }) {
303
+ const path = await resolveStrategyPath({ ...(file === undefined ? {} : { file }), cwd });
304
+ const text = await readFile(path, 'utf8').catch(() => undefined);
305
+ if (text === undefined) return { file: path, version: undefined };
306
+ const strategy = validateStrategy(text);
307
+ return { file: path, version: strategy.ok ? strategy.version : undefined };
308
+ }
309
+
310
+ // ─── Queue ────────────────────────────────────────────────────────────────────
311
+
312
+ /**
313
+ * Build the queue: read the candidates, derive each row, then order them.
314
+ *
315
+ * The order is total (desc), then stored score (desc), then id (asc). A tie is reachable
316
+ * because the total is rounded and because the logarithm flattens: `ln` maps different
317
+ * dependent counts onto the same one-decimal total. The tie-break is fixed, so two runs
318
+ * over one backlog print byte-identical output — a queue that reordered itself between
319
+ * reads could not be trusted to say what to do first.
320
+ *
321
+ * An unscored row keeps its place in the list, after the ranked rows, and carries the
322
+ * reason. Nothing is dropped silently and no score is invented: the gap is the answer.
323
+ *
324
+ * @param {object} options
325
+ * @param {Runner} options.bd
326
+ * @param {string | undefined} [options.file] Strategy path; defaults to the documented default.
327
+ * @param {string} [options.cwd]
328
+ * @param {boolean} [options.includeIdeas]
329
+ * @returns {Promise<QueueReport>}
330
+ */
331
+ export async function buildQueue({ bd, file, cwd = process.cwd(), includeIdeas = false }) {
332
+ const [strategy, { candidates, excludedIdeas, excludedDeferred }] = await Promise.all([
333
+ readStrategyVersion({ file, cwd }),
334
+ readCandidates({ bd, includeIdeas }),
335
+ ]);
336
+ const dependentsOf = createDependentReader(bd);
337
+
338
+ /** @type {QueueRow[]} */
339
+ const rows = [];
340
+ for (const candidate of candidates) {
341
+ const dependents = await dependentsOf(candidate.id);
342
+ const live = [...dependents.values()]
343
+ .filter((dependent) => dependent.status !== CLOSED_STATUS)
344
+ .map((dependent) => dependent.id)
345
+ .sort();
346
+ const inProgress = candidate.status === IN_PROGRESS_STATUS;
347
+ const score = candidate.rice?.score;
348
+ const scoredAtVersion = candidate.rice?.strategyVersion;
349
+ const stale =
350
+ score !== undefined &&
351
+ scoredAtVersion !== undefined &&
352
+ strategy.version !== undefined &&
353
+ scoredAtVersion !== strategy.version;
354
+
355
+ rows.push({
356
+ rank: undefined,
357
+ id: candidate.id,
358
+ title: candidate.title.replace(/\s+/g, ' ').trim(),
359
+ status: candidate.status,
360
+ inProgress,
361
+ score,
362
+ rice: candidate.rice,
363
+ dependents: live.length,
364
+ dependentIds: live,
365
+ dependentMultiplier: dependentMultiplier(live.length),
366
+ wipMultiplier: inProgress ? WIP_MULTIPLIER : 1,
367
+ total: score === undefined
368
+ ? undefined
369
+ : queueTotal({ score, dependents: live.length, inProgress }),
370
+ scoredAtVersion,
371
+ stale,
372
+ unscoredReason: score === undefined
373
+ ? candidate.rice === undefined
374
+ ? `no ${RICE_KEY} metadata`
375
+ : `no score in its ${RICE_KEY} record`
376
+ : undefined,
377
+ });
378
+ }
379
+
380
+ const ranked = rows
381
+ .filter((row) => row.total !== undefined)
382
+ .sort((a, b) => {
383
+ const byTotal = /** @type {number} */ (b.total) - /** @type {number} */ (a.total);
384
+ if (byTotal !== 0) return byTotal;
385
+ const byScore = /** @type {number} */ (b.score) - /** @type {number} */ (a.score);
386
+ if (byScore !== 0) return byScore;
387
+ return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
388
+ });
389
+ ranked.forEach((row, index) => {
390
+ row.rank = index + 1;
391
+ });
392
+
393
+ const unscored = rows
394
+ .filter((row) => row.total === undefined)
395
+ .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
396
+
397
+ return {
398
+ strategy,
399
+ counts: {
400
+ ranked: ranked.length,
401
+ unscored: unscored.length,
402
+ excludedIdeas,
403
+ excludedDeferred,
404
+ },
405
+ rows: [...ranked, ...unscored],
406
+ };
407
+ }
408
+
409
+ // ─── Render ───────────────────────────────────────────────────────────────────
410
+
411
+ /** @param {number} count @param {string} noun @returns {string} */
412
+ function plural(count, noun) {
413
+ return `${count} ${noun}${count === 1 ? '' : 's'}`;
414
+ }
415
+
416
+ /** @param {string} text @param {number} width @returns {string} */
417
+ function right(text, width) {
418
+ return text.padStart(width);
419
+ }
420
+
421
+ /**
422
+ * The components as the score record holds them, so a reader can check the arithmetic
423
+ * without opening the bead.
424
+ * @param {StoredRice} rice @returns {string}
425
+ */
426
+ function components(rice) {
427
+ const part = (/** @type {number | undefined} */ value, /** @type {string} */ letter) =>
428
+ `${letter}${value === undefined ? '?' : value}`;
429
+ return `${part(rice.reach, 'R')} · ${part(rice.impact, 'I')} · ${part(rice.confidence, 'C')} · ${part(rice.effort, 'E')}`;
430
+ }
431
+
432
+ /**
433
+ * What the answer unblocks, in one clause: named ids while they fit, a count after that.
434
+ * @param {QueueRow} row @returns {string}
435
+ */
436
+ function unblocks(row) {
437
+ if (row.dependentIds.length === 0) return 'unblocks nothing';
438
+ const named = row.dependentIds.slice(0, NAMED_DEPENDENTS);
439
+ const rest = row.dependentIds.length - named.length;
440
+ return `unblocks ${named.join(', ')}${rest > 0 ? ` +${rest} more` : ''}`;
441
+ }
442
+
443
+ /**
444
+ * Render the queue as text: a header naming the formula, then one block per row — its
445
+ * numbers, then its title and what the answer unblocks.
446
+ * @param {QueueReport} report @returns {string}
447
+ */
448
+ export function renderQueue(report) {
449
+ const { counts, strategy, rows } = report;
450
+ const strategyLabel = strategy.version === undefined ? 'strategy unknown' : `strategy v${strategy.version}`;
451
+
452
+ if (rows.length === 0) {
453
+ const excluded = excludedLine(counts);
454
+ return `HITL decision queue · no open ${HITL_LABEL} beads\n${excluded}`;
455
+ }
456
+
457
+ const rankWidth = Math.max(1, String(counts.ranked).length);
458
+ const idWidth = Math.max(...rows.map((row) => row.id.length));
459
+ const totalWidth = Math.max(...rows.map((row) => (row.total === undefined ? 1 : row.total.toFixed(1).length)));
460
+
461
+ const lines = [
462
+ `HITL decision queue · ${counts.ranked} ranked, ${counts.unscored} unscored · ${strategyLabel}`,
463
+ 'total = rice × (1 + ln(1 + dependents)) × 1.5 in-progress · ties break on rice, then id · unscored last',
464
+ ];
465
+ const excluded = excludedLine(counts);
466
+ if (excluded !== '') lines.push(excluded.trimEnd());
467
+ if (counts.ranked === 0) {
468
+ lines.push(
469
+ 'no RICE score on any candidate — nothing can be ranked; score one with score-bead, then re-run',
470
+ );
471
+ }
472
+ lines.push('');
473
+
474
+ for (const row of rows) {
475
+ const rank = row.rank === undefined ? '-' : String(row.rank);
476
+ const total = row.total === undefined ? '—' : row.total.toFixed(1);
477
+ const numbers = row.score === undefined
478
+ ? `unscored — ${row.unscoredReason ?? 'no score'} (score it with score-bead)`
479
+ : `rice ${row.score.toFixed(1)} = ${components(/** @type {StoredRice} */ (row.rice))}` +
480
+ ` ×${row.dependentMultiplier.toFixed(2)} dependents(${row.dependents})` +
481
+ ` ×${row.wipMultiplier.toFixed(1)}${row.inProgress ? ' in-progress' : ''}`;
482
+ const stale = row.stale ? ` (scored at strategy v${row.scoredAtVersion}, current v${strategy.version})` : '';
483
+ lines.push(
484
+ `${right(rank, rankWidth)} ${row.id.padEnd(idWidth)} ${right(total, totalWidth)} ${numbers}${stale}`,
485
+ );
486
+ lines.push(` ${row.title} — ${unblocks(row)}`);
487
+ }
488
+
489
+ return `${lines.join('\n')}\n`;
490
+ }
491
+
492
+ /**
493
+ * The excluded counts, so nothing leaves the list without a trace.
494
+ * @param {QueueReport['counts']} counts @returns {string}
495
+ */
496
+ function excludedLine(counts) {
497
+ if (counts.excludedIdeas === 0 && counts.excludedDeferred === 0) return '';
498
+ const parts = [];
499
+ if (counts.excludedIdeas > 0) {
500
+ parts.push(`${plural(counts.excludedIdeas, IDEA_LABEL + ' bead')} (--include-ideas ranks them)`);
501
+ }
502
+ if (counts.excludedDeferred > 0) {
503
+ parts.push(`${plural(counts.excludedDeferred, DEFERRED_STATUS + ' bead')}`);
504
+ }
505
+ return `excluded: ${parts.join('; ')}\n`;
506
+ }
507
+
508
+ // ─── CLI ──────────────────────────────────────────────────────────────────────
509
+
510
+ /** @type {readonly string[]} */
511
+ export const COMMANDS = /** @type {const} */ (['queue']);
512
+
513
+ const USAGE = `Usage: node queue.mjs queue [--file <path>] [--include-ideas] [--cwd <dir>] [--json]
514
+
515
+ queue Rank the open ${HITL_LABEL} beads into one list: RICE × (1 + ln(1 + dependents))
516
+ × 1.5 when in progress. A bead with no score is listed as unscored, never guessed.
517
+
518
+ Options:
519
+ --file <path> Strategy file (default: ${DEFAULT_FILE}, relative to the repository root)
520
+ --include-ideas Rank ${IDEA_LABEL} beads too (they are excluded by default)
521
+ --cwd <dir> Directory the strategy path and bd resolve from
522
+ --json Print the report as JSON
523
+ -h, --help Show this text
524
+
525
+ Environment:
526
+ BD_PATH bd binary (default: bd)
527
+ `;
528
+
529
+ /**
530
+ * Parse the CLI's argv.
531
+ * @param {string[]} argv
532
+ * @returns {{ command: string, file?: string, includeIdeas: boolean, cwd: string, json: boolean }}
533
+ */
534
+ export function parseArgs(argv) {
535
+ /** @type {string | undefined} */
536
+ let command;
537
+ /** @type {string | undefined} */
538
+ let file;
539
+ let cwd = process.cwd();
540
+ let json = false;
541
+ let includeIdeas = false;
542
+
543
+ for (let i = 0; i < argv.length; i++) {
544
+ const arg = argv[i];
545
+ if (arg === '--json') {
546
+ json = true;
547
+ continue;
548
+ }
549
+ if (arg === '--include-ideas') {
550
+ includeIdeas = true;
551
+ continue;
552
+ }
553
+ if (arg === '--file' || arg === '--cwd') {
554
+ const value = argv[++i];
555
+ if (value === undefined) throw new UsageError(`${arg} needs a value`);
556
+ if (arg === '--cwd') cwd = value;
557
+ else file = value;
558
+ continue;
559
+ }
560
+ if (arg.startsWith('-')) throw new UsageError(`unexpected argument: ${arg}`);
561
+ if (command === undefined) {
562
+ if (!COMMANDS.includes(arg)) throw new UsageError(`unknown command "${arg}"`);
563
+ command = arg;
564
+ continue;
565
+ }
566
+ throw new UsageError(`unexpected argument: ${arg}`);
567
+ }
568
+
569
+ if (command === undefined) throw new UsageError(`a command is required: ${COMMANDS.join(', ')}`);
570
+ return { command, ...(file === undefined ? {} : { file }), includeIdeas, cwd, json };
571
+ }
572
+
573
+ /**
574
+ * @param {object} options
575
+ * @param {string[]} options.argv CLI arguments (without node/script).
576
+ * @param {(chunk: string) => void} [options.write] stdout sink; injectable for tests.
577
+ * @param {(chunk: string) => void} [options.writeError] stderr sink; injectable for tests.
578
+ * @param {{ bd?: Runner }} [options.deps] Collaborators; injectable for tests.
579
+ * @returns {Promise<number>} process exit code.
580
+ */
581
+ export async function runCli({
582
+ argv,
583
+ write = (chunk) => process.stdout.write(chunk),
584
+ writeError = (chunk) => process.stderr.write(chunk),
585
+ deps = {},
586
+ }) {
587
+ if (argv.includes('--help') || argv.includes('-h')) {
588
+ write(USAGE);
589
+ return 0;
590
+ }
591
+
592
+ let options;
593
+ try {
594
+ options = parseArgs(argv);
595
+ } catch (error) {
596
+ writeError(`queue: ${errorMessage(error)}\n\n${USAGE}`);
597
+ return 2;
598
+ }
599
+
600
+ const bd = deps.bd ?? makeRunner(BD_PATH, options.cwd);
601
+ try {
602
+ const report = await buildQueue({
603
+ bd,
604
+ cwd: options.cwd,
605
+ includeIdeas: options.includeIdeas,
606
+ ...(options.file === undefined ? {} : { file: options.file }),
607
+ });
608
+ write(options.json ? `${JSON.stringify(report, null, 2)}\n` : renderQueue(report));
609
+ return 0;
610
+ } catch (error) {
611
+ writeError(`queue failed: ${errorMessage(error)}\n`);
612
+ return 1;
613
+ }
614
+ }
615
+
616
+ // Only run the CLI when invoked directly, so tests can import the module.
617
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
618
+ runCli({ argv: process.argv.slice(2) })
619
+ .then((code) => {
620
+ process.exitCode = code;
621
+ })
622
+ .catch((error) => {
623
+ process.stderr.write(`${errorMessage(error)}\n`);
624
+ process.exitCode = 1;
625
+ });
626
+ }
@@ -20,6 +20,8 @@ Covers everything needed to prepare work for autonomous execution: understand th
20
20
 
21
21
  ## When NOT to use
22
22
 
23
+ - Choosing which feature to plan next, or re-ranking the backlog — that is the **guppi** agent, which
24
+ decides what gets planned and hands one feature to this skill.
23
25
  - User already has a PRD and just needs it broken into tasks — use `prd-to-tasks` directly.
24
26
  - Single well-understood task that just needs a bead — use `create-task`.
25
27
  - Raw idea not ready for implementation planning — use `record-idea`.
@@ -28,6 +30,16 @@ Covers everything needed to prepare work for autonomous execution: understand th
28
30
 
29
31
  ## Workflow
30
32
 
33
+ ### Phase 0 — Report the HITL queue
34
+
35
+ Before asking anything, presenting a plan, or dispatching a task, load the `hitl-queue` skill and
36
+ report its queue exactly as the script prints it. Then name the top item in one sentence: its id,
37
+ its title, and what answering it unblocks.
38
+
39
+ The queue is the first thing the session says, because the answer decides what gets planned at all.
40
+ A queue with no scores says so: report the unscored list and ask which bead to score, rather than
41
+ choosing one by feel. Never summarise the queue away or reorder it.
42
+
31
43
  ### Phase 1 — Understand and specify
32
44
 
33
45
  Establish what needs to be built through investigation and collaborative design.
@@ -262,6 +274,7 @@ Deliver a handoff bead, message the running ralph session, and give the instruct
262
274
  - [spec-to-tickets](../../planning/spec-to-tickets/SKILL.md) — load instead when the PRD must become external tickets rather than beads.
263
275
  - [create-task](../../planning/create-task/SKILL.md) — Phase 3's per-task bead creation and pipeline expansion; it invokes `classify-bead` internally, so this skill never classifies directly.
264
276
  - [ralph](../ralph/SKILL.md) — the downstream consumer of the handoff bead — hand off by sending ralph a `handoff` message naming the bead, with "Use the ralph skill. Start from bead #<handoff-id>" as the manual fallback when no ralph session is online. Its [REFERENCE.md](../ralph/REFERENCE.md) owns the planner ↔ ralph message convention.
277
+ - **guppi** (the `agent-cortex:guppi` agent) — the upstream entry point: it decides what gets planned and hands this skill one feature. Load this skill for that feature. The strategy guppi recommends from is the committed file that `prioritise-work` owns; `score-bead` records the score and `hitl-queue` prints the order.
265
278
 
266
279
  ## Examples
267
280