@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.
- package/CHANGELOG.md +304 -0
- package/README.md +30 -3
- package/agents/README.md +7 -2
- package/agents/guppi/agent.md +231 -0
- package/agents/guppi/claude/frontmatter.json +6 -0
- package/agents/guppi/pi/frontmatter.json +6 -0
- package/agents/plan/agent.md +6 -0
- package/agents/ralph-plan/agent.md +6 -0
- package/bin/installers/claude.mjs +8 -23
- package/bin/installers/pi.mjs +8 -2
- package/extensions/interrupt-bookmark/README.md +129 -0
- package/extensions/interrupt-bookmark/index.test.ts +717 -0
- package/extensions/interrupt-bookmark/index.ts +354 -0
- package/extensions/interrupt-bookmark/package.json +20 -0
- package/extensions/skill-stats/README.md +84 -3
- package/extensions/skill-stats/index.ts +62 -3
- package/extensions/skill-stats/package.json +1 -1
- package/extensions/skill-stats/rollup.test.ts +643 -0
- package/extensions/skill-stats/rollup.ts +415 -0
- package/extensions/skill-stats/timeline.ts +101 -0
- package/package.json +1 -1
- package/scripts/lib/compose-agent.mjs +45 -7
- package/scripts/lib/manifest.mjs +29 -0
- package/skills/review/review-defects/EXAMPLES.md +80 -4
- package/skills/review/review-defects/SKILL.md +47 -16
- package/skills/review/review-report/EXAMPLES.md +118 -0
- package/skills/review/review-report/SKILL.md +138 -0
- package/skills/review/review-report/scripts/report.mjs +1032 -0
- package/skills/workflow/hitl-queue/BOUNDARY.md +122 -0
- package/skills/workflow/hitl-queue/DEADLINES.md +59 -0
- package/skills/workflow/hitl-queue/RECOMMEND.md +89 -0
- package/skills/workflow/hitl-queue/SCOPES.md +118 -0
- package/skills/workflow/hitl-queue/SKILL.md +150 -0
- package/skills/workflow/hitl-queue/scripts/boundary.mjs +493 -0
- package/skills/workflow/hitl-queue/scripts/queue.mjs +1012 -0
- package/skills/workflow/hitl-queue/scripts/recommend.mjs +492 -0
- package/skills/workflow/hitl-queue/scripts/scopes.mjs +479 -0
- package/skills/workflow/plan/SKILL.md +13 -0
- package/skills/workflow/prioritise-work/CHALLENGE.md +139 -0
- package/skills/workflow/prioritise-work/SKILL.md +150 -0
- package/skills/workflow/prioritise-work/scripts/challenge.mjs +505 -0
- package/skills/workflow/prioritise-work/scripts/strategy.mjs +756 -0
- package/skills/workflow/prioritise-work/templates/strategy.md +87 -0
- package/skills/workflow/ralph/REFERENCE.md +6 -1
- package/skills/workflow/ralph/scripts/prune-worktrees.mjs +185 -10
- package/skills/workflow/run-pipeline-stage/playbooks/review.md +5 -2
- package/skills/workflow/score-bead/FORMAT.md +73 -0
- package/skills/workflow/score-bead/SKILL.md +112 -0
- package/skills/workflow/score-bead/scripts/rice.mjs +664 -0
- package/skills/workflow/using-agent-skills/SKILL.md +6 -1
- package/token-map.README.md +14 -2
- package/token-map.json +17 -3
|
@@ -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
|
|
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
|