@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.
@@ -0,0 +1,493 @@
1
+ // @ts-check
2
+ // A boundary re-rank: the theme's next item against the queue's top item, both scores on screen.
3
+ //
4
+ // node boundary.mjs boundary --theme <epic-id> [--next <id>] [--decide <stay|switch|move>]
5
+ // [--reason "<what the decision buys>"] [--file <path>]
6
+ // [--include-ideas] [--cwd <dir>] [--json]
7
+ //
8
+ // guppi works one theme — one epic — and re-ranks at every item boundary. The comparison is the
9
+ // only thing that may justify leaving: this script prints the theme's next item and the queue's
10
+ // top item with the totals the queue computed, and says which way those two numbers point.
11
+ // It adds no arithmetic, so the number a reader checks is the number the queue ranked by.
12
+ //
13
+ // It recommends; it does not switch. A run with no `--decide` writes nothing, and a run that
14
+ // names a decision writes exactly one trail entry on the theme bead, carrying the timestamp,
15
+ // both scores and the reason, so a later session reads the comparison rather than a claim.
16
+ // A decision that contradicts the recommendation needs `--reason`: a move is a judgement about
17
+ // what the break buys, and a switch against the scores is a Strategy change, not a queue result.
18
+ //
19
+ // Zero runtime dependencies, async fs only, every child process with an argv array.
20
+ //
21
+ // Exit codes: 0 an answer was printed (a comparison or a stated condition), 1 a tracker call
22
+ // failed, 2 the caller asked for something malformed.
23
+
24
+ import { pathToFileURL } from 'node:url';
25
+
26
+ import { DEFAULT_FILE } from '../../prioritise-work/scripts/strategy.mjs';
27
+ import { BD_PATH, errorMessage, makeRunner } from '../../score-bead/scripts/rice.mjs';
28
+ import { HITL_LABEL, IDEA_LABEL, UsageError, buildQueue } from './queue.mjs';
29
+
30
+ /** @typedef {Awaited<ReturnType<typeof buildQueue>>} QueueReport */
31
+ /** @typedef {QueueReport['rows'][number]} QueueRow */
32
+
33
+ // ─── Contract ─────────────────────────────────────────────────────────────────
34
+
35
+ /**
36
+ * The three outcomes of one boundary decision, and no fourth. `stay` continues in the theme,
37
+ * `switch` leaves it because higher-priority work waits elsewhere, and `move` leaves it because
38
+ * a break improves the next item. Only the first two can be read off the scores; `move` is a
39
+ * judgement the contributor makes, so it always carries a reason of its own.
40
+ */
41
+ export const BOUNDARY_OUTCOME = /** @type {const} */ ({
42
+ stay: 'stay',
43
+ switch: 'switch',
44
+ move: 'move',
45
+ });
46
+
47
+ /**
48
+ * The answers that are stated rather than guessed: an empty queue, a theme with nothing
49
+ * scored, and a next item the queue does not hold. Each names its condition and one next step.
50
+ */
51
+ export const BOUNDARY_STATE = /** @type {const} */ ({
52
+ boundary: 'boundary',
53
+ empty: 'empty',
54
+ noThemeItem: 'no-theme-item',
55
+ nextNotQueued: 'next-not-queued',
56
+ });
57
+
58
+ /**
59
+ * The whole answer.
60
+ * @typedef {object} BoundaryDecision
61
+ * @property {'stay' | 'switch' | 'move'} outcome
62
+ * @property {string} reason
63
+ * @property {string} note The trail entry as it was written.
64
+ * @property {string} at The timestamp the entry carries, ISO 8601 UTC.
65
+ */
66
+
67
+ /**
68
+ * @typedef {object} BoundaryAnswer
69
+ * @property {string} state One of BOUNDARY_STATE.
70
+ * @property {string} theme The epic the session is working in.
71
+ * @property {string} [next] The next item the caller named.
72
+ * @property {QueueReport} report
73
+ * @property {number | undefined} strategyVersion
74
+ * @property {QueueRow} [row] The theme's next item.
75
+ * @property {QueueRow} [top] The queue's top item.
76
+ * @property {'stay' | 'switch'} [recommendation] Which way the two scores point.
77
+ * @property {string} [reason] Why the scores point that way.
78
+ * @property {BoundaryDecision} [decided] The decision the human made, when one was named.
79
+ */
80
+
81
+ /** The three outcome names, for reading a caller's argument against them. @type {string[]} */
82
+ const OUTCOMES = Object.values(BOUNDARY_OUTCOME);
83
+
84
+ // ─── Numbers the queue already computed ───────────────────────────────────────
85
+
86
+ /**
87
+ * The stored total of a ranked row. A row with no total is not ranked and never reaches here;
88
+ * the cast is the filter's promise, not a second computation.
89
+ * @param {QueueRow} row @returns {number}
90
+ */
91
+ function totalOf(row) {
92
+ return /** @type {number} */ (row.total);
93
+ }
94
+
95
+ /**
96
+ * The score as the queue prints it: one decimal, the number the rank was decided by.
97
+ * @param {QueueRow} row @returns {string}
98
+ */
99
+ function formatTotal(row) {
100
+ return `total ${totalOf(row).toFixed(1)}`;
101
+ }
102
+
103
+ /** @param {string} value @returns {string} */
104
+ function collapse(value) {
105
+ return value.replace(/\s+/g, ' ').trim();
106
+ }
107
+
108
+ /**
109
+ * Why the two scores point where they point, in words that carry both numbers. A reader can
110
+ * check the sentence against the two totals on the block; nothing here decides a priority the
111
+ * block has not displayed.
112
+ * @param {QueueRow} row @param {QueueRow} top @returns {string}
113
+ */
114
+ export function compareReason(row, top) {
115
+ const mine = totalOf(row).toFixed(1);
116
+ const theirs = totalOf(top).toFixed(1);
117
+ if (row.id === top.id) {
118
+ return `the theme's next item is the queue's top item at ${mine}, so higher-priority work waits nowhere else`;
119
+ }
120
+ if (totalOf(top) > totalOf(row)) {
121
+ return `the queue's top item scores ${theirs} against the theme's next ${mine}, so higher-priority work waits elsewhere`;
122
+ }
123
+ return `the theme's next item scores ${mine} against the queue's top ${theirs}, so nothing outside the theme outranks it`;
124
+ }
125
+
126
+ // ─── The theme ────────────────────────────────────────────────────────────────
127
+
128
+ /**
129
+ * The ids of one theme's children, read from the tracker. The theme's next item is the
130
+ * highest-ranked of these in the queue, so the comparison cannot be steered by naming a weak
131
+ * item: the item compared is the one the theme itself would do next.
132
+ * @param {(args: string[]) => Promise<string>} bd @param {string} theme
133
+ * @returns {Promise<Set<string>>}
134
+ */
135
+ async function readThemeItems(bd, theme) {
136
+ const stdout = await bd(['children', theme, '--json']);
137
+ /** @type {unknown} */
138
+ let parsed;
139
+ try {
140
+ parsed = JSON.parse(stdout === '' ? '[]' : stdout);
141
+ } catch {
142
+ throw new UsageError(`bd children ${theme} --json did not print JSON`);
143
+ }
144
+ if (!Array.isArray(parsed)) throw new UsageError(`bd children ${theme} --json did not print a list`);
145
+ return new Set(parsed.flatMap((entry) => {
146
+ const id = typeof entry === 'object' && entry !== null ? /** @type {Record<string, unknown>} */ (entry).id : undefined;
147
+ return typeof id === 'string' ? [id] : [];
148
+ }));
149
+ }
150
+
151
+ // ─── The comparison ───────────────────────────────────────────────────────────
152
+
153
+ /**
154
+ * Read the queue, place the theme's next item against the queue's top item, and record the
155
+ * decision when the caller names one.
156
+ *
157
+ * The recommendation is derived from the two stored totals and nothing else. The decision is
158
+ * the caller's: it is written as one trail entry on the theme bead, which is the parent of the
159
+ * items, so the reason survives the session that made it.
160
+ *
161
+ * @param {object} options
162
+ * @param {(args: string[], scope?: { name: string }) => Promise<string>} options.bd
163
+ * @param {string} options.theme The epic the session is working in.
164
+ * @param {string | undefined} [options.next] The next item, when the caller names it.
165
+ * @param {'stay' | 'switch' | 'move' | undefined} [options.decide]
166
+ * @param {string | undefined} [options.reason]
167
+ * @param {string | undefined} [options.file] Strategy path; defaults to the documented default.
168
+ * @param {string} [options.cwd]
169
+ * @param {boolean} [options.includeIdeas]
170
+ * @param {number} [options.now] Epoch milliseconds the trail entry is stamped with.
171
+ * @returns {Promise<BoundaryAnswer>}
172
+ */
173
+ export async function buildBoundary({
174
+ bd,
175
+ theme,
176
+ next,
177
+ decide,
178
+ reason,
179
+ file,
180
+ cwd = process.cwd(),
181
+ includeIdeas = false,
182
+ now = Date.now(),
183
+ }) {
184
+ const report = await buildQueue({ bd, cwd, includeIdeas, ...(file === undefined ? {} : { file }) });
185
+ const strategyVersion = report.strategy.version;
186
+ const ranked = report.rows.filter((row) => row.total !== undefined);
187
+
188
+ if (ranked.length === 0) {
189
+ return { state: BOUNDARY_STATE.empty, theme, report, strategyVersion };
190
+ }
191
+
192
+ const top = /** @type {QueueRow} */ (ranked[0]);
193
+
194
+ /** @type {QueueRow | undefined} */
195
+ let row;
196
+ if (next !== undefined) {
197
+ row = ranked.find((each) => each.id === next);
198
+ if (row === undefined) {
199
+ return { state: BOUNDARY_STATE.nextNotQueued, theme, next, report, top, strategyVersion };
200
+ }
201
+ } else {
202
+ const items = await readThemeItems(bd, theme);
203
+ row = ranked.find((each) => items.has(each.id));
204
+ if (row === undefined) {
205
+ return { state: BOUNDARY_STATE.noThemeItem, theme, report, top, strategyVersion };
206
+ }
207
+ }
208
+
209
+ const recommendation = top.id !== row.id && totalOf(top) > totalOf(row) ? 'switch' : 'stay';
210
+ const derived = compareReason(row, top);
211
+
212
+ if (decide === undefined) {
213
+ return {
214
+ state: BOUNDARY_STATE.boundary,
215
+ theme,
216
+ report,
217
+ strategyVersion,
218
+ row,
219
+ top,
220
+ recommendation,
221
+ reason: derived,
222
+ };
223
+ }
224
+
225
+ if (!OUTCOMES.includes(decide)) {
226
+ throw new UsageError(`--decide takes ${OUTCOMES.join(', ')}, not "${decide}"`);
227
+ }
228
+
229
+ const given = reason === undefined ? '' : collapse(reason);
230
+ const chosen = given === '' ? (decide === recommendation ? derived : undefined) : given;
231
+ if (chosen === undefined) {
232
+ throw new UsageError(
233
+ `--decide ${decide} contradicts the recommendation (${recommendation}), so it needs --reason: name what the decision buys`,
234
+ );
235
+ }
236
+
237
+ const at = new Date(now).toISOString();
238
+ const note = `[${at}] boundary: ${decide} · theme next ${row.id} ${formatTotal(row)}` +
239
+ ` vs queue top ${top.id} ${formatTotal(top)} · reason: ${chosen}`;
240
+ await bd(['note', theme, note]);
241
+
242
+ return {
243
+ state: BOUNDARY_STATE.boundary,
244
+ theme,
245
+ report,
246
+ strategyVersion,
247
+ row,
248
+ top,
249
+ recommendation,
250
+ reason: derived,
251
+ decided: { outcome: decide, reason: chosen, note, at },
252
+ };
253
+ }
254
+
255
+ // ─── Render ───────────────────────────────────────────────────────────────────
256
+
257
+ /**
258
+ * One side of the comparison: the bead, its total and its title. Both sides are printed on
259
+ * every comparison, so leaving the theme is always a choice between two numbers on screen.
260
+ * @param {string} label @param {QueueRow} row @returns {string}
261
+ */
262
+ function side(label, row) {
263
+ return `${label} ${row.id} · ${formatTotal(row)} · ${row.title}`;
264
+ }
265
+
266
+ /**
267
+ * Render one answer. A comparison is the recommendation with its reason, both scores, and
268
+ * then either the decision that was recorded or the one step that asks for it. Every other
269
+ * state leads with `No boundary`, names the condition and offers the one step that clears it.
270
+ * @param {BoundaryAnswer} answer @returns {string}
271
+ */
272
+ export function renderBoundary(answer) {
273
+ const version = answer.strategyVersion === undefined
274
+ ? 'strategy unknown'
275
+ : `strategy v${answer.strategyVersion}`;
276
+
277
+ if (answer.state === BOUNDARY_STATE.boundary) {
278
+ const row = /** @type {QueueRow} */ (answer.row);
279
+ const top = /** @type {QueueRow} */ (answer.top);
280
+ const lines = [
281
+ `Boundary · theme ${answer.theme} · ${version}`,
282
+ `recommendation: ${answer.recommendation} — ${answer.reason}`,
283
+ side('theme next ', row),
284
+ side('queue top ', top),
285
+ ];
286
+ if (answer.decided === undefined) {
287
+ lines.push("next: stay, switch or move is the human's call — nothing is recorded until they answer");
288
+ } else {
289
+ lines.push(
290
+ `decision: ${answer.decided.outcome} — ${answer.decided.reason}`,
291
+ `trail: ${answer.theme} · ${answer.decided.note}`,
292
+ );
293
+ }
294
+ return `${lines.join('\n')}\n`;
295
+ }
296
+
297
+ if (answer.state === BOUNDARY_STATE.empty) {
298
+ return `${
299
+ [
300
+ 'No boundary · the queue is empty',
301
+ ` no open ${HITL_LABEL} beads`,
302
+ `next: file the theme's next item as an ${HITL_LABEL} bead, or lift a deferral with bd update <id> --status open`,
303
+ ].join('\n')
304
+ }\n`;
305
+ }
306
+
307
+ if (answer.state === BOUNDARY_STATE.nextNotQueued) {
308
+ const named = answer.next ?? 'that bead';
309
+ const top = /** @type {QueueRow} */ (answer.top);
310
+ return `${
311
+ [
312
+ `No boundary · ${named} is not in the queue`,
313
+ ` ${named} is not an open ${HITL_LABEL} bead carrying a score — check it with bd show ${named}`,
314
+ ` ${side('queue top ', top)}`,
315
+ `next: name a scored bead from the queue, or score ${named} with score-bead`,
316
+ ].join('\n')
317
+ }\n`;
318
+ }
319
+
320
+ const top = /** @type {QueueRow} */ (answer.top);
321
+ return `${
322
+ [
323
+ `No boundary · theme ${answer.theme} has no ranked item`,
324
+ ` no scored ${HITL_LABEL} bead sits under ${answer.theme}`,
325
+ ` ${side('queue top ', top)}`,
326
+ `next: name the theme's next item with --next <id>, or score it with score-bead`,
327
+ ].join('\n')
328
+ }\n`;
329
+ }
330
+
331
+ // ─── CLI ──────────────────────────────────────────────────────────────────────
332
+
333
+ /** @type {readonly string[]} */
334
+ export const COMMANDS = /** @type {const} */ (['boundary']);
335
+
336
+ const USAGE = `Usage: node boundary.mjs boundary --theme <epic-id> [--next <id>] [--decide <outcome>]
337
+ [--reason "<what the decision buys>"] [--file <path>]
338
+ [--include-ideas] [--cwd <dir>] [--json]
339
+
340
+ boundary Compare the theme's next item against the queue's top item, print both totals and
341
+ the recommendation the two numbers point to, and record the decision on the theme
342
+ bead when one is named. Without --decide nothing is written: the human decides.
343
+
344
+ Options:
345
+ --theme <epic-id> The theme the session is working in; its children are the theme's items
346
+ --next <id> The theme's next item, when the caller names it instead
347
+ --decide <outcome> ${Object.values(BOUNDARY_OUTCOME).join(', ')} — record the human's decision as a trail entry
348
+ --reason "<text>" Why that decision: required when it contradicts the recommendation
349
+ --file <path> Strategy file (default: ${DEFAULT_FILE}, relative to the repository root)
350
+ --include-ideas Rank ${IDEA_LABEL} beads too (they are excluded by default)
351
+ --cwd <dir> Directory the strategy path and bd resolve from
352
+ --json Print the answer as JSON
353
+ -h, --help Show this text
354
+
355
+ Environment:
356
+ BD_PATH bd binary (default: bd)
357
+ `;
358
+
359
+ /**
360
+ * Parse the CLI's argv.
361
+ * @param {string[]} argv
362
+ * @returns {{ command: string, theme: string, next?: string, decide?: 'stay' | 'switch' | 'move', reason?: string, file?: string, includeIdeas: boolean, cwd: string, json: boolean }}
363
+ */
364
+ export function parseArgs(argv) {
365
+ /** @type {string | undefined} */
366
+ let command;
367
+ /** @type {string | undefined} */
368
+ let theme;
369
+ /** @type {string | undefined} */
370
+ let next;
371
+ /** @type {string | undefined} */
372
+ let decide;
373
+ /** @type {string | undefined} */
374
+ let reason;
375
+ /** @type {string | undefined} */
376
+ let file;
377
+ let cwd = process.cwd();
378
+ let json = false;
379
+ let includeIdeas = false;
380
+
381
+ for (let i = 0; i < argv.length; i++) {
382
+ const arg = argv[i];
383
+ if (arg === '--json') {
384
+ json = true;
385
+ continue;
386
+ }
387
+ if (arg === '--include-ideas') {
388
+ includeIdeas = true;
389
+ continue;
390
+ }
391
+ if (arg === '--file' || arg === '--cwd' || arg === '--theme' || arg === '--next' || arg === '--decide' || arg === '--reason') {
392
+ const value = argv[++i];
393
+ if (value === undefined) throw new UsageError(`${arg} needs a value`);
394
+ if (arg === '--cwd') cwd = value;
395
+ else if (arg === '--theme') theme = value;
396
+ else if (arg === '--next') next = value;
397
+ else if (arg === '--decide') decide = value;
398
+ else if (arg === '--reason') reason = value;
399
+ else file = value;
400
+ continue;
401
+ }
402
+ if (arg.startsWith('-')) throw new UsageError(`unexpected argument: ${arg}`);
403
+ if (command === undefined) {
404
+ if (!COMMANDS.includes(arg)) throw new UsageError(`unknown command "${arg}"`);
405
+ command = arg;
406
+ continue;
407
+ }
408
+ throw new UsageError(`unexpected argument: ${arg}`);
409
+ }
410
+
411
+ if (command === undefined) throw new UsageError(`a command is required: ${COMMANDS.join(', ')}`);
412
+ if (theme === undefined) throw new UsageError('--theme is required: the epic the session is working in');
413
+ if (decide !== undefined && !OUTCOMES.includes(decide)) {
414
+ throw new UsageError(`--decide takes ${Object.values(BOUNDARY_OUTCOME).join(', ')}, not "${decide}"`);
415
+ }
416
+
417
+ return {
418
+ command,
419
+ theme,
420
+ ...(next === undefined ? {} : { next }),
421
+ ...(decide === undefined ? {} : { decide: /** @type {'stay' | 'switch' | 'move'} */ (decide) }),
422
+ ...(reason === undefined ? {} : { reason }),
423
+ ...(file === undefined ? {} : { file }),
424
+ includeIdeas,
425
+ cwd,
426
+ json,
427
+ };
428
+ }
429
+
430
+ /**
431
+ * @param {object} options
432
+ * @param {string[]} options.argv CLI arguments (without node/script).
433
+ * @param {(chunk: string) => void} [options.write] stdout sink; injectable for tests.
434
+ * @param {(chunk: string) => void} [options.writeError] stderr sink; injectable for tests.
435
+ * @param {{ bd?: (args: string[], scope?: { name: string }) => Promise<string> }} [options.deps]
436
+ * Collaborators; injectable for tests.
437
+ * @returns {Promise<number>} process exit code.
438
+ */
439
+ export async function runCli({
440
+ argv,
441
+ write = (chunk) => process.stdout.write(chunk),
442
+ writeError = (chunk) => process.stderr.write(chunk),
443
+ deps = {},
444
+ }) {
445
+ if (argv.includes('--help') || argv.includes('-h')) {
446
+ write(USAGE);
447
+ return 0;
448
+ }
449
+
450
+ /** @type {ReturnType<typeof parseArgs>} */
451
+ let options;
452
+ try {
453
+ options = parseArgs(argv);
454
+ } catch (error) {
455
+ writeError(`boundary: ${errorMessage(error)}\n\n${USAGE}`);
456
+ return 2;
457
+ }
458
+
459
+ const bd = deps.bd ?? makeRunner(BD_PATH, options.cwd);
460
+ try {
461
+ const answer = await buildBoundary({
462
+ bd,
463
+ theme: options.theme,
464
+ cwd: options.cwd,
465
+ includeIdeas: options.includeIdeas,
466
+ ...(options.next === undefined ? {} : { next: options.next }),
467
+ ...(options.decide === undefined ? {} : { decide: options.decide }),
468
+ ...(options.reason === undefined ? {} : { reason: options.reason }),
469
+ ...(options.file === undefined ? {} : { file: options.file }),
470
+ });
471
+ write(options.json ? `${JSON.stringify(answer, null, 2)}\n` : renderBoundary(answer));
472
+ return 0;
473
+ } catch (error) {
474
+ if (error instanceof UsageError) {
475
+ writeError(`boundary: ${errorMessage(error)}\n`);
476
+ return 2;
477
+ }
478
+ writeError(`boundary failed: ${errorMessage(error)}\n`);
479
+ return 1;
480
+ }
481
+ }
482
+
483
+ // Only run the CLI when invoked directly, so tests can import the module.
484
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
485
+ runCli({ argv: process.argv.slice(2) })
486
+ .then((code) => {
487
+ process.exitCode = code;
488
+ })
489
+ .catch((error) => {
490
+ process.stderr.write(`${errorMessage(error)}\n`);
491
+ process.exitCode = 1;
492
+ });
493
+ }