@jaybeeuu/agent-cortex 2.7.0 → 2.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +412 -0
- package/README.md +87 -8
- package/agents/README.md +4 -4
- package/agents/guppi/agent.md +162 -0
- package/agents/guppi/claude/frontmatter.json +6 -0
- package/agents/guppi/pi/frontmatter.json +6 -0
- package/agents/plan/agent.md +6 -0
- package/agents/ralph/agent.md +17 -16
- package/agents/ralph/claude/wake.md +4 -4
- package/agents/ralph/pi/wake.md +15 -20
- package/agents/ralph-plan/agent.md +6 -0
- package/bin/agent-cortex.mjs +20 -1
- package/bin/installers/claude.mjs +1 -20
- package/bin/installers/pi.mjs +8 -2
- package/extensions/interrupt-bookmark/README.md +129 -0
- package/extensions/interrupt-bookmark/index.test.ts +717 -0
- package/extensions/interrupt-bookmark/index.ts +354 -0
- package/extensions/interrupt-bookmark/package.json +20 -0
- package/extensions/post-merge-watch/README.md +133 -0
- package/extensions/post-merge-watch/index.test.ts +247 -0
- package/extensions/post-merge-watch/index.ts +123 -0
- package/extensions/post-merge-watch/package.json +16 -0
- package/extensions/post-merge-watch/watch.test.ts +469 -0
- package/extensions/post-merge-watch/watch.ts +431 -0
- package/extensions/session-clear/README.md +74 -0
- package/extensions/session-clear/index.test.ts +524 -0
- package/extensions/session-clear/index.ts +159 -0
- package/extensions/session-clear/package.json +16 -0
- package/extensions/skill-stats/README.md +84 -3
- package/extensions/skill-stats/index.ts +62 -3
- package/extensions/skill-stats/package.json +1 -1
- package/extensions/skill-stats/rollup.test.ts +643 -0
- package/extensions/skill-stats/rollup.ts +415 -0
- package/extensions/skill-stats/timeline.ts +101 -0
- package/lib/cli.mjs +42 -1
- package/lib/self-update.mjs +553 -0
- package/package.json +1 -1
- package/scripts/lib/manifest.mjs +29 -0
- package/skills/engineering/implement-task/SKILL.md +124 -0
- package/skills/engineering/tdd/SKILL.md +2 -0
- package/skills/planning/create-task/REFERENCE.md +15 -6
- package/skills/planning/create-task/SKILL.md +1 -1
- package/skills/planning/create-task/pipeline.json +8 -0
- package/skills/planning/create-task/scripts/create-chores.mjs +27 -18
- package/skills/planning/create-task/templates/code.md +8 -3
- package/skills/planning/create-task/templates/document.md +8 -3
- package/skills/planning/create-task/templates/fix.md +8 -3
- package/skills/planning/create-task/templates/housekeeping.md +8 -4
- package/skills/planning/create-task/templates/post-merge.md +67 -0
- package/skills/planning/create-task/templates/review.md +8 -3
- package/skills/planning/create-task/templates/verify.md +8 -3
- package/skills/productivity/bd-tool/SKILL.md +55 -0
- package/skills/review/review-defects/EXAMPLES.md +198 -0
- package/skills/review/review-defects/SKILL.md +121 -0
- package/skills/review/review-report/EXAMPLES.md +114 -0
- package/skills/review/review-report/SKILL.md +138 -0
- package/skills/review/review-report/scripts/report.mjs +1032 -0
- package/skills/style/style-tests/EXAMPLES.md +30 -0
- package/skills/style/style-tests/SKILL.md +10 -6
- package/skills/workflow/hitl-queue/SKILL.md +134 -0
- package/skills/workflow/hitl-queue/scripts/queue.mjs +626 -0
- package/skills/workflow/plan/SKILL.md +27 -3
- package/skills/workflow/prioritise-work/CHALLENGE.md +139 -0
- package/skills/workflow/prioritise-work/SKILL.md +150 -0
- package/skills/workflow/prioritise-work/scripts/challenge.mjs +505 -0
- package/skills/workflow/prioritise-work/scripts/strategy.mjs +566 -0
- package/skills/workflow/prioritise-work/templates/strategy.md +61 -0
- package/skills/workflow/ralph/REFERENCE.md +124 -27
- package/skills/workflow/ralph/SKILL.md +24 -15
- package/skills/workflow/ralph/scripts/post-merge-check.mjs +651 -0
- package/skills/workflow/run-pipeline-stage/SKILL.md +37 -22
- package/skills/workflow/run-pipeline-stage/playbooks/code.md +14 -8
- package/skills/workflow/run-pipeline-stage/playbooks/document.md +2 -2
- package/skills/workflow/run-pipeline-stage/playbooks/fix.md +2 -2
- package/skills/workflow/run-pipeline-stage/playbooks/housekeeping.md +1 -1
- package/skills/workflow/run-pipeline-stage/playbooks/post-merge.md +54 -0
- package/skills/workflow/run-pipeline-stage/playbooks/review.md +37 -3
- package/skills/workflow/run-pipeline-stage/playbooks/verify.md +2 -2
- package/skills/workflow/run-pipeline-stage/prompts/stage-runner.md +55 -11
- package/skills/workflow/run-pipeline-stage/scripts/recover-brief.mjs +805 -0
- package/skills/workflow/run-pipeline-stage/scripts/trail-entry.mjs +702 -0
- package/skills/workflow/score-bead/FORMAT.md +73 -0
- package/skills/workflow/score-bead/SKILL.md +112 -0
- package/skills/workflow/score-bead/scripts/rice.mjs +664 -0
- package/skills/workflow/using-agent-skills/SKILL.md +21 -8
- package/token-map.README.md +27 -0
- package/token-map.json +16 -4
|
@@ -0,0 +1,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
|
+
}
|