@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.
@@ -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 [--file <strategy>] [--include-ideas] [--cwd <dir>] [--json]
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, 1 the tracker call failed, 2 the caller asked for
21
- // something malformed.
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 {{ ranked: number, unscored: number, excludedIdeas: number, excludedDeferred: number }} counts
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, 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.
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 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.
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 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.
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 readStrategyVersion({ file, cwd }) {
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) return { file: path, version: undefined };
390
+ if (text === undefined) {
391
+ return { file: path, version: undefined, deadlineTiers: [...DEFAULT_DEADLINE_TIERS] };
392
+ }
306
393
  const strategy = validateStrategy(text);
307
- return { file: path, version: strategy.ok ? strategy.version : undefined };
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
- * Build the queue: read the candidates, derive each row, then order them.
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 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.
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 {Runner} options.bd
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({ bd, file, cwd = process.cwd(), includeIdeas = false }) {
332
- const [strategy, { candidates, excludedIdeas, excludedDeferred }] = await Promise.all([
333
- readStrategyVersion({ file, cwd }),
334
- readCandidates({ bd, includeIdeas }),
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
- const dependentsOf = createDependentReader(bd);
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.id < b.id ? -1 : a.id > b.id ? 1 : 0;
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((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
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 components(rice) {
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 unblocks(row) {
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
- * 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.
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
- if (rows.length === 0) {
453
- const excluded = excludedLine(counts);
454
- return `HITL decision queue · no open ${HITL_LABEL} beads\n${excluded}`;
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
- : `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' : ''}`;
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.id.padEnd(idWidth)} ${right(total, totalWidth)} ${numbers}${stale}`,
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} — ${unblocks(row)}`);
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 excludedLine(counts) {
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 [--file <path>] [--include-ideas] [--cwd <dir>] [--json]
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: RICE × (1 + ln(1 + dependents))
516
- × 1.5 when in progress. A bead with no score is listed as unscored, never guessed.
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 path and bd resolve from
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 bd binary (default: bd)
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 === '--file' || arg === '--cwd') {
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 { command, ...(file === undefined ? {} : { file }), includeIdeas, cwd, json };
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?: Runner }} [options.deps] Collaborators; injectable for tests.
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, options.cwd);
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) })