@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,664 @@
1
+ // @ts-check
2
+ // RICE scoring for one bead: compute the score against a validated strategy, write the
3
+ // record into the bead's metadata, and propose a priority band for a reviewer to apply.
4
+ //
5
+ // Two subcommands, and the split is the point:
6
+ //
7
+ // node rice.mjs score <bead-id> --reach <n> --impact <n> --confidence <0..1> \
8
+ // --effort <n> --rationale "<why these numbers>" [--file <strategy>] \
9
+ // [--at <rfc3339>] [--cwd <dir>] [--json]
10
+ //
11
+ // node rice.mjs band <bead-id> [--cwd <dir>] [--json]
12
+ //
13
+ // `score` writes; `band` only reads and prints a proposal. Banding never sets a priority
14
+ // itself, because a score is a judgement and the band it implies is reviewed before it
15
+ // lands. P0 is an incident, so `band` neither proposes it nor touches a bead already at
16
+ // P0 — that bead is refused before any proposal exists.
17
+ //
18
+ // The rubric is the strategy file owned by the prioritise-work skill. This script reads
19
+ // it through that skill's validator and resolver, so there is one definition of a valid
20
+ // strategy and one way to find it. A missing or invalid strategy refuses the score before
21
+ // the first tracker call, so a refusal writes nothing at all.
22
+ //
23
+ // Zero runtime dependencies, async fs only, and every child process runs with an argv
24
+ // array — never a shell string, so a rationale carrying spaces or quotes reaches `bd`
25
+ // unmangled. `skills/*/scripts` sits outside the check-no-sync scan; this script stays
26
+ // async anyway so the rule holds everywhere.
27
+ //
28
+ // Exit codes: 0 the command ran, 1 it refused (bad strategy, bad bead, P0 guard),
29
+ // 2 the caller asked for something malformed.
30
+
31
+ import { execFile } from 'node:child_process';
32
+ import { readFile } from 'node:fs/promises';
33
+ import { pathToFileURL } from 'node:url';
34
+ import { promisify } from 'node:util';
35
+
36
+ import {
37
+ DEFAULT_FILE,
38
+ resolveStrategyPath,
39
+ validateStrategy,
40
+ } from '../../prioritise-work/scripts/strategy.mjs';
41
+
42
+ // ─── Contract ─────────────────────────────────────────────────────────────────
43
+
44
+ /** The metadata key the whole record lives under, so a reader greps one key. */
45
+ export const RICE_KEY = 'rice';
46
+
47
+ /** bd's most urgent priority. It means an incident; banding never produces it. */
48
+ export const INCIDENT_PRIORITY = 0;
49
+
50
+ /**
51
+ * The score-to-priority bands, highest first: the first band whose floor the score
52
+ * reaches wins. P0 is deliberately absent — a score can never raise a bead to an
53
+ * incident, and `bandFor` throws if a table ever tries. A band above P1 would make the
54
+ * two highest priorities indistinguishable, so the top band maps to P1.
55
+ * @type {readonly { minScore: number, priority: number }[]}
56
+ */
57
+ export const DEFAULT_BANDS = /** @type {const} */ ([
58
+ { minScore: 8, priority: 1 },
59
+ { minScore: 4, priority: 2 },
60
+ { minScore: 1, priority: 3 },
61
+ { minScore: 0, priority: 4 },
62
+ ]);
63
+
64
+ /** The prefix that marks one historical score inside the rationale trail. */
65
+ const PRIOR_PREFIX = '[prior]';
66
+
67
+ /** A child-process call that returns stdout. @typedef {(args: string[]) => Promise<string>} Runner */
68
+
69
+ /**
70
+ * What `score` wrote.
71
+ * @typedef {object} RiceRecord
72
+ * @property {number} reach
73
+ * @property {number} impact
74
+ * @property {number} confidence
75
+ * @property {number} effort
76
+ * @property {number} score
77
+ * @property {number} strategyVersion
78
+ * @property {string} assessedAt
79
+ * @property {string} rationale
80
+ */
81
+
82
+ /**
83
+ * A record read back from metadata. Every field is optional, because the stored blob is
84
+ * not ours alone: a hand edit or an older writer may have left some out.
85
+ * @typedef {object} StoredRice
86
+ * @property {number} [score]
87
+ * @property {number} [strategyVersion]
88
+ * @property {string} [assessedAt]
89
+ * @property {number} [reach]
90
+ * @property {number} [impact]
91
+ * @property {number} [confidence]
92
+ * @property {number} [effort]
93
+ * @property {string} [rationale]
94
+ */
95
+
96
+ /**
97
+ * The bead as this script needs it.
98
+ * @typedef {object} Bead
99
+ * @property {string} id
100
+ * @property {number} priority
101
+ * @property {StoredRice | undefined} rice
102
+ */
103
+
104
+ /**
105
+ * The components as the caller supplied them, before validation.
106
+ * @typedef {object} ComponentInput
107
+ * @property {unknown} reach
108
+ * @property {unknown} impact
109
+ * @property {unknown} confidence
110
+ * @property {unknown} effort
111
+ */
112
+
113
+ /**
114
+ * What `score` reports.
115
+ * @typedef {object} ScoreReport
116
+ * @property {string} bead
117
+ * @property {RiceRecord} rice
118
+ * @property {{ file: string, version: number }} strategy
119
+ * @property {StoredRice | undefined} previous
120
+ */
121
+
122
+ /**
123
+ * What `band` reports. `command` is the argv a reviewer runs to apply the band — this
124
+ * script never runs it.
125
+ * @typedef {object} BandReport
126
+ * @property {string} bead
127
+ * @property {number} score
128
+ * @property {number} currentPriority
129
+ * @property {number} proposedPriority
130
+ * @property {boolean} changed
131
+ * @property {string[]} command
132
+ */
133
+
134
+ /** A usage mistake: the caller asked for something malformed. Exit code 2. */
135
+ export class UsageError extends Error {}
136
+
137
+ /** The run refused: a bad strategy, an unscored bead, a P0 bead. Exit code 1. */
138
+ export class RiceError extends Error {}
139
+
140
+ // ─── Value helpers ────────────────────────────────────────────────────────────
141
+
142
+ /** The full message of a thrown error, including a child process's stderr.
143
+ * @param {unknown} error @returns {string} */
144
+ export function errorMessage(error) {
145
+ if (typeof error !== 'object' || error === null) return String(error);
146
+ const parts = [];
147
+ for (const key of /** @type {const} */ (['message', 'stderr'])) {
148
+ const value = /** @type {Record<string, unknown>} */ (error)[key];
149
+ if (typeof value === 'string' && value.trim() !== '') parts.push(value.trim());
150
+ }
151
+ return parts.join(': ');
152
+ }
153
+
154
+ /** Collapse a value onto one line, so a rationale never breaks the record's shape.
155
+ * @param {string} value @returns {string} */
156
+ export function oneLine(value) {
157
+ return value.replace(/\s+/g, ' ').trim();
158
+ }
159
+
160
+ /** @param {unknown} value @returns {number | undefined} */
161
+ function finiteOrUndefined(value) {
162
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
163
+ }
164
+
165
+ /** @param {unknown} value @returns {string | undefined} */
166
+ function stringOrUndefined(value) {
167
+ return typeof value === 'string' ? value : undefined;
168
+ }
169
+
170
+ /**
171
+ * Read a number from a flag. A string is parsed, because argv is strings; anything that
172
+ * does not parse is a usage mistake, never a silent NaN in a written record.
173
+ * @param {unknown} value @param {string} flag @returns {number}
174
+ */
175
+ function toNumber(value, flag) {
176
+ const parsed = typeof value === 'number'
177
+ ? value
178
+ : typeof value === 'string' && value.trim() !== ''
179
+ ? Number(value)
180
+ : Number.NaN;
181
+ if (!Number.isFinite(parsed)) {
182
+ throw new UsageError(`${flag} must be a number (got ${JSON.stringify(value)})`);
183
+ }
184
+ return parsed;
185
+ }
186
+
187
+ // ─── Arithmetic ───────────────────────────────────────────────────────────────
188
+
189
+ /**
190
+ * RICE: reach × impact × confidence ÷ effort, rounded to one decimal. Rounding happens
191
+ * once, on the total, so the stored components reproduce the stored score exactly.
192
+ * @param {{ reach: number, impact: number, confidence: number, effort: number }} components
193
+ * @returns {number}
194
+ */
195
+ export function computeScore({ reach, impact, confidence, effort }) {
196
+ return Math.round(((reach * impact * confidence) / effort) * 10) / 10;
197
+ }
198
+
199
+ /**
200
+ * Check the components before they reach a bead: a confidence outside 0..1 is a claim
201
+ * the caller cannot support, and a zero effort divides by zero. Reach and impact are
202
+ * scaled judgements, so zero is allowed and only a negative value is a mistake.
203
+ * @param {ComponentInput} input
204
+ * @returns {{ reach: number, impact: number, confidence: number, effort: number }}
205
+ */
206
+ export function validateComponents({ reach, impact, confidence, effort }) {
207
+ const values = {
208
+ reach: toNumber(reach, '--reach'),
209
+ impact: toNumber(impact, '--impact'),
210
+ confidence: toNumber(confidence, '--confidence'),
211
+ effort: toNumber(effort, '--effort'),
212
+ };
213
+ if (values.reach < 0) throw new UsageError(`--reach must be zero or more (got ${values.reach})`);
214
+ if (values.impact < 0) throw new UsageError(`--impact must be zero or more (got ${values.impact})`);
215
+ if (values.confidence < 0 || values.confidence > 1) {
216
+ throw new UsageError(`--confidence is a probability between 0 and 1 (got ${values.confidence})`);
217
+ }
218
+ if (values.effort <= 0) throw new UsageError(`--effort must be greater than zero (got ${values.effort})`);
219
+ return values;
220
+ }
221
+
222
+ /**
223
+ * The priority a score implies. Never P0: an incident is declared, not computed, and a
224
+ * table that produced P0 fails here rather than quietly filing a bug as an incident.
225
+ * @param {number} score
226
+ * @param {readonly { minScore: number, priority: number }[]} [bands]
227
+ * @returns {number}
228
+ */
229
+ export function bandFor(score, bands = DEFAULT_BANDS) {
230
+ const band = bands.find(({ minScore }) => score >= minScore) ?? bands[bands.length - 1];
231
+ if (band === undefined) throw new RiceError('the band table is empty');
232
+ if (band.priority <= INCIDENT_PRIORITY) {
233
+ throw new RiceError(
234
+ `the band table yields P${band.priority} for score ${score} — banding never produces P0`,
235
+ );
236
+ }
237
+ return band.priority;
238
+ }
239
+
240
+ // ─── Rationale trail ──────────────────────────────────────────────────────────
241
+
242
+ /**
243
+ * One line describing the record a re-score replaces. Everything a reader needs to audit
244
+ * the superseded arithmetic is on it, so the old score stays checkable after the
245
+ * components are overwritten.
246
+ * @param {StoredRice} previous @returns {string}
247
+ */
248
+ function priorLine(previous) {
249
+ /** @type {Array<[string, number | undefined]>} */
250
+ const parts = [
251
+ ['reach', previous.reach],
252
+ ['impact', previous.impact],
253
+ ['confidence', previous.confidence],
254
+ ['effort', previous.effort],
255
+ ];
256
+ const components = parts
257
+ .filter((part) => part[1] !== undefined)
258
+ .map(([name, value]) => `${name} ${value}`)
259
+ .join(', ');
260
+ const head = `[prior] score ${previous.score} @ strategy v${previous.strategyVersion ?? '?'}, ` +
261
+ `assessed ${previous.assessedAt ?? 'unknown'}`;
262
+ return components === '' ? head : `${head} (${components})`;
263
+ }
264
+
265
+ /**
266
+ * The new rationale text: the caller's argument for the current numbers, followed by the
267
+ * trail of scores this record replaces, oldest first and ending with the one just
268
+ * replaced.
269
+ *
270
+ * The trail is appended into the rationale rather than kept in a separate field or file.
271
+ * One metadata record then holds both the current argument and its history, `bd show`
272
+ * renders the whole thing, and a re-score cannot drop the old number by forgetting a
273
+ * second store. Lines are prefixed `[prior]` so a reader greps the history out of the
274
+ * prose, and each carries the old score, components, strategy version and date.
275
+ *
276
+ * @param {object} input
277
+ * @param {string} input.rationale
278
+ * @param {StoredRice | undefined} input.previous
279
+ * @returns {string}
280
+ */
281
+ export function buildRationale({ rationale, previous }) {
282
+ if (previous === undefined) return rationale;
283
+ const carried = (previous.rationale ?? '')
284
+ .split('\n')
285
+ .map((line) => line.trim())
286
+ .filter((line) => line.startsWith(PRIOR_PREFIX));
287
+ // A record with no score is not a score: keep its trail, claim nothing about it.
288
+ const replaced = previous.score === undefined ? [] : [priorLine(previous)];
289
+ return [rationale, ...carried, ...replaced].join('\n');
290
+ }
291
+
292
+ // ─── Tracker ──────────────────────────────────────────────────────────────────
293
+
294
+ const execFileAsync = promisify(execFile);
295
+
296
+ // BD_PATH lets a machine where bd is not on PATH pin a location. Nothing here names a
297
+ // repository, a branch or a host.
298
+ export const BD_PATH = process.env.BD_PATH || 'bd';
299
+
300
+ /**
301
+ * Build a runner for one binary. argv arrays only, never a shell string.
302
+ * @param {string} bin @param {string} cwd @returns {Runner}
303
+ */
304
+ export function makeRunner(bin, cwd) {
305
+ return async (args) => {
306
+ const { stdout } = await execFileAsync(bin, args, { cwd, encoding: 'utf-8', maxBuffer: 8 * 1024 * 1024 });
307
+ return stdout;
308
+ };
309
+ }
310
+
311
+ /**
312
+ * Read a priority in bd's 0..4 scale. A value outside it is a bead this script cannot
313
+ * judge, so it is refused rather than coerced.
314
+ * @param {unknown} value @param {string} beadId @returns {number}
315
+ */
316
+ function readPriority(value, beadId) {
317
+ if (typeof value === 'number' && Number.isInteger(value) && value >= 0 && value <= 4) return value;
318
+ if (typeof value === 'string') {
319
+ const match = /^P?([0-4])$/i.exec(value.trim());
320
+ if (match !== null) return Number(match[1]);
321
+ }
322
+ throw new RiceError(
323
+ `bd show ${beadId} --json did not report a priority in P0..P4 (got ${JSON.stringify(value)})`,
324
+ );
325
+ }
326
+
327
+ /**
328
+ * The bead's metadata record, as far as a reader may rely on it. Exported so the queue
329
+ * reads a `rice` record through exactly the same lens that wrote it.
330
+ * @param {unknown} metadata @returns {StoredRice | undefined}
331
+ */
332
+ export function storedRice(metadata) {
333
+ if (typeof metadata !== 'object' || metadata === null) return undefined;
334
+ const value = /** @type {Record<string, unknown>} */ (metadata)[RICE_KEY];
335
+ if (typeof value !== 'object' || value === null) return undefined;
336
+ const record = /** @type {Record<string, unknown>} */ (value);
337
+ return {
338
+ score: finiteOrUndefined(record.score),
339
+ strategyVersion: finiteOrUndefined(record.strategyVersion),
340
+ assessedAt: stringOrUndefined(record.assessedAt),
341
+ reach: finiteOrUndefined(record.reach),
342
+ impact: finiteOrUndefined(record.impact),
343
+ confidence: finiteOrUndefined(record.confidence),
344
+ effort: finiteOrUndefined(record.effort),
345
+ rationale: stringOrUndefined(record.rationale),
346
+ };
347
+ }
348
+
349
+ /**
350
+ * Read one bead. `bd show <id> --json` prints a one-element array; a bare object is
351
+ * accepted too, because the shape is bd's to change.
352
+ * @param {object} options
353
+ * @param {string} options.beadId
354
+ * @param {Runner} options.bd
355
+ * @returns {Promise<Bead>}
356
+ */
357
+ export async function readBead({ beadId, bd }) {
358
+ const stdout = await bd(['show', beadId, '--json']);
359
+ let parsed;
360
+ try {
361
+ parsed = JSON.parse(stdout);
362
+ } catch {
363
+ throw new RiceError(`bd show ${beadId} --json did not print JSON`);
364
+ }
365
+ const bead = Array.isArray(parsed) ? parsed[0] : parsed;
366
+ if (typeof bead !== 'object' || bead === null) throw new RiceError(`bd show ${beadId} --json printed no bead`);
367
+ const record = /** @type {Record<string, unknown>} */ (bead);
368
+ return {
369
+ id: stringOrUndefined(record.id) ?? beadId,
370
+ priority: readPriority(record.priority, beadId),
371
+ rice: storedRice(record.metadata),
372
+ };
373
+ }
374
+
375
+ // ─── Score ────────────────────────────────────────────────────────────────────
376
+
377
+ /**
378
+ * Score one bead: validate the strategy, then read the bead, then write the record.
379
+ *
380
+ * The order is the contract. The strategy is read and validated before the first `bd`
381
+ * call, so a missing or malformed rubric refuses the run with nothing written — not a
382
+ * score, not a note, not a metadata key. Reach * impact * confidence / effort is computed
383
+ * once, and the version recorded is the file's own version, read after it validated.
384
+ *
385
+ * @param {object} options
386
+ * @param {string} options.beadId
387
+ * @param {ComponentInput} options.components
388
+ * @param {string} options.rationale
389
+ * @param {string} [options.file] Strategy path; defaults to the documented default.
390
+ * @param {string} [options.at] Assessment timestamp; defaults to now, UTC.
391
+ * @param {string} [options.cwd] Directory the strategy path and bd resolve from.
392
+ * @param {{ bd?: Runner }} [options.deps]
393
+ * @returns {Promise<ScoreReport>}
394
+ */
395
+ export async function scoreBead({
396
+ beadId,
397
+ components,
398
+ rationale,
399
+ file,
400
+ at = new Date().toISOString(),
401
+ cwd = process.cwd(),
402
+ deps = {},
403
+ }) {
404
+ const clean = validateComponents(components);
405
+ const cleanRationale = oneLine(rationale);
406
+ if (cleanRationale === '') {
407
+ throw new UsageError('--rationale is required: name the strategy section that justifies each component');
408
+ }
409
+
410
+ const strategyFile = await resolveStrategyPath({ ...(file === undefined ? {} : { file }), cwd });
411
+ let strategyText;
412
+ try {
413
+ strategyText = await readFile(strategyFile, 'utf8');
414
+ } catch {
415
+ throw new RiceError(
416
+ `${strategyFile} does not exist — write it with the prioritise-work skill, or pass --file <path>`,
417
+ );
418
+ }
419
+ const strategy = validateStrategy(strategyText);
420
+ if (!strategy.ok || strategy.version === undefined) {
421
+ throw new RiceError(
422
+ `${strategyFile} is not a valid strategy:\n${strategy.errors.map((error) => ` - ${error}`).join('\n')}`,
423
+ );
424
+ }
425
+ const strategyVersion = strategy.version;
426
+
427
+ const bd = deps.bd ?? makeRunner(BD_PATH, cwd);
428
+ const bead = await readBead({ beadId, bd });
429
+ const previous = bead.rice;
430
+
431
+ /** @type {RiceRecord} */
432
+ const rice = {
433
+ ...clean,
434
+ score: computeScore(clean),
435
+ strategyVersion,
436
+ assessedAt: at,
437
+ rationale: buildRationale({ rationale: cleanRationale, previous }),
438
+ };
439
+
440
+ await bd(['update', bead.id, '--metadata', JSON.stringify({ [RICE_KEY]: rice })]);
441
+
442
+ return { bead: bead.id, rice, strategy: { file: strategyFile, version: strategyVersion }, previous };
443
+ }
444
+
445
+ // ─── Band ─────────────────────────────────────────────────────────────────────
446
+
447
+ /**
448
+ * Propose a priority band from the stored score. Read-only by construction: the only
449
+ * tracker call is `bd show`, and the proposal leaves as a command line for a reviewer.
450
+ *
451
+ * A bead at P0 is refused before a proposal exists. P0 means an incident, and an
452
+ * incident is not re-prioritised by a quarterly rubric; the guard sits here rather than
453
+ * only in the band table, so a bead already at P0 cannot be nudged even if the table,
454
+ * the caller or a later edit changes what the score maps to.
455
+ *
456
+ * @param {object} options
457
+ * @param {string} options.beadId
458
+ * @param {readonly { minScore: number, priority: number }[]} [options.bands]
459
+ * @param {string} [options.cwd] Directory bd resolves from.
460
+ * @param {{ bd?: Runner }} [options.deps]
461
+ * @returns {Promise<BandReport>}
462
+ */
463
+ export async function bandBead({ beadId, bands = DEFAULT_BANDS, cwd = process.cwd(), deps = {} }) {
464
+ const bd = deps.bd ?? makeRunner(BD_PATH, cwd);
465
+ const bead = await readBead({ beadId, bd });
466
+
467
+ if (bead.priority === INCIDENT_PRIORITY) {
468
+ throw new RiceError(
469
+ `refusing to band ${bead.id}: it is already P0, and score never raises or lowers an incident`,
470
+ );
471
+ }
472
+ const rice = bead.rice;
473
+ if (rice === undefined || rice.score === undefined) {
474
+ throw new RiceError(`no RICE score on ${bead.id} — run "score ${bead.id}" first`);
475
+ }
476
+
477
+ const proposedPriority = bandFor(rice.score, bands);
478
+ return {
479
+ bead: bead.id,
480
+ score: rice.score,
481
+ currentPriority: bead.priority,
482
+ proposedPriority,
483
+ changed: proposedPriority !== bead.priority,
484
+ command: ['bd', 'update', bead.id, '-p', String(proposedPriority)],
485
+ };
486
+ }
487
+
488
+ // ─── CLI ──────────────────────────────────────────────────────────────────────
489
+
490
+ /** @type {readonly string[]} */
491
+ export const COMMANDS = /** @type {const} */ (['score', 'band']);
492
+
493
+ const USAGE = `Usage: node rice.mjs score <bead-id> --reach <n> --impact <n> --confidence <0..1> \\
494
+ --effort <n> --rationale "<why these numbers>" [--file <path>] [--at <rfc3339>] \\
495
+ [--cwd <dir>] [--json]
496
+ node rice.mjs band <bead-id> [--cwd <dir>] [--json]
497
+
498
+ score Compute reach × impact × confidence ÷ effort (one decimal) and write it into the
499
+ bead's metadata as {"rice":{…}}. Refuses, writing nothing, without a valid strategy.
500
+ band Propose a priority from the stored score. Prints the command; never runs it, and
501
+ never touches a bead already at P0.
502
+
503
+ Options:
504
+ --reach <n> How much work or how many workflows this changes.
505
+ --impact <n> How much it matters per unit of reach.
506
+ --confidence <0..1> How sure the estimate is. A real figure, not a guess dressed as one.
507
+ --effort <n> Cost, on the strategy's calibration scale. Must be greater than zero.
508
+ --rationale <text> Why these numbers, naming the strategy section that justifies them.
509
+ --file <path> Strategy file (default: ${DEFAULT_FILE}, relative to the repository root)
510
+ --at <rfc3339> Assessment timestamp (default: now, UTC)
511
+ --cwd <dir> Directory the strategy path and bd resolve from
512
+ --json Print the report as JSON
513
+ -h, --help Show this text
514
+
515
+ Environment:
516
+ BD_PATH bd binary (default: bd)
517
+ `;
518
+
519
+ /**
520
+ * Parse the CLI's argv.
521
+ * @param {string[]} argv
522
+ * @returns {{ command: string, beadId: string, components: ComponentInput, rationale?: string, file?: string, at?: string, cwd: string, json: boolean }}
523
+ */
524
+ export function parseArgs(argv) {
525
+ /** @type {Record<string, string>} */
526
+ const values = {};
527
+ /** @type {string | undefined} */
528
+ let command;
529
+ /** @type {string | undefined} */
530
+ let beadId;
531
+ let cwd = process.cwd();
532
+ let json = false;
533
+
534
+ const VALUE_FLAGS = new Set(['--reach', '--impact', '--confidence', '--effort', '--rationale', '--file', '--at', '--cwd']);
535
+
536
+ for (let i = 0; i < argv.length; i++) {
537
+ const arg = argv[i];
538
+ if (arg === '--json') {
539
+ json = true;
540
+ continue;
541
+ }
542
+ if (VALUE_FLAGS.has(arg)) {
543
+ const value = argv[++i];
544
+ if (value === undefined) throw new UsageError(`${arg} needs a value`);
545
+ if (arg === '--cwd') cwd = value;
546
+ else values[arg.slice(2)] = value;
547
+ continue;
548
+ }
549
+ if (arg.startsWith('-')) throw new UsageError(`unexpected argument: ${arg}`);
550
+ if (command === undefined) {
551
+ if (!COMMANDS.includes(arg)) throw new UsageError(`unknown command "${arg}"`);
552
+ command = arg;
553
+ continue;
554
+ }
555
+ if (beadId !== undefined) throw new UsageError(`unexpected argument: ${arg}`);
556
+ beadId = arg;
557
+ }
558
+
559
+ if (command === undefined) throw new UsageError(`a command is required: ${COMMANDS.join(', ')}`);
560
+ if (beadId === undefined) throw new UsageError(`a bead id is required: rice.mjs ${command} <bead-id>`);
561
+
562
+ return {
563
+ command,
564
+ beadId,
565
+ cwd,
566
+ json,
567
+ components: {
568
+ reach: values.reach,
569
+ impact: values.impact,
570
+ confidence: values.confidence,
571
+ effort: values.effort,
572
+ },
573
+ ...(values.rationale === undefined ? {} : { rationale: values.rationale }),
574
+ ...(values.file === undefined ? {} : { file: values.file }),
575
+ ...(values.at === undefined ? {} : { at: values.at }),
576
+ };
577
+ }
578
+
579
+ /**
580
+ * @param {object} options
581
+ * @param {string[]} options.argv CLI arguments (without node/script).
582
+ * @param {(chunk: string) => void} [options.write] stdout sink; injectable for tests.
583
+ * @param {(chunk: string) => void} [options.writeError] stderr sink; injectable for tests.
584
+ * @param {{ bd?: Runner }} [options.deps] Collaborators; injectable for tests.
585
+ * @returns {Promise<number>} process exit code.
586
+ */
587
+ export async function runCli({
588
+ argv,
589
+ write = (chunk) => process.stdout.write(chunk),
590
+ writeError = (chunk) => process.stderr.write(chunk),
591
+ deps = {},
592
+ }) {
593
+ if (argv.includes('--help') || argv.includes('-h')) {
594
+ write(USAGE);
595
+ return 0;
596
+ }
597
+
598
+ let options;
599
+ try {
600
+ options = parseArgs(argv);
601
+ } catch (error) {
602
+ writeError(`rice: ${errorMessage(error)}\n\n${USAGE}`);
603
+ return 2;
604
+ }
605
+
606
+ try {
607
+ if (options.command === 'score') {
608
+ const report = await scoreBead({
609
+ beadId: options.beadId,
610
+ components: options.components,
611
+ rationale: options.rationale ?? '',
612
+ cwd: options.cwd,
613
+ deps,
614
+ ...(options.file === undefined ? {} : { file: options.file }),
615
+ ...(options.at === undefined ? {} : { at: options.at }),
616
+ });
617
+
618
+ if (options.json) {
619
+ write(`${JSON.stringify(report, null, 2)}\n`);
620
+ return 0;
621
+ }
622
+ const { rice } = report;
623
+ write(
624
+ `✓ ${report.bead}: score ${rice.score} = reach ${rice.reach} × impact ${rice.impact} ` +
625
+ `× confidence ${rice.confidence} ÷ effort ${rice.effort} (strategy v${report.strategy.version})\n`,
626
+ );
627
+ write(` assessed ${rice.assessedAt}\n`);
628
+ if (report.previous !== undefined) {
629
+ write(
630
+ ` replaced score ${report.previous.score ?? '(none)'} @ strategy ` +
631
+ `v${report.previous.strategyVersion ?? '?'} — kept in the rationale trail\n`,
632
+ );
633
+ }
634
+ write(` next: band ${report.bead} proposes a priority; a reviewer applies it, never the script\n`);
635
+ return 0;
636
+ }
637
+
638
+ const band = await bandBead({ beadId: options.beadId, cwd: options.cwd, deps });
639
+ if (options.json) {
640
+ write(`${JSON.stringify(band, null, 2)}\n`);
641
+ return 0;
642
+ }
643
+ const movement = band.changed ? '' : ', unchanged';
644
+ write(`✓ ${band.bead}: score ${band.score} → P${band.proposedPriority} (currently P${band.currentPriority}${movement})\n`);
645
+ write(` proposal only — review the components and the rationale, then apply: ${band.command.join(' ')}\n`);
646
+ return 0;
647
+ } catch (error) {
648
+ const kind = error instanceof UsageError ? 'usage' : 'failed';
649
+ writeError(`rice ${kind}: ${errorMessage(error)}\n`);
650
+ return error instanceof UsageError ? 2 : 1;
651
+ }
652
+ }
653
+
654
+ // Only run the CLI when invoked directly, so tests can import the module.
655
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
656
+ runCli({ argv: process.argv.slice(2) })
657
+ .then((code) => {
658
+ process.exitCode = code;
659
+ })
660
+ .catch((error) => {
661
+ process.stderr.write(`${errorMessage(error)}\n`);
662
+ process.exitCode = 1;
663
+ });
664
+ }
@@ -26,6 +26,11 @@ Task arrives
26
26
  ├── Spec approved, need tickets? ───────→ spec-to-tickets
