@jaybeeuu/agent-cortex 2.9.0 → 2.11.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 +167 -0
- package/README.md +8 -4
- package/agents/README.md +5 -0
- package/agents/guppi/agent.md +72 -2
- package/bin/installers/claude.mjs +7 -3
- package/package.json +1 -1
- package/scripts/lib/compose-agent.mjs +45 -7
- package/skills/review/review-defects/SKILL.md +2 -1
- package/skills/review/review-report/EXAMPLES.md +7 -3
- 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 +48 -32
- package/skills/workflow/hitl-queue/scripts/boundary.mjs +493 -0
- package/skills/workflow/hitl-queue/scripts/queue.mjs +464 -78
- package/skills/workflow/hitl-queue/scripts/recommend.mjs +492 -0
- package/skills/workflow/hitl-queue/scripts/scopes.mjs +479 -0
- package/skills/workflow/prioritise-work/REFERENCE.md +34 -0
- package/skills/workflow/prioritise-work/SKILL.md +29 -29
- package/skills/workflow/prioritise-work/scripts/strategy.mjs +381 -8
- package/skills/workflow/prioritise-work/templates/strategy.md +27 -1
- package/skills/workflow/ralph/REFERENCE.md +8 -1
- package/skills/workflow/ralph/scripts/prune-worktrees.mjs +247 -12
- package/token-map.README.md +14 -2
- package/token-map.json +9 -1
|
@@ -2,12 +2,24 @@
|
|
|
2
2
|
// The ranked HITL queue: one flat list of the beads that are waiting for a human
|
|
3
3
|
// answer, ordered by how much each answer unblocks.
|
|
4
4
|
//
|
|
5
|
-
// node queue.mjs queue [--
|
|
5
|
+
// node queue.mjs queue [--scopes <path>] [--shared] [--file <strategy>]
|
|
6
|
+
// [--include-ideas] [--cwd <dir>] [--json]
|
|
7
|
+
//
|
|
8
|
+
// The list spans several bd workspaces — the *scopes* — and tags every row with the scope
|
|
9
|
+
// it came from. See `scopes.mjs` for the list, the read-only guard and the daily note.
|
|
6
10
|
//
|
|
7
11
|
// One multiplier does the load-bearing work: a bead that unblocks three others outranks
|
|
8
12
|
// an otherwise identical bead that unblocks none, and the count is **transitive** — a
|
|
9
13
|
// bead blocking A, which blocks B, which blocks C, unblocks three. The walk collects
|
|
10
14
|
// distinct beads, never paths, so a diamond (one bead reached by two routes) counts once.
|
|
15
|
+
// The walk runs inside the candidate's own workspace, so two scopes may reuse a bead id
|
|
16
|
+
// without their graphs mixing.
|
|
17
|
+
//
|
|
18
|
+
// A bd `--due` date adds a second factor, and its numbers live in the strategy's
|
|
19
|
+
// `## Deadline tiers` table rather than here: the narrower the remaining time, the higher
|
|
20
|
+
// the factor, and a passed deadline is flagged `OVERDUE` in its row instead of being
|
|
21
|
+
// lifted in silence. `now` is a parameter of the build, never read deep inside the
|
|
22
|
+
// ranking, so a boundary case is deterministic.
|
|
11
23
|
//
|
|
12
24
|
// The RICE score is read from the bead's own metadata, written by the score-bead skill.
|
|
13
25
|
// This script never recomputes it and never invents one: a bead with no score is listed
|
|
@@ -17,17 +29,36 @@
|
|
|
17
29
|
// `skills/*/scripts` sits outside the check-no-sync scan; this script stays async anyway,
|
|
18
30
|
// so the rule holds everywhere. BD_PATH pins a bd binary the way the score script does.
|
|
19
31
|
//
|
|
20
|
-
// Exit codes: 0 the queue was built
|
|
21
|
-
// something
|
|
32
|
+
// Exit codes: 0 the queue was built — a scope it could not read is reported as
|
|
33
|
+
// `unavailable`, never fatal; 1 an internal failure; 2 the caller asked for something
|
|
34
|
+
// malformed.
|
|
22
35
|
|
|
23
36
|
import { readFile } from 'node:fs/promises';
|
|
24
37
|
import { pathToFileURL } from 'node:url';
|
|
25
38
|
|
|
26
39
|
import { DEFAULT_FILE, resolveStrategyPath, validateStrategy } from '../../prioritise-work/scripts/strategy.mjs';
|
|
40
|
+
import { DEFAULT_DEADLINE_TIERS, deadlineMultiplier, parseDeadlineTiers } from '../../prioritise-work/scripts/strategy.mjs';
|
|
27
41
|
import { BD_PATH, RICE_KEY, errorMessage, makeRunner, storedRice } from '../../score-bead/scripts/rice.mjs';
|
|
42
|
+
import {
|
|
43
|
+
DEFAULT_SCOPES_FILE,
|
|
44
|
+
HOME_ENV,
|
|
45
|
+
PERSONAL_KIND,
|
|
46
|
+
SHARED_ENV,
|
|
47
|
+
ScopeError,
|
|
48
|
+
ScopeWriteError,
|
|
49
|
+
emptyNote,
|
|
50
|
+
guardRunner,
|
|
51
|
+
readDailyNote,
|
|
52
|
+
readScopeConfig,
|
|
53
|
+
unavailableReason,
|
|
54
|
+
} from './scopes.mjs';
|
|
28
55
|
|
|
29
56
|
/** @typedef {import('../../score-bead/scripts/rice.mjs').StoredRice} StoredRice */
|
|
57
|
+
/** @typedef {import('../../prioritise-work/scripts/strategy.mjs').DeadlineTier} DeadlineTier */
|
|
58
|
+
/** @typedef {import('./scopes.mjs').DailyNote} DailyNote */
|
|
59
|
+
/** @typedef {import('./scopes.mjs').Scope} Scope */
|
|
30
60
|
/** @typedef {(args: string[]) => Promise<string>} Runner */
|
|
61
|
+
/** @typedef {(args: string[], scope: Scope) => Promise<string>} ScopedRunner */
|
|
31
62
|
|
|
32
63
|
// ─── Contract ─────────────────────────────────────────────────────────────────
|
|
33
64
|
|
|
@@ -55,6 +86,9 @@ export const BLOCKS_TYPE = 'blocks';
|
|
|
55
86
|
/** How many dependent ids a row names before it summarises the rest as a count. */
|
|
56
87
|
const NAMED_DEPENDENTS = 5;
|
|
57
88
|
|
|
89
|
+
/** How many milliseconds a minute holds, for showing a deadline as days and hours. */
|
|
90
|
+
const MS_PER_MINUTE = 60_000;
|
|
91
|
+
|
|
58
92
|
/** A usage mistake: the caller asked for something malformed. Exit code 2. */
|
|
59
93
|
export class UsageError extends Error {}
|
|
60
94
|
|
|
@@ -64,12 +98,15 @@ export class QueueError extends Error {}
|
|
|
64
98
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
65
99
|
|
|
66
100
|
/**
|
|
67
|
-
* One bead as `bd list` reports it.
|
|
101
|
+
* One bead as `bd list` reports it, plus the scope it was read from.
|
|
68
102
|
* @typedef {object} Candidate
|
|
69
103
|
* @property {string} id
|
|
70
104
|
* @property {string} title
|
|
71
105
|
* @property {string} status
|
|
106
|
+
* @property {string} scope
|
|
72
107
|
* @property {StoredRice | undefined} rice
|
|
108
|
+
* @property {number | undefined} dueAt The `--due` moment, epoch milliseconds.
|
|
109
|
+
* @property {number | undefined} deferUntil The `--defer` wake moment, epoch milliseconds.
|
|
73
110
|
*/
|
|
74
111
|
|
|
75
112
|
/**
|
|
@@ -77,6 +114,7 @@ export class QueueError extends Error {}
|
|
|
77
114
|
* @typedef {object} QueueRow
|
|
78
115
|
* @property {number | undefined} rank Undefined for an unscored row.
|
|
79
116
|
* @property {string} id
|
|
117
|
+
* @property {string} scope
|
|
80
118
|
* @property {string} title
|
|
81
119
|
* @property {string} status
|
|
82
120
|
* @property {boolean} inProgress
|
|
@@ -86,17 +124,37 @@ export class QueueError extends Error {}
|
|
|
86
124
|
* @property {string[]} dependentIds Those dependents, sorted, closed excluded.
|
|
87
125
|
* @property {number} dependentMultiplier 1 + ln(1 + dependents).
|
|
88
126
|
* @property {number} wipMultiplier WIP_MULTIPLIER, or 1.
|
|
127
|
+
* @property {number | undefined} dueAt The `--due` moment, epoch milliseconds.
|
|
128
|
+
* @property {number | undefined} remainingMs Distance to the deadline; negative once past.
|
|
129
|
+
* @property {number} deadlineFactor The strategy's factor; 1 without a deadline.
|
|
130
|
+
* @property {boolean} overdue The deadline has passed.
|
|
89
131
|
* @property {number | undefined} total The rounded ranking key; undefined if unscored.
|
|
90
132
|
* @property {number | undefined} scoredAtVersion
|
|
91
133
|
* @property {boolean} stale Scored against an older strategy version.
|
|
92
134
|
* @property {string | undefined} unscoredReason Why this bead holds no rank.
|
|
93
135
|
*/
|
|
94
136
|
|
|
137
|
+
/**
|
|
138
|
+
* One configured scope, as the report publishes it. `name` and `path` are null for a
|
|
139
|
+
* withheld scope: they identify a workspace the reader may not see.
|
|
140
|
+
* @typedef {object} ScopeStatus
|
|
141
|
+
* @property {string | null} name
|
|
142
|
+
* @property {string} kind
|
|
143
|
+
* @property {boolean} readOnly
|
|
144
|
+
* @property {boolean} writable
|
|
145
|
+
* @property {string | null} path
|
|
146
|
+
* @property {'available' | 'unavailable' | 'withheld'} status
|
|
147
|
+
* @property {string | undefined} reason
|
|
148
|
+
*/
|
|
149
|
+
|
|
95
150
|
/**
|
|
96
151
|
* The whole answer.
|
|
97
152
|
* @typedef {object} QueueReport
|
|
98
|
-
* @property {{ file: string, version: number | undefined }} strategy
|
|
99
|
-
* @property {
|
|
153
|
+
* @property {{ file: string, version: number | undefined, deadlineTiers: DeadlineTier[] }} strategy
|
|
154
|
+
* @property {'local' | 'shared'} invocation
|
|
155
|
+
* @property {ScopeStatus[]} scopes
|
|
156
|
+
* @property {DailyNote} today
|
|
157
|
+
* @property {{ ranked: number, unscored: number, excludedIdeas: number, excludedDeferred: number, availableScopes: number, unavailableScopes: number, withheldScopes: number }} counts
|
|
100
158
|
* @property {QueueRow[]} rows Ranked rows first, then unscored rows.
|
|
101
159
|
*/
|
|
102
160
|
|
|
@@ -119,17 +177,19 @@ export function dependentMultiplier(count) {
|
|
|
119
177
|
}
|
|
120
178
|
|
|
121
179
|
/**
|
|
122
|
-
* The ranking key: RICE × dependent multiplier × work-in-progress multiplier
|
|
123
|
-
* one decimal. The rounded figure is the sort key as well as the
|
|
124
|
-
* number a reader sees is the number the order was decided by.
|
|
180
|
+
* The ranking key: RICE × dependent multiplier × work-in-progress multiplier × deadline
|
|
181
|
+
* multiplier, rounded to one decimal. The rounded figure is the sort key as well as the
|
|
182
|
+
* printed one, so the number a reader sees is the number the order was decided by.
|
|
125
183
|
* @param {object} input
|
|
126
184
|
* @param {number} input.score
|
|
127
185
|
* @param {number} input.dependents
|
|
128
186
|
* @param {boolean} input.inProgress
|
|
187
|
+
* @param {number} [input.deadline] The strategy's factor at the bead's remaining time; 1
|
|
188
|
+
* when the bead has no deadline.
|
|
129
189
|
* @returns {number}
|
|
130
190
|
*/
|
|
131
|
-
export function queueTotal({ score, dependents, inProgress }) {
|
|
132
|
-
return round1(score * dependentMultiplier(dependents) * (inProgress ? WIP_MULTIPLIER : 1));
|
|
191
|
+
export function queueTotal({ score, dependents, inProgress, deadline = 1 }) {
|
|
192
|
+
return round1(score * dependentMultiplier(dependents) * (inProgress ? WIP_MULTIPLIER : 1) * deadline);
|
|
133
193
|
}
|
|
134
194
|
|
|
135
195
|
// ─── Tracker ──────────────────────────────────────────────────────────────────
|
|
@@ -158,6 +218,17 @@ function asString(value) {
|
|
|
158
218
|
return typeof value === 'string' ? value : undefined;
|
|
159
219
|
}
|
|
160
220
|
|
|
221
|
+
/**
|
|
222
|
+
* An ISO instant as epoch milliseconds, or undefined when the field is absent or unreadable.
|
|
223
|
+
* `bd` writes both `due_at` and `defer_until` as UTC ISO strings.
|
|
224
|
+
* @param {unknown} value @returns {number | undefined}
|
|
225
|
+
*/
|
|
226
|
+
function instantOf(value) {
|
|
227
|
+
if (typeof value !== 'string') return undefined;
|
|
228
|
+
const parsed = Date.parse(value);
|
|
229
|
+
return Number.isNaN(parsed) ? undefined : parsed;
|
|
230
|
+
}
|
|
231
|
+
|
|
161
232
|
/**
|
|
162
233
|
* A dependent edge: the bead, and the status it is in, so the count can drop finished work.
|
|
163
234
|
* @typedef {object} Dependent
|
|
@@ -246,15 +317,23 @@ export function createDependentReader(bd) {
|
|
|
246
317
|
}
|
|
247
318
|
|
|
248
319
|
/**
|
|
249
|
-
* The candidates: every open bead carrying `execution:hitl`. Closed beads are
|
|
250
|
-
* default filter;
|
|
251
|
-
*
|
|
320
|
+
* The candidates in one scope: every open bead carrying `execution:hitl`. Closed beads are
|
|
321
|
+
* bd's own default filter; an idea is unshaped, so it is counted and left out unless asked
|
|
322
|
+
* for.
|
|
323
|
+
*
|
|
324
|
+
* A deferral is bd's own field and bd's own rule, so the queue reads it rather than
|
|
325
|
+
* inventing a second one. `bd ready` wakes a bead whose `defer_until` has passed, but
|
|
326
|
+
* `bd list` keeps reporting the stale `deferred` status the moment it was parked in, so
|
|
327
|
+
* applying the wake rule to `defer_until` here keeps this list equal to bd's own view.
|
|
328
|
+
* A `deferred` bead with no readable wake moment stays out: nothing says it woke.
|
|
252
329
|
* @param {object} options
|
|
253
330
|
* @param {Runner} options.bd
|
|
331
|
+
* @param {string} options.scope The scope name every row is tagged with.
|
|
254
332
|
* @param {boolean} options.includeIdeas
|
|
333
|
+
* @param {number} options.now Epoch milliseconds the deferral is judged against.
|
|
255
334
|
* @returns {Promise<{ candidates: Candidate[], excludedIdeas: number, excludedDeferred: number }>}
|
|
256
335
|
*/
|
|
257
|
-
async function readCandidates({ bd, includeIdeas }) {
|
|
336
|
+
async function readCandidates({ bd, scope, includeIdeas, now }) {
|
|
258
337
|
const stdout = await bd(['list', '-l', HITL_LABEL, '--json', '--limit', '0']);
|
|
259
338
|
const rows = parseJsonArray(stdout, `bd list -l ${HITL_LABEL} --json`);
|
|
260
339
|
|
|
@@ -271,19 +350,23 @@ async function readCandidates({ bd, includeIdeas }) {
|
|
|
271
350
|
const status = asString(record.status) ?? 'open';
|
|
272
351
|
if (status === CLOSED_STATUS) continue;
|
|
273
352
|
const labels = Array.isArray(record.labels) ? record.labels.filter((label) => typeof label === 'string') : [];
|
|
353
|
+
const deferUntil = instantOf(record.defer_until);
|
|
274
354
|
if (!includeIdeas && labels.includes(IDEA_LABEL)) {
|
|
275
355
|
excludedIdeas += 1;
|
|
276
356
|
continue;
|
|
277
357
|
}
|
|
278
|
-
if (status === DEFERRED_STATUS) {
|
|
358
|
+
if (status === DEFERRED_STATUS && (deferUntil === undefined || deferUntil > now)) {
|
|
279
359
|
excludedDeferred += 1;
|
|
280
360
|
continue;
|
|
281
361
|
}
|
|
282
362
|
candidates.push({
|
|
283
363
|
id,
|
|
364
|
+
scope,
|
|
284
365
|
title: asString(record.title) ?? id,
|
|
285
366
|
status,
|
|
286
367
|
rice: storedRice(record.metadata),
|
|
368
|
+
dueAt: instantOf(record.due_at),
|
|
369
|
+
deferUntil,
|
|
287
370
|
});
|
|
288
371
|
}
|
|
289
372
|
|
|
@@ -291,53 +374,179 @@ async function readCandidates({ bd, includeIdeas }) {
|
|
|
291
374
|
}
|
|
292
375
|
|
|
293
376
|
/**
|
|
294
|
-
* The strategy version a score is compared
|
|
295
|
-
*
|
|
296
|
-
*
|
|
377
|
+
* The strategy a run ranks against: the version a score is compared with, and the deadline
|
|
378
|
+
* tiers its factor comes from. A missing or malformed strategy does not stop the queue:
|
|
379
|
+
* the scores are already on the beads, and refusing to rank them would hide the backlog
|
|
380
|
+
* behind a file. The version is then reported for what it is — unknown — and the tiers
|
|
381
|
+
* fall back to their documented default, which is a value a reader can look up.
|
|
297
382
|
* @param {object} options
|
|
298
383
|
* @param {string | undefined} options.file
|
|
299
384
|
* @param {string} options.cwd
|
|
300
|
-
* @returns {Promise<{ file: string, version: number | undefined }>}
|
|
385
|
+
* @returns {Promise<{ file: string, version: number | undefined, deadlineTiers: DeadlineTier[] }>}
|
|
301
386
|
*/
|
|
302
|
-
async function
|
|
387
|
+
async function readStrategy({ file, cwd }) {
|
|
303
388
|
const path = await resolveStrategyPath({ ...(file === undefined ? {} : { file }), cwd });
|
|
304
389
|
const text = await readFile(path, 'utf8').catch(() => undefined);
|
|
305
|
-
if (text === undefined)
|
|
390
|
+
if (text === undefined) {
|
|
391
|
+
return { file: path, version: undefined, deadlineTiers: [...DEFAULT_DEADLINE_TIERS] };
|
|
392
|
+
}
|
|
306
393
|
const strategy = validateStrategy(text);
|
|
307
|
-
|
|
394
|
+
const tiers = parseDeadlineTiers(text);
|
|
395
|
+
return {
|
|
396
|
+
file: path,
|
|
397
|
+
version: strategy.ok ? strategy.version : undefined,
|
|
398
|
+
deadlineTiers: tiers.ok ? tiers.tiers : [...DEFAULT_DEADLINE_TIERS],
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// ─── Scopes ───────────────────────────────────────────────────────────────────
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* One scope's half of the report: what it contributed, or why it contributed nothing.
|
|
406
|
+
* @typedef {object} ScopeRead
|
|
407
|
+
* @property {Scope} scope
|
|
408
|
+
* @property {'available' | 'unavailable' | 'withheld'} status
|
|
409
|
+
* @property {string | undefined} reason
|
|
410
|
+
* @property {Candidate[]} candidates
|
|
411
|
+
* @property {number} excludedIdeas
|
|
412
|
+
* @property {number} excludedDeferred
|
|
413
|
+
*/
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Read every configured scope.
|
|
417
|
+
*
|
|
418
|
+
* Two scopes never reach the tracker: a scope whose workspace is missing or uninitialised
|
|
419
|
+
* is `unavailable` with the reason, and a personal scope is `withheld` in a shared
|
|
420
|
+
* invocation — not read, so nothing personal is even loaded, let alone printed. A bd call
|
|
421
|
+
* that fails marks its own scope unavailable and leaves the other scopes alone. A refused
|
|
422
|
+
* write is the one error that propagates: it is a defect in this script, not in the machine.
|
|
423
|
+
*
|
|
424
|
+
* @param {object} options
|
|
425
|
+
* @param {ScopedRunner} options.bd
|
|
426
|
+
* @param {Scope[]} options.scopes
|
|
427
|
+
* @param {boolean} options.includeIdeas
|
|
428
|
+
* @param {number} options.now Epoch milliseconds the deferral is judged against.
|
|
429
|
+
* @param {boolean} options.shared
|
|
430
|
+
* @returns {Promise<ScopeRead[]>}
|
|
431
|
+
*/
|
|
432
|
+
async function readScopes({ bd, scopes, includeIdeas, now, shared }) {
|
|
433
|
+
/** @type {ScopeRead[]} */
|
|
434
|
+
const reads = [];
|
|
435
|
+
|
|
436
|
+
for (const scope of scopes) {
|
|
437
|
+
/** @type {ScopeRead} */
|
|
438
|
+
const empty = { scope, status: 'unavailable', reason: undefined, candidates: [], excludedIdeas: 0, excludedDeferred: 0 };
|
|
439
|
+
|
|
440
|
+
if (shared && scope.kind === PERSONAL_KIND) {
|
|
441
|
+
reads.push({ ...empty, status: 'withheld', reason: 'personal scope, withheld in a shared invocation' });
|
|
442
|
+
continue;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const reason = await unavailableReason(scope);
|
|
446
|
+
if (reason !== undefined) {
|
|
447
|
+
reads.push({ ...empty, reason });
|
|
448
|
+
continue;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
try {
|
|
452
|
+
const read = await readCandidates({ bd: guardRunner(scope, bd), scope: scope.name, includeIdeas, now });
|
|
453
|
+
reads.push({ ...empty, ...read, status: 'available' });
|
|
454
|
+
} catch (error) {
|
|
455
|
+
if (error instanceof ScopeWriteError) throw error;
|
|
456
|
+
reads.push({ ...empty, reason: `bd failed: ${errorMessage(error)}` });
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
return reads;
|
|
308
461
|
}
|
|
309
462
|
|
|
310
463
|
// ─── Queue ────────────────────────────────────────────────────────────────────
|
|
311
464
|
|
|
312
465
|
/**
|
|
313
|
-
*
|
|
466
|
+
* One scope as the report publishes it.
|
|
467
|
+
*
|
|
468
|
+
* A withheld scope publishes no `name` and no `path`. Both identify the workspace a shared
|
|
469
|
+
* invocation is not allowed to see: a path encodes the home directory, and a name is
|
|
470
|
+
* whatever the owner called that workspace, so publishing either one while claiming the
|
|
471
|
+
* scope is withheld is a partial disclosure. `kind`, `readOnly` and `writable` stay,
|
|
472
|
+
* because the withholding rule keys on `kind` and the header already counts the withheld
|
|
473
|
+
* scopes — those disclose only that a personal scope exists, which the count says anyway.
|
|
474
|
+
* @param {ScopeRead} read @returns {ScopeStatus}
|
|
475
|
+
*/
|
|
476
|
+
function publishedScope(read) {
|
|
477
|
+
const withheld = read.status === 'withheld';
|
|
478
|
+
return {
|
|
479
|
+
name: withheld ? null : read.scope.name,
|
|
480
|
+
kind: read.scope.kind,
|
|
481
|
+
readOnly: read.scope.readOnly,
|
|
482
|
+
writable: read.scope.writable,
|
|
483
|
+
path: withheld ? null : read.scope.path,
|
|
484
|
+
status: read.status,
|
|
485
|
+
reason: read.reason,
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Build the queue: read every scope, derive each row, then order them.
|
|
314
491
|
*
|
|
315
|
-
* The order is total (desc), then stored score (desc), then
|
|
316
|
-
* because the total is rounded and because the logarithm
|
|
317
|
-
* dependent counts onto the same one-decimal total. The
|
|
318
|
-
*
|
|
319
|
-
*
|
|
492
|
+
* The order is total (desc), then stored score (desc), then scope name, then id (both
|
|
493
|
+
* asc). A tie is reachable because the total is rounded and because the logarithm
|
|
494
|
+
* flattens: `ln` maps different dependent counts onto the same one-decimal total. The
|
|
495
|
+
* tie-break is fixed, and the scope name breaks it before the id because two workspaces
|
|
496
|
+
* may hold the same bead id, so two runs over one backlog print byte-identical output — a
|
|
497
|
+
* queue that reordered itself between reads could not be trusted to say what to do first.
|
|
320
498
|
*
|
|
321
499
|
* An unscored row keeps its place in the list, after the ranked rows, and carries the
|
|
322
500
|
* reason. Nothing is dropped silently and no score is invented: the gap is the answer.
|
|
323
501
|
*
|
|
324
502
|
* @param {object} options
|
|
325
|
-
* @param {
|
|
503
|
+
* @param {ScopedRunner} [options.bd] The tracker; defaults to one bd call per scope.
|
|
326
504
|
* @param {string | undefined} [options.file] Strategy path; defaults to the documented default.
|
|
505
|
+
* @param {string | undefined} [options.scopesFile] Scope list; defaults to the documented default.
|
|
327
506
|
* @param {string} [options.cwd]
|
|
328
507
|
* @param {boolean} [options.includeIdeas]
|
|
508
|
+
* @param {number} [options.now] Epoch milliseconds the deadlines are measured from.
|
|
509
|
+
* Injectable so a tier boundary test is deterministic.
|
|
510
|
+
* @param {Scope[]} [options.scopes] An already-resolved scope list, for a caller.
|
|
511
|
+
* @param {boolean} [options.shared] Output that will leave this machine.
|
|
329
512
|
* @returns {Promise<QueueReport>}
|
|
330
513
|
*/
|
|
331
|
-
export async function buildQueue({
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
514
|
+
export async function buildQueue({
|
|
515
|
+
bd,
|
|
516
|
+
file,
|
|
517
|
+
scopesFile,
|
|
518
|
+
cwd = process.cwd(),
|
|
519
|
+
includeIdeas = false,
|
|
520
|
+
scopes,
|
|
521
|
+
shared = false,
|
|
522
|
+
now = Date.now(),
|
|
523
|
+
}) {
|
|
524
|
+
const runBd = bd ?? (async (args, scope) => await makeRunner(BD_PATH, scope.path)(args));
|
|
525
|
+
const scopeList = scopes ??
|
|
526
|
+
(await readScopeConfig({ cwd, ...(scopesFile === undefined ? {} : { file: scopesFile }) })).scopes;
|
|
527
|
+
|
|
528
|
+
const [strategy, reads, today] = await Promise.all([
|
|
529
|
+
readStrategy({ file, cwd }),
|
|
530
|
+
readScopes({ bd: runBd, scopes: scopeList, includeIdeas, shared, now }),
|
|
531
|
+
shared ? Promise.resolve({ ...emptyNote('withheld in a shared invocation'), withheld: true }) : readDailyNote(scopeList),
|
|
532
|
+
|
|
335
533
|
]);
|
|
336
|
-
|
|
534
|
+
|
|
535
|
+
/** @type {Map<string, ReturnType<typeof createDependentReader>>} */
|
|
536
|
+
const readers = new Map();
|
|
537
|
+
for (const read of reads) {
|
|
538
|
+
if (read.status !== 'available') continue;
|
|
539
|
+
readers.set(read.scope.name, createDependentReader(guardRunner(read.scope, runBd)));
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
const candidates = reads.flatMap((read) => read.candidates);
|
|
543
|
+
const excludedIdeas = reads.reduce((total, read) => total + read.excludedIdeas, 0);
|
|
544
|
+
const excludedDeferred = reads.reduce((total, read) => total + read.excludedDeferred, 0);
|
|
337
545
|
|
|
338
546
|
/** @type {QueueRow[]} */
|
|
339
547
|
const rows = [];
|
|
340
548
|
for (const candidate of candidates) {
|
|
549
|
+
const dependentsOf = /** @type {ReturnType<typeof createDependentReader>} */ (readers.get(candidate.scope));
|
|
341
550
|
const dependents = await dependentsOf(candidate.id);
|
|
342
551
|
const live = [...dependents.values()]
|
|
343
552
|
.filter((dependent) => dependent.status !== CLOSED_STATUS)
|
|
@@ -345,6 +554,10 @@ export async function buildQueue({ bd, file, cwd = process.cwd(), includeIdeas =
|
|
|
345
554
|
.sort();
|
|
346
555
|
const inProgress = candidate.status === IN_PROGRESS_STATUS;
|
|
347
556
|
const score = candidate.rice?.score;
|
|
557
|
+
const remainingMs = candidate.dueAt === undefined ? undefined : candidate.dueAt - now;
|
|
558
|
+
const deadline = remainingMs === undefined
|
|
559
|
+
? { factor: 1, overdue: false }
|
|
560
|
+
: deadlineMultiplier(remainingMs, strategy.deadlineTiers);
|
|
348
561
|
const scoredAtVersion = candidate.rice?.strategyVersion;
|
|
349
562
|
const stale =
|
|
350
563
|
score !== undefined &&
|
|
@@ -355,6 +568,7 @@ export async function buildQueue({ bd, file, cwd = process.cwd(), includeIdeas =
|
|
|
355
568
|
rows.push({
|
|
356
569
|
rank: undefined,
|
|
357
570
|
id: candidate.id,
|
|
571
|
+
scope: candidate.scope,
|
|
358
572
|
title: candidate.title.replace(/\s+/g, ' ').trim(),
|
|
359
573
|
status: candidate.status,
|
|
360
574
|
inProgress,
|
|
@@ -364,9 +578,13 @@ export async function buildQueue({ bd, file, cwd = process.cwd(), includeIdeas =
|
|
|
364
578
|
dependentIds: live,
|
|
365
579
|
dependentMultiplier: dependentMultiplier(live.length),
|
|
366
580
|
wipMultiplier: inProgress ? WIP_MULTIPLIER : 1,
|
|
581
|
+
dueAt: candidate.dueAt,
|
|
582
|
+
remainingMs,
|
|
583
|
+
deadlineFactor: deadline.factor,
|
|
584
|
+
overdue: deadline.overdue,
|
|
367
585
|
total: score === undefined
|
|
368
586
|
? undefined
|
|
369
|
-
: queueTotal({ score, dependents: live.length, inProgress }),
|
|
587
|
+
: queueTotal({ score, dependents: live.length, inProgress, deadline: deadline.factor }),
|
|
370
588
|
scoredAtVersion,
|
|
371
589
|
stale,
|
|
372
590
|
unscoredReason: score === undefined
|
|
@@ -384,7 +602,7 @@ export async function buildQueue({ bd, file, cwd = process.cwd(), includeIdeas =
|
|
|
384
602
|
if (byTotal !== 0) return byTotal;
|
|
385
603
|
const byScore = /** @type {number} */ (b.score) - /** @type {number} */ (a.score);
|
|
386
604
|
if (byScore !== 0) return byScore;
|
|
387
|
-
return a
|
|
605
|
+
return compareScopeThenId(a, b);
|
|
388
606
|
});
|
|
389
607
|
ranked.forEach((row, index) => {
|
|
390
608
|
row.rank = index + 1;
|
|
@@ -392,20 +610,38 @@ export async function buildQueue({ bd, file, cwd = process.cwd(), includeIdeas =
|
|
|
392
610
|
|
|
393
611
|
const unscored = rows
|
|
394
612
|
.filter((row) => row.total === undefined)
|
|
395
|
-
.sort(
|
|
613
|
+
.sort(compareScopeThenId);
|
|
614
|
+
|
|
615
|
+
const available = reads.filter((read) => read.status === 'available').length;
|
|
396
616
|
|
|
397
617
|
return {
|
|
398
618
|
strategy,
|
|
619
|
+
invocation: shared ? 'shared' : 'local',
|
|
620
|
+
scopes: reads.map(publishedScope),
|
|
621
|
+
today,
|
|
399
622
|
counts: {
|
|
400
623
|
ranked: ranked.length,
|
|
401
624
|
unscored: unscored.length,
|
|
402
625
|
excludedIdeas,
|
|
403
626
|
excludedDeferred,
|
|
627
|
+
availableScopes: available,
|
|
628
|
+
unavailableScopes: reads.filter((read) => read.status === 'unavailable').length,
|
|
629
|
+
withheldScopes: reads.filter((read) => read.status === 'withheld').length,
|
|
404
630
|
},
|
|
405
631
|
rows: [...ranked, ...unscored],
|
|
406
632
|
};
|
|
407
633
|
}
|
|
408
634
|
|
|
635
|
+
/**
|
|
636
|
+
* The tie-break two workspaces both reach: the scope name first, because two workspaces may
|
|
637
|
+
* hold the same bead id, then the id. Stable and total, so one backlog prints one order.
|
|
638
|
+
* @param {QueueRow} a @param {QueueRow} b @returns {number}
|
|
639
|
+
*/
|
|
640
|
+
function compareScopeThenId(a, b) {
|
|
641
|
+
if (a.scope !== b.scope) return a.scope < b.scope ? -1 : 1;
|
|
642
|
+
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
|
|
643
|
+
}
|
|
644
|
+
|
|
409
645
|
// ─── Render ───────────────────────────────────────────────────────────────────
|
|
410
646
|
|
|
411
647
|
/** @param {number} count @param {string} noun @returns {string} */
|
|
@@ -420,20 +656,79 @@ function right(text, width) {
|
|
|
420
656
|
|
|
421
657
|
/**
|
|
422
658
|
* The components as the score record holds them, so a reader can check the arithmetic
|
|
423
|
-
* without opening the bead.
|
|
659
|
+
* without opening the bead. Exported so the one-item recommendation prints the same
|
|
660
|
+
* breakdown rather than a second copy of it that could drift.
|
|
424
661
|
* @param {StoredRice} rice @returns {string}
|
|
425
662
|
*/
|
|
426
|
-
function
|
|
663
|
+
export function formatComponents(rice) {
|
|
427
664
|
const part = (/** @type {number | undefined} */ value, /** @type {string} */ letter) =>
|
|
428
665
|
`${letter}${value === undefined ? '?' : value}`;
|
|
429
666
|
return `${part(rice.reach, 'R')} · ${part(rice.impact, 'I')} · ${part(rice.confidence, 'C')} · ${part(rice.effort, 'E')}`;
|
|
430
667
|
}
|
|
431
668
|
|
|
669
|
+
/**
|
|
670
|
+
* A span of milliseconds as the largest units that fit, so a row reads `2d 23h` instead of
|
|
671
|
+
* a count of milliseconds. Rounded down: the row shows the time left, never a figure the
|
|
672
|
+
* deadline has not reached.
|
|
673
|
+
* @param {number} ms @returns {string}
|
|
674
|
+
*/
|
|
675
|
+
function formatSpan(ms) {
|
|
676
|
+
const totalMinutes = Math.floor(Math.abs(ms) / MS_PER_MINUTE);
|
|
677
|
+
const days = Math.floor(totalMinutes / 1440);
|
|
678
|
+
const hours = Math.floor((totalMinutes % 1440) / 60);
|
|
679
|
+
const parts = [];
|
|
680
|
+
if (days > 0) parts.push(`${days}d`);
|
|
681
|
+
if (hours > 0) parts.push(`${hours}h`);
|
|
682
|
+
if (parts.length === 0) parts.push(`${totalMinutes % 60}m`);
|
|
683
|
+
return parts.join(' ');
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/**
|
|
687
|
+
* The due moment in UTC, to the minute: a fixed form, so two runs print it identically.
|
|
688
|
+
* @param {number} instant @returns {string}
|
|
689
|
+
*/
|
|
690
|
+
function formatInstant(instant) {
|
|
691
|
+
return `${new Date(instant).toISOString().slice(0, 16)}Z`;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* The distance and the due moment, with the word `OVERDUE` once the moment has passed.
|
|
696
|
+
* Empty for a bead with no deadline. No factor here: the scored clause adds one.
|
|
697
|
+
* @param {QueueRow} row @returns {string}
|
|
698
|
+
*/
|
|
699
|
+
function dueClause(row) {
|
|
700
|
+
if (row.dueAt === undefined || row.remainingMs === undefined) return '';
|
|
701
|
+
const due = formatInstant(row.dueAt);
|
|
702
|
+
const span = formatSpan(row.remainingMs);
|
|
703
|
+
return row.overdue ? `OVERDUE by ${span} (due ${due})` : `in ${span} (due ${due})`;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/**
|
|
707
|
+
* The deadline as one clause: the factor and the due clause. Empty for a bead with no
|
|
708
|
+
* deadline, which keeps the factor 1 and is not ranked down for lacking one.
|
|
709
|
+
* @param {QueueRow} row @returns {string}
|
|
710
|
+
*/
|
|
711
|
+
function deadlineClause(row) {
|
|
712
|
+
const due = dueClause(row);
|
|
713
|
+
return due === '' ? '' : ` deadline ×${row.deadlineFactor} ${due}`;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/**
|
|
717
|
+
* The same moment for a row with no total. An unscored row has no product to scale, so it
|
|
718
|
+
* shows the due moment and the slip and claims no factor — the deadline is still visible.
|
|
719
|
+
* @param {QueueRow} row @returns {string}
|
|
720
|
+
*/
|
|
721
|
+
function unscoredDueClause(row) {
|
|
722
|
+
const due = dueClause(row);
|
|
723
|
+
if (due === '') return '';
|
|
724
|
+
return row.overdue ? ` ${due}` : ` due ${due}`;
|
|
725
|
+
}
|
|
726
|
+
|
|
432
727
|
/**
|
|
433
728
|
* What the answer unblocks, in one clause: named ids while they fit, a count after that.
|
|
434
729
|
* @param {QueueRow} row @returns {string}
|
|
435
730
|
*/
|
|
436
|
-
function
|
|
731
|
+
export function formatUnblocks(row) {
|
|
437
732
|
if (row.dependentIds.length === 0) return 'unblocks nothing';
|
|
438
733
|
const named = row.dependentIds.slice(0, NAMED_DEPENDENTS);
|
|
439
734
|
const rest = row.dependentIds.length - named.length;
|
|
@@ -441,59 +736,116 @@ function unblocks(row) {
|
|
|
441
736
|
}
|
|
442
737
|
|
|
443
738
|
/**
|
|
444
|
-
*
|
|
445
|
-
*
|
|
739
|
+
* The scope summary for the header: how many scopes the run had, and how many it could not
|
|
740
|
+
* read. A scope that could not be read is counted in the open, never quietly dropped.
|
|
741
|
+
* @param {QueueReport['counts']} counts @returns {string}
|
|
742
|
+
*/
|
|
743
|
+
function scopeSummary(counts) {
|
|
744
|
+
const total = counts.availableScopes + counts.unavailableScopes + counts.withheldScopes;
|
|
745
|
+
const parts = [plural(total, 'scope')];
|
|
746
|
+
if (counts.unavailableScopes > 0) parts.push(`${counts.unavailableScopes} unavailable`);
|
|
747
|
+
if (counts.withheldScopes > 0) parts.push(`${counts.withheldScopes} withheld`);
|
|
748
|
+
return parts.join(', ');
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/**
|
|
752
|
+
* The daily scope note, above the list: which scopes the day belongs to, and the strategy
|
|
753
|
+
* line that says so. A shared invocation withholds it, because the quote is personal
|
|
754
|
+
* strategy text; nothing personal is read, let alone printed, off this machine.
|
|
755
|
+
* @param {DailyNote} today @returns {string}
|
|
756
|
+
*/
|
|
757
|
+
export function renderToday(today) {
|
|
758
|
+
if (today.withheld === true) return 'today · withheld (shared invocation) — personal strategy text';
|
|
759
|
+
if (today.reason !== undefined) return `today · no daily note — ${today.reason}`;
|
|
760
|
+
|
|
761
|
+
const names = today.scopes.length === 0 ? 'unnamed scopes' : today.scopes.join(', ');
|
|
762
|
+
const version = today.strategyVersion === undefined ? 'strategy unknown' : `strategy v${today.strategyVersion}`;
|
|
763
|
+
const section = today.section === undefined ? '' : `, "## ${today.section}"`;
|
|
764
|
+
return `today · ${names} — "${today.line}" (${version}${section}, scope ${today.scopeName})`;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
/**
|
|
768
|
+
* The scopes that contributed nothing, one clause each, so a missing personal workspace is
|
|
769
|
+
* visible instead of looking like an empty backlog.
|
|
770
|
+
* @param {QueueReport} report @returns {string}
|
|
771
|
+
*/
|
|
772
|
+
function unavailableLine(report) {
|
|
773
|
+
const quiet = report.scopes.filter((scope) => scope.status === 'unavailable');
|
|
774
|
+
if (quiet.length === 0) return '';
|
|
775
|
+
return `unavailable: ${quiet.map((scope) => `${scope.name} (${scope.path}) — ${scope.reason ?? 'unknown'}`).join('; ')}\n`;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
/**
|
|
779
|
+
* Render the queue as text: a header naming the formula and the scopes, the daily scope
|
|
780
|
+
* note, then one block per row — its scope tag and numbers, then its title and what the
|
|
781
|
+
* answer unblocks.
|
|
446
782
|
* @param {QueueReport} report @returns {string}
|
|
447
783
|
*/
|
|
448
784
|
export function renderQueue(report) {
|
|
449
|
-
const { counts, strategy, rows } = report;
|
|
785
|
+
const { counts, strategy, rows, today } = report;
|
|
450
786
|
const strategyLabel = strategy.version === undefined ? 'strategy unknown' : `strategy v${strategy.version}`;
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
787
|
+
const scopes = scopeSummary(counts);
|
|
788
|
+
|
|
789
|
+
const head = rows.length === 0
|
|
790
|
+
? [`HITL decision queue · no open ${HITL_LABEL} beads · ${scopes}`, renderToday(today)]
|
|
791
|
+
: [
|
|
792
|
+
`HITL decision queue · ${counts.ranked} ranked, ${counts.unscored} unscored · ${scopes} · ${strategyLabel}`,
|
|
793
|
+
'total = rice × (1 + ln(1 + dependents)) × 1.5 in-progress × deadline tier · ties break on rice, then scope, then id · unscored last',
|
|
794
|
+
renderToday(today),
|
|
795
|
+
];
|
|
796
|
+
|
|
797
|
+
const excluded = formatExcluded(counts);
|
|
798
|
+
const unavailable = unavailableLine(report);
|
|
799
|
+
const hasNoScore = counts.ranked === 0 && rows.length > 0;
|
|
800
|
+
for (const line of [excluded, unavailable]) if (line !== '') head.push(line.trimEnd());
|
|
801
|
+
if (hasNoScore) {
|
|
802
|
+
head.push('no RICE score on any candidate — nothing can be ranked; score one with score-bead, then re-run');
|
|
455
803
|
}
|
|
804
|
+
if (rows.length === 0) return `${head.join('\n')}\n`;
|
|
456
805
|
|
|
457
806
|
const rankWidth = Math.max(1, String(counts.ranked).length);
|
|
807
|
+
const scopeWidth = Math.max(...rows.map((row) => row.scope.length));
|
|
458
808
|
const idWidth = Math.max(...rows.map((row) => row.id.length));
|
|
459
809
|
const totalWidth = Math.max(...rows.map((row) => (row.total === undefined ? 1 : row.total.toFixed(1).length)));
|
|
460
810
|
|
|
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('');
|
|
811
|
+
const lines = [...head, ''];
|
|
473
812
|
|
|
474
813
|
for (const row of rows) {
|
|
475
814
|
const rank = row.rank === undefined ? '-' : String(row.rank);
|
|
476
815
|
const total = row.total === undefined ? '—' : row.total.toFixed(1);
|
|
477
816
|
const numbers = row.score === undefined
|
|
478
|
-
? `unscored — ${row.unscoredReason ?? 'no score'} (score it with score-bead)`
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
` ×${row.wipMultiplier.toFixed(1)}${row.inProgress ? ' in-progress' : ''}`;
|
|
817
|
+
? `unscored — ${row.unscoredReason ?? 'no score'} (score it with score-bead)` +
|
|
818
|
+
unscoredDueClause(row)
|
|
819
|
+
: formatNumbers(row);
|
|
482
820
|
const stale = row.stale ? ` (scored at strategy v${row.scoredAtVersion}, current v${strategy.version})` : '';
|
|
483
821
|
lines.push(
|
|
484
|
-
`${right(rank, rankWidth)} ${row.
|
|
822
|
+
`${right(rank, rankWidth)} ${row.scope.padEnd(scopeWidth)} ${row.id.padEnd(idWidth)} ` +
|
|
823
|
+
`${right(total, totalWidth)} ${numbers}${stale}`,
|
|
485
824
|
);
|
|
486
|
-
lines.push(` ${row.title} — ${
|
|
825
|
+
lines.push(` ${row.title} — ${formatUnblocks(row)}`);
|
|
487
826
|
}
|
|
488
827
|
|
|
489
828
|
return `${lines.join('\n')}\n`;
|
|
490
829
|
}
|
|
491
830
|
|
|
831
|
+
/**
|
|
832
|
+
* The score line of one row: the stored RICE total, the four components it was computed
|
|
833
|
+
* from, and both multipliers. Exported so the one-item recommendation prints the queue's
|
|
834
|
+
* own numbers instead of deriving them a second time.
|
|
835
|
+
* @param {QueueRow} row @returns {string}
|
|
836
|
+
*/
|
|
837
|
+
export function formatNumbers(row) {
|
|
838
|
+
return `rice ${/** @type {number} */ (row.score).toFixed(1)} = ${formatComponents(/** @type {StoredRice} */ (row.rice))}` +
|
|
839
|
+
` ×${row.dependentMultiplier.toFixed(2)} dependents(${row.dependents})` +
|
|
840
|
+
` ×${row.wipMultiplier.toFixed(1)}${row.inProgress ? ' in-progress' : ''}` +
|
|
841
|
+
deadlineClause(row);
|
|
842
|
+
}
|
|
843
|
+
|
|
492
844
|
/**
|
|
493
845
|
* The excluded counts, so nothing leaves the list without a trace.
|
|
494
846
|
* @param {QueueReport['counts']} counts @returns {string}
|
|
495
847
|
*/
|
|
496
|
-
function
|
|
848
|
+
export function formatExcluded(counts) {
|
|
497
849
|
if (counts.excludedIdeas === 0 && counts.excludedDeferred === 0) return '';
|
|
498
850
|
const parts = [];
|
|
499
851
|
if (counts.excludedIdeas > 0) {
|
|
@@ -510,35 +862,46 @@ function excludedLine(counts) {
|
|
|
510
862
|
/** @type {readonly string[]} */
|
|
511
863
|
export const COMMANDS = /** @type {const} */ (['queue']);
|
|
512
864
|
|
|
513
|
-
const USAGE = `Usage: node queue.mjs queue [--
|
|
865
|
+
const USAGE = `Usage: node queue.mjs queue [--scopes <path>] [--shared] [--file <path>]
|
|
866
|
+
[--include-ideas] [--cwd <dir>] [--json]
|
|
514
867
|
|
|
515
|
-
queue Rank the open ${HITL_LABEL} beads into one list:
|
|
516
|
-
× 1
|
|
868
|
+
queue Rank the open ${HITL_LABEL} beads from every configured scope into one list:
|
|
869
|
+
RICE × (1 + ln(1 + dependents)) × 1.5 when in progress, × the strategy's
|
|
870
|
+
deadline factor at the bead's --due. Each row carries the scope it came from.
|
|
871
|
+
A bead with no score is listed as unscored, never guessed.
|
|
517
872
|
|
|
518
873
|
Options:
|
|
874
|
+
--scopes <path> Scope list (default: ${DEFAULT_SCOPES_FILE} from the repository root,
|
|
875
|
+
then ~/.agent-cortex/scopes.json, then the current workspace alone)
|
|
876
|
+
--shared Output that leaves this machine: personal scopes are not read
|
|
519
877
|
--file <path> Strategy file (default: ${DEFAULT_FILE}, relative to the repository root)
|
|
520
878
|
--include-ideas Rank ${IDEA_LABEL} beads too (they are excluded by default)
|
|
521
|
-
--cwd <dir> Directory the strategy
|
|
879
|
+
--cwd <dir> Directory the strategy, scope paths and bd resolve from
|
|
522
880
|
--json Print the report as JSON
|
|
523
881
|
-h, --help Show this text
|
|
524
882
|
|
|
525
883
|
Environment:
|
|
526
|
-
BD_PATH
|
|
884
|
+
BD_PATH bd binary (default: bd)
|
|
885
|
+
${HOME_ENV} Install root the user-level scope list is read from
|
|
886
|
+
${SHARED_ENV} Set to 1 to run as a shared invocation
|
|
527
887
|
`;
|
|
528
888
|
|
|
529
889
|
/**
|
|
530
890
|
* Parse the CLI's argv.
|
|
531
891
|
* @param {string[]} argv
|
|
532
|
-
* @returns {{ command: string, file?: string, includeIdeas: boolean, cwd: string, json: boolean }}
|
|
892
|
+
* @returns {{ command: string, file?: string, scopesFile?: string, includeIdeas: boolean, shared: boolean, cwd: string, json: boolean }}
|
|
533
893
|
*/
|
|
534
894
|
export function parseArgs(argv) {
|
|
535
895
|
/** @type {string | undefined} */
|
|
536
896
|
let command;
|
|
537
897
|
/** @type {string | undefined} */
|
|
538
898
|
let file;
|
|
899
|
+
/** @type {string | undefined} */
|
|
900
|
+
let scopesFile;
|
|
539
901
|
let cwd = process.cwd();
|
|
540
902
|
let json = false;
|
|
541
903
|
let includeIdeas = false;
|
|
904
|
+
let shared = false;
|
|
542
905
|
|
|
543
906
|
for (let i = 0; i < argv.length; i++) {
|
|
544
907
|
const arg = argv[i];
|
|
@@ -550,10 +913,15 @@ export function parseArgs(argv) {
|
|
|
550
913
|
includeIdeas = true;
|
|
551
914
|
continue;
|
|
552
915
|
}
|
|
553
|
-
if (arg === '--
|
|
916
|
+
if (arg === '--shared') {
|
|
917
|
+
shared = true;
|
|
918
|
+
continue;
|
|
919
|
+
}
|
|
920
|
+
if (arg === '--file' || arg === '--cwd' || arg === '--scopes') {
|
|
554
921
|
const value = argv[++i];
|
|
555
922
|
if (value === undefined) throw new UsageError(`${arg} needs a value`);
|
|
556
923
|
if (arg === '--cwd') cwd = value;
|
|
924
|
+
else if (arg === '--scopes') scopesFile = value;
|
|
557
925
|
else file = value;
|
|
558
926
|
continue;
|
|
559
927
|
}
|
|
@@ -567,7 +935,15 @@ export function parseArgs(argv) {
|
|
|
567
935
|
}
|
|
568
936
|
|
|
569
937
|
if (command === undefined) throw new UsageError(`a command is required: ${COMMANDS.join(', ')}`);
|
|
570
|
-
return {
|
|
938
|
+
return {
|
|
939
|
+
command,
|
|
940
|
+
...(file === undefined ? {} : { file }),
|
|
941
|
+
...(scopesFile === undefined ? {} : { scopesFile }),
|
|
942
|
+
includeIdeas,
|
|
943
|
+
shared,
|
|
944
|
+
cwd,
|
|
945
|
+
json,
|
|
946
|
+
};
|
|
571
947
|
}
|
|
572
948
|
|
|
573
949
|
/**
|
|
@@ -575,7 +951,7 @@ export function parseArgs(argv) {
|
|
|
575
951
|
* @param {string[]} options.argv CLI arguments (without node/script).
|
|
576
952
|
* @param {(chunk: string) => void} [options.write] stdout sink; injectable for tests.
|
|
577
953
|
* @param {(chunk: string) => void} [options.writeError] stderr sink; injectable for tests.
|
|
578
|
-
* @param {{ bd?:
|
|
954
|
+
* @param {{ bd?: ScopedRunner }} [options.deps] Collaborators; injectable for tests.
|
|
579
955
|
* @returns {Promise<number>} process exit code.
|
|
580
956
|
*/
|
|
581
957
|
export async function runCli({
|
|
@@ -597,22 +973,32 @@ export async function runCli({
|
|
|
597
973
|
return 2;
|
|
598
974
|
}
|
|
599
975
|
|
|
600
|
-
const bd = deps.bd ?? makeRunner(BD_PATH,
|
|
976
|
+
const bd = deps.bd ?? (async (args, scope) => await makeRunner(BD_PATH, scope.path)(args));
|
|
977
|
+
const shared = options.shared || isTruthy(process.env[SHARED_ENV]);
|
|
978
|
+
|
|
601
979
|
try {
|
|
602
980
|
const report = await buildQueue({
|
|
603
981
|
bd,
|
|
604
982
|
cwd: options.cwd,
|
|
605
983
|
includeIdeas: options.includeIdeas,
|
|
984
|
+
shared,
|
|
606
985
|
...(options.file === undefined ? {} : { file: options.file }),
|
|
986
|
+
...(options.scopesFile === undefined ? {} : { scopesFile: options.scopesFile }),
|
|
607
987
|
});
|
|
608
988
|
write(options.json ? `${JSON.stringify(report, null, 2)}\n` : renderQueue(report));
|
|
609
989
|
return 0;
|
|
610
990
|
} catch (error) {
|
|
991
|
+
// A scope list that cannot be read is the caller's mistake; anything else is a defect.
|
|
611
992
|
writeError(`queue failed: ${errorMessage(error)}\n`);
|
|
612
|
-
return 1;
|
|
993
|
+
return error instanceof ScopeError ? 2 : 1;
|
|
613
994
|
}
|
|
614
995
|
}
|
|
615
996
|
|
|
997
|
+
/** @param {string | undefined} value @returns {boolean} */
|
|
998
|
+
function isTruthy(value) {
|
|
999
|
+
return value !== undefined && ['1', 'true', 'yes'].includes(value.trim().toLowerCase());
|
|
1000
|
+
}
|
|
1001
|
+
|
|
616
1002
|
// Only run the CLI when invoked directly, so tests can import the module.
|
|
617
1003
|
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
618
1004
|
runCli({ argv: process.argv.slice(2) })
|