@jaybeeuu/agent-cortex 2.7.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 +412 -0
- package/README.md +87 -8
- package/agents/README.md +4 -4
- 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/agent.md +17 -16
- package/agents/ralph/claude/wake.md +4 -4
- package/agents/ralph/pi/wake.md +15 -20
- package/agents/ralph-plan/agent.md +6 -0
- package/bin/agent-cortex.mjs +20 -1
- 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/post-merge-watch/README.md +133 -0
- package/extensions/post-merge-watch/index.test.ts +247 -0
- package/extensions/post-merge-watch/index.ts +123 -0
- package/extensions/post-merge-watch/package.json +16 -0
- package/extensions/post-merge-watch/watch.test.ts +469 -0
- package/extensions/post-merge-watch/watch.ts +431 -0
- package/extensions/session-clear/README.md +74 -0
- package/extensions/session-clear/index.test.ts +524 -0
- package/extensions/session-clear/index.ts +159 -0
- package/extensions/session-clear/package.json +16 -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/lib/cli.mjs +42 -1
- package/lib/self-update.mjs +553 -0
- package/package.json +1 -1
- package/scripts/lib/manifest.mjs +29 -0
- package/skills/engineering/implement-task/SKILL.md +124 -0
- package/skills/engineering/tdd/SKILL.md +2 -0
- package/skills/planning/create-task/REFERENCE.md +15 -6
- package/skills/planning/create-task/SKILL.md +1 -1
- package/skills/planning/create-task/pipeline.json +8 -0
- package/skills/planning/create-task/scripts/create-chores.mjs +27 -18
- package/skills/planning/create-task/templates/code.md +8 -3
- package/skills/planning/create-task/templates/document.md +8 -3
- package/skills/planning/create-task/templates/fix.md +8 -3
- package/skills/planning/create-task/templates/housekeeping.md +8 -4
- package/skills/planning/create-task/templates/post-merge.md +67 -0
- package/skills/planning/create-task/templates/review.md +8 -3
- package/skills/planning/create-task/templates/verify.md +8 -3
- package/skills/productivity/bd-tool/SKILL.md +55 -0
- package/skills/review/review-defects/EXAMPLES.md +198 -0
- package/skills/review/review-defects/SKILL.md +121 -0
- 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/style/style-tests/EXAMPLES.md +30 -0
- package/skills/style/style-tests/SKILL.md +10 -6
- 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 +27 -3
- 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/ralph/REFERENCE.md +124 -27
- package/skills/workflow/ralph/SKILL.md +24 -15
- package/skills/workflow/ralph/scripts/post-merge-check.mjs +651 -0
- package/skills/workflow/run-pipeline-stage/SKILL.md +37 -22
- package/skills/workflow/run-pipeline-stage/playbooks/code.md +14 -8
- package/skills/workflow/run-pipeline-stage/playbooks/document.md +2 -2
- package/skills/workflow/run-pipeline-stage/playbooks/fix.md +2 -2
- package/skills/workflow/run-pipeline-stage/playbooks/housekeeping.md +1 -1
- package/skills/workflow/run-pipeline-stage/playbooks/post-merge.md +54 -0
- package/skills/workflow/run-pipeline-stage/playbooks/review.md +37 -3
- package/skills/workflow/run-pipeline-stage/playbooks/verify.md +2 -2
- package/skills/workflow/run-pipeline-stage/prompts/stage-runner.md +55 -11
- package/skills/workflow/run-pipeline-stage/scripts/recover-brief.mjs +805 -0
- package/skills/workflow/run-pipeline-stage/scripts/trail-entry.mjs +702 -0
- 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 +21 -8
- package/token-map.README.md +27 -0
- package/token-map.json +16 -4
|
@@ -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
|
+
}
|