27
27
  ├── Need a single task tracked? ────────→ create-task
28
28
  ├── Need to plan a feature end-to-end? ─→ plan
29
+ ├── Deciding what to work on next? ─────→ guppi
30
+ │ └── Strategy stale or missing? ────→ prioritise-work
31
+ ├── Scoring or banding one bead? ──────→ score-bead
32
+ ├── Ranking the HITL decision queue? ──→ hitl-queue
33
+ ├── Collaborating on HITL work? ────────→ guppi
29
34
  ├── Running beads / task execution? ────→ ralph
30
35
  │ └── Single stage? ─────────────────→ run-pipeline-stage
31
36
  ├── Implementing a task? ──────────────→ implement-task
@@ -43,7 +48,7 @@ Task arrives
43
48
  ├── Evaluating architecture? ──────────→ technical-direction
44
49
  ├── Finding refactor opportunities? ───→ improve-codebase-architecture
45
50
  ├── Need task tracking? ───────────────→ bd-tool
46
- ├── Need human review / HITL? ─────────→ hitl-collab
51
+ ├── Need a human handoff document? ────→ hitl-collab
47
52
  ├── Need a handoff doc? ───────────────→ handoff
48
53
  ├── Writing a ticket for Jira? ────────→ write-a-ticket
49
54
  ├── Creating a new skill? ─────────────→ write-a-skill