@jaybeeuu/agent-cortex 2.7.0 → 2.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/CHANGELOG.md +412 -0
  2. package/README.md +87 -8
  3. package/agents/README.md +4 -4
  4. package/agents/guppi/agent.md +162 -0
  5. package/agents/guppi/claude/frontmatter.json +6 -0
  6. package/agents/guppi/pi/frontmatter.json +6 -0
  7. package/agents/plan/agent.md +6 -0
  8. package/agents/ralph/agent.md +17 -16
  9. package/agents/ralph/claude/wake.md +4 -4
  10. package/agents/ralph/pi/wake.md +15 -20
  11. package/agents/ralph-plan/agent.md +6 -0
  12. package/bin/agent-cortex.mjs +20 -1
  13. package/bin/installers/claude.mjs +1 -20
  14. package/bin/installers/pi.mjs +8 -2
  15. package/extensions/interrupt-bookmark/README.md +129 -0
  16. package/extensions/interrupt-bookmark/index.test.ts +717 -0
  17. package/extensions/interrupt-bookmark/index.ts +354 -0
  18. package/extensions/interrupt-bookmark/package.json +20 -0
  19. package/extensions/post-merge-watch/README.md +133 -0
  20. package/extensions/post-merge-watch/index.test.ts +247 -0
  21. package/extensions/post-merge-watch/index.ts +123 -0
  22. package/extensions/post-merge-watch/package.json +16 -0
  23. package/extensions/post-merge-watch/watch.test.ts +469 -0
  24. package/extensions/post-merge-watch/watch.ts +431 -0
  25. package/extensions/session-clear/README.md +74 -0
  26. package/extensions/session-clear/index.test.ts +524 -0
  27. package/extensions/session-clear/index.ts +159 -0
  28. package/extensions/session-clear/package.json +16 -0
  29. package/extensions/skill-stats/README.md +84 -3
  30. package/extensions/skill-stats/index.ts +62 -3
  31. package/extensions/skill-stats/package.json +1 -1
  32. package/extensions/skill-stats/rollup.test.ts +643 -0
  33. package/extensions/skill-stats/rollup.ts +415 -0
  34. package/extensions/skill-stats/timeline.ts +101 -0
  35. package/lib/cli.mjs +42 -1
  36. package/lib/self-update.mjs +553 -0
  37. package/package.json +1 -1
  38. package/scripts/lib/manifest.mjs +29 -0
  39. package/skills/engineering/implement-task/SKILL.md +124 -0
  40. package/skills/engineering/tdd/SKILL.md +2 -0
  41. package/skills/planning/create-task/REFERENCE.md +15 -6
  42. package/skills/planning/create-task/SKILL.md +1 -1
  43. package/skills/planning/create-task/pipeline.json +8 -0
  44. package/skills/planning/create-task/scripts/create-chores.mjs +27 -18
  45. package/skills/planning/create-task/templates/code.md +8 -3
  46. package/skills/planning/create-task/templates/document.md +8 -3
  47. package/skills/planning/create-task/templates/fix.md +8 -3
  48. package/skills/planning/create-task/templates/housekeeping.md +8 -4
  49. package/skills/planning/create-task/templates/post-merge.md +67 -0
  50. package/skills/planning/create-task/templates/review.md +8 -3
  51. package/skills/planning/create-task/templates/verify.md +8 -3
  52. package/skills/productivity/bd-tool/SKILL.md +55 -0
  53. package/skills/review/review-defects/EXAMPLES.md +198 -0
  54. package/skills/review/review-defects/SKILL.md +121 -0
  55. package/skills/review/review-report/EXAMPLES.md +114 -0
  56. package/skills/review/review-report/SKILL.md +138 -0
  57. package/skills/review/review-report/scripts/report.mjs +1032 -0
  58. package/skills/style/style-tests/EXAMPLES.md +30 -0
  59. package/skills/style/style-tests/SKILL.md +10 -6
  60. package/skills/workflow/hitl-queue/SKILL.md +134 -0
  61. package/skills/workflow/hitl-queue/scripts/queue.mjs +626 -0
  62. package/skills/workflow/plan/SKILL.md +27 -3
  63. package/skills/workflow/prioritise-work/CHALLENGE.md +139 -0
  64. package/skills/workflow/prioritise-work/SKILL.md +150 -0
  65. package/skills/workflow/prioritise-work/scripts/challenge.mjs +505 -0
  66. package/skills/workflow/prioritise-work/scripts/strategy.mjs +566 -0
  67. package/skills/workflow/prioritise-work/templates/strategy.md +61 -0
  68. package/skills/workflow/ralph/REFERENCE.md +124 -27
  69. package/skills/workflow/ralph/SKILL.md +24 -15
  70. package/skills/workflow/ralph/scripts/post-merge-check.mjs +651 -0
  71. package/skills/workflow/run-pipeline-stage/SKILL.md +37 -22
  72. package/skills/workflow/run-pipeline-stage/playbooks/code.md +14 -8
  73. package/skills/workflow/run-pipeline-stage/playbooks/document.md +2 -2
  74. package/skills/workflow/run-pipeline-stage/playbooks/fix.md +2 -2
  75. package/skills/workflow/run-pipeline-stage/playbooks/housekeeping.md +1 -1
  76. package/skills/workflow/run-pipeline-stage/playbooks/post-merge.md +54 -0
  77. package/skills/workflow/run-pipeline-stage/playbooks/review.md +37 -3
  78. package/skills/workflow/run-pipeline-stage/playbooks/verify.md +2 -2
  79. package/skills/workflow/run-pipeline-stage/prompts/stage-runner.md +55 -11
  80. package/skills/workflow/run-pipeline-stage/scripts/recover-brief.mjs +805 -0
  81. package/skills/workflow/run-pipeline-stage/scripts/trail-entry.mjs +702 -0
  82. package/skills/workflow/score-bead/FORMAT.md +73 -0
  83. package/skills/workflow/score-bead/SKILL.md +112 -0
  84. package/skills/workflow/score-bead/scripts/rice.mjs +664 -0
  85. package/skills/workflow/using-agent-skills/SKILL.md +21 -8
  86. package/token-map.README.md +27 -0
  87. package/token-map.json +16 -4
