@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.
- package/CHANGELOG.md +173 -0
- package/README.md +26 -3
- package/agents/README.md +2 -2
- package/agents/guppi/agent.md +162 -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 +1 -20
- 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/manifest.mjs +29 -0
- package/skills/review/review-defects/EXAMPLES.md +80 -4
- package/skills/review/review-defects/SKILL.md +45 -15
- package/skills/review/review-report/EXAMPLES.md +114 -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/SKILL.md +134 -0
- package/skills/workflow/hitl-queue/scripts/queue.mjs +626 -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 +566 -0
- package/skills/workflow/prioritise-work/templates/strategy.md +61 -0
- 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.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
|
|