@@ -0,0 +1,805 @@
1
+ // @ts-check
2
+ // The read side of the bead trail: one command prints a bounded brief for a bead, so a
3
+ // cold session resumes from one screen instead of a transcript.
4
+ //
5
+ // `trail-entry.mjs` appends one checkpoint per transition. `bd show <id>` returns the whole
6
+ // note, and it prints 89 lines and about 6.5 KB for a bare bead carrying 20 entries, or about
7
+ // 120 lines and about 8 KB for a realistic one — 21 entries with acceptance criteria, a
8
+ // blocker, provenance refs and a description — most of it history nobody needs to continue.
9
+ // This script prints what a session does need — status and state labels, the newest trail
10
+ // entries, provenance refs, blocking dependencies, and the acceptance criteria — and enforces
11
+ // a line budget on its own output, so the read stays small however long the trail grows.
12
+ //
13
+ // Read-only. Every bd call is one of READ_ONLY_CALLS and carries `--readonly`, so the
14
+ // tracker refuses a write even if a later edit mistypes a flag. This script never runs
15
+ // `bd note`, `bd update`, `bd close` or `bd provenance record`: it reports, it never records.
16
+ //
17
+ // Zero dependencies. Run:
18
+ //
19
+ // node recover-brief.mjs --bead <id> [--trail <n>] [--provenance <n>] [--max-lines <n>] \
20
+ // [--cwd <dir>]
21
+ //
22
+ // The newest `--trail` entries print and the rest are summarised by count, because bd
23
+ // cannot return part of a note: its `show --json` carries the whole `notes` string, so the
24
+ // bound has to live here.
25
+
26
+ import { pathToFileURL } from 'node:url';
27
+
28
+ import {
29
+ BD_PATH,
30
+ UsageError,
31
+ errorMessage,
32
+ makeRunner,
33
+ withRetry,
34
+ } from './trail-entry.mjs';
35
+
36
+ // ─── Contract ─────────────────────────────────────────────────────────────────
37
+
38
+ /** Trail entries printed when the caller names none. */
39
+ export const DEFAULT_TRAIL_ENTRIES = 3;
40
+
41
+ /** Provenance events printed when the caller names none. */
42
+ export const DEFAULT_PROVENANCE_EVENTS = 4;
43
+
44
+ /** The output budget, in lines. Enforced, not documented. */
45
+ export const DEFAULT_MAX_LINES = 40;
46
+
47
+ /** A field longer than this is clipped, so one value cannot eat the budget. */
48
+ export const MAX_FIELD_CHARS = 120;
49
+
50
+ /** Acceptance-criteria lines printed at most, before the rest are summarised. */
51
+ export const MAX_ACCEPTANCE_LINES = 6;
52
+
53
+ /** Blocking dependencies printed at most, before the rest are summarised. */
54
+ export const MAX_BLOCKED_LINES = 5;
55
+
56
+ /** Attempts per bd read: the first try plus two retries. */
57
+ export const DEFAULT_ATTEMPTS = 3;
58
+
59
+ /**
60
+ * The only bd invocations this script may run, as leading argv. Anything else is refused
61
+ * before it reaches the binary.
62
+ */
63
+ export const READ_ONLY_CALLS = /** @type {const} */ ([
64
+ ['show'],
65
+ ['provenance', 'log'],
66
+ ]);
67
+
68
+ /** Statuses that satisfy a blocking dependency. */
69
+ const SATISFIED_STATUSES = new Set(['closed', 'tombstone']);
70
+
71
+ /** A failure worth reporting, not retrying. */
72
+ export class BriefError extends Error {
73
+ /**
74
+ * @param {string} message
75
+ * @param {{ step: string }} detail
76
+ */
77
+ constructor(message, { step }) {
78
+ super(message);
79
+ this.name = 'BriefError';
80
+ this.step = step;
81
+ }
82
+ }
83
+
84
+ // ─── Types ────────────────────────────────────────────────────────────────────
85
+
86
+ /** A bd call that returns stdout. @typedef {(args: string[]) => Promise<string>} Runner */
87
+
88
+ /**
89
+ * One trail entry, parsed from the note text. A free-form note has null fields.
90
+ * @typedef {object} TrailEntry
91
+ * @property {string | null} at Header timestamp, or null for a free-form note.
92
+ * @property {string | null} stage
93
+ * @property {string | null} fingerprint The writer's content fingerprint, or null.
94
+ * @property {string | null} outcome
95
+ * @property {string | null} evidence
96
+ * @property {string | null} remaining
97
+ * @property {string} text First meaningful line, for a free-form note.
98
+ */
99
+
100
+ /** A blocking dependency of the bead being read.
101
+ * @typedef {object} BlockingDep
102
+ * @property {string} id
103
+ * @property {string} status
104
+ * @property {string} title
105
+ */
106
+
107
+ /**
108
+ * One provenance event, as `bd provenance log --json` prints it.
109
+ * @typedef {object} ProvenanceEvent
110
+ * @property {string} refKind
111
+ * @property {string} ref
112
+ * @property {string} createdAt
113
+ */
114
+
115
+ /**
116
+ * The bead fields the brief renders.
117
+ * @typedef {object} Bead
118
+ * @property {string} id
119
+ * @property {string} title
120
+ * @property {string} status
121
+ * @property {string} type
122
+ * @property {number} priority
123
+ * @property {string[]} labels
124
+ * @property {string | null} parent
125
+ * @property {string} description
126
+ * @property {string} acceptance
127
+ * @property {string} notes
128
+ * @property {BlockingDep[]} blockedBy
129
+ */
130
+
131
+ /**
132
+ * Everything the renderer needs, already read from the tracker.
133
+ * @typedef {object} BriefModel
134
+ * @property {Bead} bead
135
+ * @property {TrailEntry[]} trail Ordered oldest first, deduplicated.
136
+ * @property {ProvenanceEvent[]} provenance Ordered newest first.
137
+ * @property {string[]} acceptance Normalised acceptance-criteria lines.
138
+ */
139
+
140
+ /**
141
+ * Injected collaborators, so a test drives the script without a bead database.
142
+ * @typedef {object} BriefDeps
143
+ * @property {Runner} [bd]
144
+ * @property {(ms: number) => Promise<void>} [sleep]
145
+ */
146
+
147
+ // ─── Value helpers ────────────────────────────────────────────────────────────
148
+
149
+ /** Read a non-negative integer from the environment, falling back to `fallback`.
150
+ * @param {string} name @param {number} fallback @returns {number} */
151
+ function envInt(name, fallback) {
152
+ const raw = process.env[name];
153
+ if (raw === undefined || raw === '') return fallback;
154
+ const value = Number(raw);
155
+ return Number.isFinite(value) && value >= 0 ? Math.trunc(value) : fallback;
156
+ }
157
+
158
+ /** Clip a value to the field budget, marking the cut.
159
+ * @param {string} value @param {number} [max] @returns {string} */
160
+ export function clip(value, max = MAX_FIELD_CHARS) {
161
+ const text = value.replace(/\s+/g, ' ').trim();
162
+ return text.length <= max ? text : `${text.slice(0, max - 1)}…`;
163
+ }
164
+
165
+ /** The newest trail entry that recorded what is left, or null.
166
+ * @param {TrailEntry[]} trail @returns {string | null} */
167
+ export function nextStep(trail) {
168
+ for (let i = trail.length - 1; i >= 0; i--) {
169
+ const remaining = trail[i].remaining;
170
+ if (remaining !== null && remaining !== '') return remaining;
171
+ }
172
+ return null;
173
+ }
174
+
175
+ /**
176
+ * What the `next:` line says. `no trail entry` is a claim about the trail itself, so it
177
+ * prints only when the bead carries none; a bead whose entries carry no `What is left`
178
+ * field gets a line that says exactly that instead of denying the trail exists.
179
+ * @param {string | null} next What is left on the newest entry that recorded it.
180
+ * @param {number} trailLength Entries parsed from the note.
181
+ * @returns {string}
182
+ */
183
+ export function nextStepLine(next, trailLength) {
184
+ if (next !== null) return clip(next);
185
+ if (trailLength === 0) return 'no trail entry — record a stage-start entry before working';
186
+ return 'no entry records what is left — read the trail below';
187
+ }
188
+
189
+ // ─── Trail ────────────────────────────────────────────────────────────────────
190
+
191
+ /**
192
+ * The writer's header line: `[<rfc3339>] [<stage>] trail:<fingerprint>`. A note appended by
193
+ * hand may carry the first two parts only, so the fingerprint is optional.
194
+ */
195
+ const TRAIL_HEADER = /^\[([^\]]{4,64})\]\s\[([a-z][a-z0-9-]*)\](?:\s+trail:([0-9a-f]{6,64}))?$/;
196
+
197
+ /**
198
+ * A line that starts a bracketed field, the shape of both the writer's header and the
199
+ * hand-written note AGENTS.md documents for a stop: `[<ISO timestamp>] stopped: …`.
200
+ */
201
+ const ENTRY_START = /^\[[^\]]{4,64}\]/;
202
+
203
+ /**
204
+ * The fields the writer stamps into an entry body. A stamped entry's body is exactly these
205
+ * lines, so any other text after a header is a note someone appended by hand. Authored in
206
+ * `buildEntry` of `trail-entry.mjs`: a field added there and not here still prints, as a
207
+ * note of its own, rather than disappearing.
208
+ */
209
+ const TRAIL_FIELDS = ['Outcome', 'Evidence', 'What is left', 'Refs'];
210
+
211
+ /**
212
+ * Is this line one of the writer's `Field: value` lines?
213
+ * @param {string} line @returns {boolean}
214
+ */
215
+ function isFieldLine(line) {
216
+ return TRAIL_FIELDS.some((name) => line.trim().startsWith(`${name}:`));
217
+ }
218
+
219
+ /** The first line of a note that carries no header, clipped for the brief.
220
+ * @param {string[]} lines @returns {string} */
221
+ function firstLine(lines) {
222
+ for (const line of lines) {
223
+ const text = line.trim();
224
+ if (text !== '') return text;
225
+ }
226
+ return '';
227
+ }
228
+
229
+ /** The value of one `Field: value` line in an entry body.
230
+ * @param {string[]} body @param {string} name @returns {string | null} */
231
+ function fieldValue(body, name) {
232
+ const prefix = `${name}:`;
233
+ for (const line of body) {
234
+ const text = line.trim();
235
+ if (text.startsWith(prefix)) return text.slice(prefix.length).trim();
236
+ }
237
+ return null;
238
+ }
239
+
240
+ /** A free-form entry: a note someone appended by hand, carrying no writer fields.
241
+ * @param {string} text @returns {TrailEntry} */
242
+ function noteEntry(text) {
243
+ return { at: null, stage: null, fingerprint: null, outcome: null, evidence: null, remaining: null, text };
244
+ }
245
+
246
+ /**
247
+ * Split a bead's `notes` string into trail entries. The note is one text blob: `bd` joins
248
+ * every appended note with a single newline and marks no boundary, so the split is textual.
249
+ *
250
+ * - A writer header starts a stamped entry. Its body runs only over blank lines and the
251
+ * writer's `Field: value` lines; the first other line ends it.
252
+ * - Any other text is a free-form entry of its own. A bracketed timestamp starts a new one,
253
+ * which is the shape of the hand-written stop note AGENTS.md documents.
254
+ *
255
+ * So a hand-appended `bd note` is an entry wherever it lands — before a stamped entry,
256
+ * between two of them, or after the last one — rather than a line buried in the previous
257
+ * entry's body, where the renderer would never print it.
258
+ *
259
+ * The one shape text cannot separate: notes that carry no bracketed start and are appended
260
+ * back to back. `bd` joins every note with a bare newline, so such a run reads as one note.
261
+ * The documented stop note starts with `[<ISO timestamp>]`, and so does the writer's header,
262
+ * so the shape a session records is always separable.
263
+ * @param {string} notes @returns {TrailEntry[]}
264
+ */
265
+ export function splitTrail(notes) {
266
+ /** @type {TrailEntry[]} */
267
+ const entries = [];
268
+ /** @type {{ at: string, stage: string, fingerprint: string | null } | null} */
269
+ let header = null;
270
+ /** @type {string[]} */
271
+ let body = [];
272
+ /** @type {string[]} */
273
+ let note = [];
274
+
275
+ /** Close the open stamped entry, if there is one. */
276
+ const flushStamped = () => {
277
+ if (header === null) return;
278
+ entries.push({
279
+ at: header.at,
280
+ stage: header.stage,
281
+ fingerprint: header.fingerprint,
282
+ outcome: fieldValue(body, 'Outcome'),
283
+ evidence: fieldValue(body, 'Evidence'),
284
+ remaining: fieldValue(body, 'What is left'),
285
+ text: firstLine(body),
286
+ });
287
+ header = null;
288
+ body = [];
289
+ };
290
+
291
+ /** Close the open free-form entry, if it carries text. */
292
+ const flushNote = () => {
293
+ const text = firstLine(note);
294
+ if (text !== '') entries.push(noteEntry(text));
295
+ note = [];
296
+ };
297
+
298
+ for (const line of notes.split('\n')) {
299
+ const match = TRAIL_HEADER.exec(line.trim());
300
+ if (match !== null) {
301
+ flushStamped();
302
+ flushNote();
303
+ header = { at: match[1], stage: match[2], fingerprint: match[3] ?? null };
304
+ continue;
305
+ }
306
+ if (header !== null) {
307
+ if (line.trim() === '' || isFieldLine(line)) {
308
+ body.push(line);
309
+ continue;
310
+ }
311
+ flushStamped();
312
+ }
313
+ if (ENTRY_START.test(line.trim())) flushNote();
314
+ note.push(line);
315
+ }
316
+ flushStamped();
317
+ flushNote();
318
+
319
+ return entries;
320
+ }
321
+
322
+ /**
323
+ * Drop a duplicate of an entry already present. The writer's fingerprint lets a reader
324
+ * collapse a repeat that two writers raced into the note. The last occurrence wins, because
325
+ * that is the one the append sequence ended on.
326
+ * @param {TrailEntry[]} entries @returns {TrailEntry[]}
327
+ */
328
+ export function dedupeTrail(entries) {
329
+ /** @type {Set<string>} */
330
+ const seen = new Set();
331
+ /** @type {TrailEntry[]} */
332
+ const kept = [];
333
+ for (let i = entries.length - 1; i >= 0; i--) {
334
+ const entry = entries[i];
335
+ if (entry.fingerprint !== null) {
336
+ if (seen.has(entry.fingerprint)) continue;
337
+ seen.add(entry.fingerprint);
338
+ }
339
+ kept.push(entry);
340
+ }
341
+ return kept.reverse();
342
+ }
343
+
344
+ // ─── Bead ─────────────────────────────────────────────────────────────────────
345
+
346
+ /**
347
+ * The lines under a description heading, so a bead that records its acceptance criteria in
348
+ * the description reads the same as one that used `bd create --acceptance`.
349
+ * @param {string} description @param {RegExp} heading @returns {string[]}
350
+ */
351
+ export function sectionLines(description, heading) {
352
+ /** @type {string[]} */
353
+ const lines = [];
354
+ let level = 0;
355
+ for (const line of description.split('\n')) {
356
+ const match = /^(#{1,6})\s+(.*)$/.exec(line.trim());
357
+ if (match !== null) {
358
+ if (level !== 0) break; // the section ended at the next heading
359
+ if (heading.test(match[2].trim())) {
360
+ level = match[1].length;
361
+ continue;
362
+ }
363
+ } else if (level !== 0) {
364
+ lines.push(line);
365
+ }
366
+ }
367
+ return lines
368
+ .map((line) => line.trim().replace(/^[-*]\s+/, ''))
369
+ .filter((line) => line !== '');
370
+ }
371
+
372
+ /**
373
+ * The bead's acceptance criteria as display lines: the dedicated field when `bd` has one,
374
+ * otherwise the `## Acceptance Criteria` section of the description.
375
+ * @param {Bead} bead @returns {string[]}
376
+ */
377
+ export function acceptanceOf(bead) {
378
+ const field = bead.acceptance.split('\n').map((line) => line.trim().replace(/^[-*]\s+/, '')).filter((line) => line !== '');
379
+ if (field.length > 0) return field;
380
+ return sectionLines(bead.description, /^acceptance criteria$/i);
381
+ }
382
+
383
+ /** The dependencies that hold this bead back: `blocks` deps not yet in a satisfied status.
384
+ * @param {unknown} dependencies @returns {BlockingDep[]} */
385
+ export function blockingDeps(dependencies) {
386
+ if (!Array.isArray(dependencies)) return [];
387
+ /** @type {BlockingDep[]} */
388
+ const blockedBy = [];
389
+ for (const entry of dependencies) {
390
+ if (typeof entry !== 'object' || entry === null) continue;
391
+ const dep = /** @type {Record<string, unknown>} */ (entry);
392
+ if (dep.dependency_type !== 'blocks') continue;
393
+ const status = typeof dep.status === 'string' ? dep.status : 'unknown';
394
+ if (SATISFIED_STATUSES.has(status)) continue;
395
+ blockedBy.push({
396
+ id: typeof dep.id === 'string' ? dep.id : '(unknown)',
397
+ status,
398
+ title: typeof dep.title === 'string' ? dep.title : '',
399
+ });
400
+ }
401
+ return blockedBy;
402
+ }
403
+
404
+ /** Read one string field, defaulting to ''. @param {Record<string, unknown>} raw @param {string} key */
405
+ function textOf(raw, key) {
406
+ const value = raw[key];
407
+ return typeof value === 'string' ? value : '';
408
+ }
409
+
410
+ /**
411
+ * Reduce `bd show --json` output to the fields the brief renders. Both shapes bd has
412
+ * printed are accepted: a one-element array, and a bare object.
413
+ * @param {unknown} parsed @returns {Bead}
414
+ */
415
+ export function normaliseBead(parsed) {
416
+ const bead = Array.isArray(parsed) ? parsed[0] : parsed;
417
+ if (typeof bead !== 'object' || bead === null) {
418
+ throw new BriefError('bd show printed no bead', { step: 'bd show' });
419
+ }
420
+ const raw = /** @type {Record<string, unknown>} */ (bead);
421
+ if (typeof raw.id !== 'string' || raw.id === '') {
422
+ throw new BriefError('bd show printed a bead with no id', { step: 'bd show' });
423
+ }
424
+ const labels = Array.isArray(raw.labels)
425
+ ? raw.labels.filter((/** @type {unknown} */ label) => typeof label === 'string')
426
+ : [];
427
+ return {
428
+ id: raw.id,
429
+ title: textOf(raw, 'title'),
430
+ status: textOf(raw, 'status'),
431
+ type: textOf(raw, 'issue_type'),
432
+ priority: typeof raw.priority === 'number' ? raw.priority : 2,
433
+ labels: /** @type {string[]} */ (labels),
434
+ parent: typeof raw.parent === 'string' && raw.parent !== '' ? raw.parent : null,
435
+ description: textOf(raw, 'description'),
436
+ acceptance: textOf(raw, 'acceptance_criteria'),
437
+ notes: textOf(raw, 'notes'),
438
+ blockedBy: blockingDeps(raw.dependencies),
439
+ };
440
+ }
441
+
442
+ /**
443
+ * Reduce `bd provenance log --json` output to the events the brief renders.
444
+ * @param {unknown} parsed @returns {ProvenanceEvent[]}
445
+ */
446
+ export function normaliseProvenance(parsed) {
447
+ if (!Array.isArray(parsed)) return [];
448
+ /** @type {ProvenanceEvent[]} */
449
+ const events = [];
450
+ for (const entry of parsed) {
451
+ if (typeof entry !== 'object' || entry === null) continue;
452
+ const raw = /** @type {Record<string, unknown>} */ (entry);
453
+ const ref = textOf(raw, 'ref');
454
+ if (ref === '') continue;
455
+ events.push({
456
+ refKind: textOf(raw, 'ref_kind') || 'ref',
457
+ ref,
458
+ createdAt: textOf(raw, 'created_at'),
459
+ });
460
+ }
461
+ // Newest first, so the latest handover is the first line the reader sees.
462
+ return events.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
463
+ }
464
+
465
+ // ─── Reads ────────────────────────────────────────────────────────────────────
466
+
467
+ /**
468
+ * Is this argv one of the read invocations the brief is allowed to make?
469
+ * @param {string[]} args @returns {boolean}
470
+ */
471
+ export function isReadOnlyCall(args) {
472
+ return READ_ONLY_CALLS.some((prefix) => prefix.every((token, index) => args[index] === token));
473
+ }
474
+
475
+ /**
476
+ * Wrap a runner so a write can never reach the tracker. The allow-list is the guarantee:
477
+ * a call the brief is not allowed to make fails here, before the binary runs. `--readonly`
478
+ * travels on every call as well, so bd refuses a write independently of this script.
479
+ * @param {Runner} run @returns {Runner}
480
+ */
481
+ export function makeReadOnlyBd(run) {
482
+ return async (args) => {
483
+ if (!isReadOnlyCall(args)) {
484
+ throw new BriefError(`refusing a non-read bd call: ${args.join(' ')}`, { step: 'read-only guard' });
485
+ }
486
+ return run([...args, '--readonly']);
487
+ };
488
+ }
489
+
490
+ /** Parse bd's JSON, reporting the step that printed it.
491
+ * @param {string} stdout @param {string} step @returns {unknown} */
492
+ export function parseJson(stdout, step) {
493
+ try {
494
+ return JSON.parse(stdout);
495
+ } catch {
496
+ throw new BriefError(`${step} did not print JSON`, { step });
497
+ }
498
+ }
499
+
500
+ /**
501
+ * Read one bead. `bd show --json` returns the whole note — there is no way to ask for the
502
+ * last few entries — so the bound is applied when the brief renders.
503
+ * @param {object} input
504
+ * @param {string} input.beadId
505
+ * @param {Runner} input.bd
506
+ * @param {(ms: number) => Promise<void>} input.sleep
507
+ * @param {number} input.attempts
508
+ * @param {number} input.backoffMs
509
+ * @returns {Promise<Bead>}
510
+ */
511
+ export async function readBead({ beadId, bd, sleep, attempts, backoffMs }) {
512
+ const stdout = await withRetry({
513
+ step: `bd show ${beadId}`,
514
+ attempts,
515
+ backoffMs,
516
+ sleep,
517
+ run: () => bd(['show', beadId, '--json']),
518
+ }).then(({ value }) => value);
519
+ return normaliseBead(parseJson(stdout, 'bd show'));
520
+ }
521
+
522
+ /**
523
+ * Read one bead's provenance events. A bead with none prints `[]`.
524
+ * @param {object} input
525
+ * @param {string} input.beadId
526
+ * @param {Runner} input.bd
527
+ * @param {(ms: number) => Promise<void>} input.sleep
528
+ * @param {number} input.attempts
529
+ * @param {number} input.backoffMs
530
+ * @returns {Promise<ProvenanceEvent[]>}
531
+ */
532
+ export async function readProvenance({ beadId, bd, sleep, attempts, backoffMs }) {
533
+ const stdout = await withRetry({
534
+ step: `bd provenance log ${beadId}`,
535
+ attempts,
536
+ backoffMs,
537
+ sleep,
538
+ run: () => bd(['provenance', 'log', beadId, '--json']),
539
+ }).then(({ value }) => value);
540
+ return normaliseProvenance(parseJson(stdout, 'bd provenance log'));
541
+ }
542
+
543
+ // ─── Render ───────────────────────────────────────────────────────────────────
544
+
545
+ /** One trail entry, bounded to three lines however long its fields are.
546
+ * @param {TrailEntry} entry @returns {string[]} */
547
+ function entryLines(entry) {
548
+ if (entry.at === null) return [` (note) ${clip(entry.text)}`];
549
+ const head = ` [${entry.at}] [${entry.stage}]${entry.outcome === null ? '' : ` ${entry.outcome}`}`;
550
+ /** @type {string[]} */
551
+ const lines = [head];
552
+ if (entry.evidence !== null && entry.evidence !== '') lines.push(` evidence: ${clip(entry.evidence)}`);
553
+ if (entry.remaining !== null && entry.remaining !== '') lines.push(` left: ${clip(entry.remaining)}`);
554
+ return lines;
555
+ }
556
+
557
+ /**
558
+ * Lay the brief out at the requested selection. No section prints a heading with nothing
559
+ * under it: an empty section states the fact in one line.
560
+ * @param {BriefModel} model
561
+ * @param {{ trailCount: number, provenanceCount: number, acceptanceCount: number, blockedCount: number }} selection
562
+ * @returns {string[]}
563
+ */
564
+ export function composeBrief(model, selection) {
565
+ const { bead, trail, provenance, acceptance } = model;
566
+ /** @type {string[]} */
567
+ const lines = [];
568
+
569
+ lines.push(`${bead.id} ${bead.status} ${bead.type} P${bead.priority}`);
570
+ if (bead.title !== '') lines.push(clip(bead.title));
571
+ lines.push(`labels: ${bead.labels.length === 0 ? 'none' : bead.labels.join(', ')}`);
572
+ lines.push(`parent: ${bead.parent ?? 'none'}`);
573
+ const next = nextStep(trail);
574
+ lines.push(`next: ${nextStepLine(next, trail.length)}`);
575
+
576
+ lines.push('');
577
+ if (bead.blockedBy.length === 0) {
578
+ lines.push('blocked by: none');
579
+ } else {
580
+ lines.push(`blocked by ${bead.blockedBy.length}:`);
581
+ for (const dep of bead.blockedBy.slice(0, selection.blockedCount)) {
582
+ lines.push(` ${dep.id} ${dep.status} ${clip(dep.title)}`);
583
+ }
584
+ const hidden = bead.blockedBy.length - selection.blockedCount;
585
+ if (hidden > 0) lines.push(` … ${hidden} more blocking`);
586
+ }
587
+
588
+ lines.push('');
589
+ if (trail.length === 0) {
590
+ lines.push('trail: no entries yet — this bead carries no trail');
591
+ } else {
592
+ const shown = trail.slice(Math.max(0, trail.length - selection.trailCount));
593
+ const earlier = trail.length - shown.length;
594
+ lines.push(`trail (newest ${shown.length} of ${trail.length}${earlier === 0 ? '' : `; ${earlier} earlier not shown`}):`);
595
+ for (const entry of shown) lines.push(...entryLines(entry));
596
+ }
597
+
598
+ lines.push('');
599
+ if (provenance.length === 0) {
600
+ lines.push('provenance: none');
601
+ } else {
602
+ const shown = provenance.slice(0, selection.provenanceCount);
603
+ const hidden = provenance.length - shown.length;
604
+ lines.push(`provenance (${shown.length} of ${provenance.length}):`);
605
+ for (const event of shown) lines.push(` ${event.refKind} ${clip(event.ref)}`);
606
+ if (hidden > 0) lines.push(` … ${hidden} earlier not shown`);
607
+ }
608
+
609
+ lines.push('');
610
+ if (acceptance.length === 0) {
611
+ lines.push('acceptance criteria: none recorded');
612
+ } else {
613
+ lines.push('acceptance criteria:');
614
+ for (const line of acceptance.slice(0, selection.acceptanceCount)) lines.push(` - ${clip(line)}`);
615
+ const hidden = acceptance.length - selection.acceptanceCount;
616
+ if (hidden > 0) lines.push(` … ${hidden} more`);
617
+ }
618
+
619
+ return lines;
620
+ }
621
+
622
+ /**
623
+ * Render the brief inside a line budget.
624
+ *
625
+ * The budget is enforced here rather than promised in prose. When the requested window is
626
+ * too wide the renderer shrinks it in order of least loss — oldest trail entries first,
627
+ * then oldest provenance, then trailing acceptance lines, then surplus blockers — and keeps
628
+ * a count of what it left out. As a last resort it truncates and says so, so no input can
629
+ * print more lines than `--max-lines`.
630
+ *
631
+ * @param {BriefModel} model
632
+ * @param {object} [options]
633
+ * @param {number} [options.maxLines]
634
+ * @param {number} [options.trailLimit]
635
+ * @param {number} [options.provenanceLimit]
636
+ * @returns {string} The brief, newline-terminated.
637
+ */
638
+ export function renderBrief(model, { maxLines = DEFAULT_MAX_LINES, trailLimit = DEFAULT_TRAIL_ENTRIES, provenanceLimit = DEFAULT_PROVENANCE_EVENTS } = {}) {
639
+ const selection = {
640
+ trailCount: Math.min(trailLimit, model.trail.length),
641
+ provenanceCount: Math.min(provenanceLimit, model.provenance.length),
642
+ acceptanceCount: Math.min(MAX_ACCEPTANCE_LINES, model.acceptance.length),
643
+ blockedCount: Math.min(MAX_BLOCKED_LINES, model.bead.blockedBy.length),
644
+ };
645
+
646
+ let lines = composeBrief(model, selection);
647
+ while (lines.length > maxLines) {
648
+ if (selection.trailCount > 1) selection.trailCount -= 1;
649
+ else if (selection.provenanceCount > 1) selection.provenanceCount -= 1;
650
+ else if (selection.acceptanceCount > 1) selection.acceptanceCount -= 1;
651
+ else if (selection.blockedCount > 1) selection.blockedCount -= 1;
652
+ else break;
653
+ lines = composeBrief(model, selection);
654
+ }
655
+
656
+ if (lines.length <= maxLines) return `${lines.join('\n')}\n`;
657
+ const kept = lines.slice(0, Math.max(1, maxLines - 1));
658
+ kept.push(`… truncated at ${maxLines} lines; --max-lines raises the budget`);
659
+ return `${kept.join('\n')}\n`;
660
+ }
661
+
662
+ // ─── CLI ──────────────────────────────────────────────────────────────────────
663
+
664
+ const USAGE = `Usage: node recover-brief.mjs --bead <id> [--trail <n>] [--provenance <n>] \\
665
+ [--max-lines <n>] [--cwd <dir>]
666
+
667
+ Prints a bounded brief for one bead: status and state labels, the newest trail entries, the
668
+ provenance refs, the blocking dependencies, and the acceptance criteria. The rest of the
669
+ trail is summarised by count.
670
+
671
+ Read-only: every bd call is a read subcommand carrying --readonly. This script never writes
672
+ to the tracker.
673
+
674
+ --trail trail entries printed (default: ${DEFAULT_TRAIL_ENTRIES}); the newest n are shown
675
+ --provenance provenance events printed (default: ${DEFAULT_PROVENANCE_EVENTS})
676
+ --max-lines the output budget the renderer enforces (default: ${DEFAULT_MAX_LINES})
677
+
678
+ Environment:
679
+ BD_PATH bd binary (default: bd)
680
+ BRIEF_TRAIL default for --trail
681
+ BRIEF_PROVENANCE default for --provenance
682
+ BRIEF_MAX_LINES default for --max-lines
683
+ BRIEF_ATTEMPTS attempts per bd read (default: ${DEFAULT_ATTEMPTS})
684
+ BRIEF_BACKOFF_MS first backoff step in ms (shared retry with the writer)
685
+ TRAIL_ENTRY_CALL_TIMEOUT_MS per-call timeout in ms, shared with the writer
686
+ `;
687
+
688
+ /**
689
+ * Parse the CLI's argv into `runCli` options.
690
+ * @param {string[]} argv
691
+ * @returns {{ beadId: string, trailLimit: number, provenanceLimit: number, maxLines: number, cwd: string }}
692
+ */
693
+ export function parseArgs(argv) {
694
+ /** @type {Record<string, string>} */
695
+ const values = {};
696
+ let cwd = process.cwd();
697
+
698
+ const FLAGS = new Map([
699
+ ['--bead', 'bead'],
700
+ ['--trail', 'trail'],
701
+ ['--provenance', 'provenance'],
702
+ ['--max-lines', 'maxLines'],
703
+ ['--cwd', 'cwd'],
704
+ ]);
705
+
706
+ for (let i = 0; i < argv.length; i++) {
707
+ const arg = argv[i];
708
+ const name = FLAGS.get(arg);
709
+ if (name === undefined) throw new UsageError(`unexpected argument: ${arg}`);
710
+ const value = argv[++i];
711
+ if (value === undefined) throw new UsageError(`${arg} needs a value`);
712
+ if (name === 'cwd') cwd = value;
713
+ else values[name] = value;
714
+ }
715
+
716
+ if (values.bead === undefined) throw new UsageError('--bead is required');
717
+
718
+ /** @param {string} flag @param {string | undefined} raw @param {number} fallback @param {number} min */
719
+ const count = (flag, raw, fallback, min) => {
720
+ if (raw === undefined) return fallback;
721
+ const value = Number(raw);
722
+ if (!Number.isInteger(value) || value < min) {
723
+ throw new UsageError(`${flag} must be an integer of at least ${min} (got ${JSON.stringify(raw)})`);
724
+ }
725
+ return value;
726
+ };
727
+
728
+ return {
729
+ beadId: values.bead,
730
+ trailLimit: count('--trail', values.trail, envInt('BRIEF_TRAIL', DEFAULT_TRAIL_ENTRIES), 1),
731
+ provenanceLimit: count('--provenance', values.provenance, envInt('BRIEF_PROVENANCE', DEFAULT_PROVENANCE_EVENTS), 0),
732
+ maxLines: count('--max-lines', values.maxLines, envInt('BRIEF_MAX_LINES', DEFAULT_MAX_LINES), 10),
733
+ cwd,
734
+ };
735
+ }
736
+
737
+ /**
738
+ * Read one bead and print its brief.
739
+ * @param {object} options
740
+ * @param {string[]} options.argv CLI arguments (without node/script).
741
+ * @param {(chunk: string) => void} [options.write] stdout sink; injectable for tests.
742
+ * @param {(chunk: string) => void} [options.writeError] stderr sink; injectable for tests.
743
+ * @param {BriefDeps} [options.deps] Collaborators; injectable for tests.
744
+ * @returns {Promise<number>} process exit code.
745
+ */
746
+ export async function runCli({
747
+ argv,
748
+ write = (chunk) => process.stdout.write(chunk),
749
+ writeError = (chunk) => process.stderr.write(chunk),
750
+ deps = {},
751
+ }) {
752
+ if (argv.includes('--help') || argv.includes('-h')) {
753
+ write(USAGE);
754
+ return 0;
755
+ }
756
+
757
+ let options;
758
+ try {
759
+ options = parseArgs(argv);
760
+ } catch (error) {
761
+ writeError(`recover-brief: ${errorMessage(error)}\n\n${USAGE}`);
762
+ return 2;
763
+ }
764
+
765
+ const bd = makeReadOnlyBd(deps.bd ?? makeRunner(BD_PATH, options.cwd));
766
+ const sleep = deps.sleep ?? ((ms) => new Promise((resolveSleep) => setTimeout(resolveSleep, ms)));
767
+ const attempts = envInt('BRIEF_ATTEMPTS', DEFAULT_ATTEMPTS);
768
+ const backoffMs = envInt('BRIEF_BACKOFF_MS', 100);
769
+
770
+ try {
771
+ // Sequential, not concurrent: embeddeddolt serialises readers too, and two calls in
772
+ // flight on a busy database is the shape that fails and retries.
773
+ const bead = await readBead({ beadId: options.beadId, bd, sleep, attempts, backoffMs });
774
+ const provenance = await readProvenance({ beadId: options.beadId, bd, sleep, attempts, backoffMs });
775
+
776
+ const model = {
777
+ bead,
778
+ trail: dedupeTrail(splitTrail(bead.notes)),
779
+ provenance,
780
+ acceptance: acceptanceOf(bead),
781
+ };
782
+ write(renderBrief(model, {
783
+ maxLines: options.maxLines,
784
+ trailLimit: options.trailLimit,
785
+ provenanceLimit: options.provenanceLimit,
786
+ }));
787
+ return 0;
788
+ } catch (error) {
789
+ const kind = error instanceof UsageError ? 'usage' : 'failed';
790
+ writeError(`recover-brief ${kind}: ${errorMessage(error)}\n`);
791
+ return error instanceof UsageError ? 2 : 1;
792
+ }
793
+ }
794
+
795
+ // Only run the CLI when invoked directly, so tests can import the module.
796
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
797
+ runCli({ argv: process.argv.slice(2) })
798
+ .then((code) => {
799
+ process.exitCode = code;
800
+ })
801
+ .catch((error) => {
802
+ process.stderr.write(`${errorMessage(error)}\n`);
803
+ process.exitCode = 1;
804
+ });
805
+ }