@jaybeeuu/agent-cortex 2.8.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 (41) hide show
  1. package/CHANGELOG.md +173 -0
  2. package/README.md +26 -3
  3. package/agents/README.md +2 -2
  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-plan/agent.md +6 -0
  9. package/bin/installers/claude.mjs +1 -20
  10. package/bin/installers/pi.mjs +8 -2
  11. package/extensions/interrupt-bookmark/README.md +129 -0
  12. package/extensions/interrupt-bookmark/index.test.ts +717 -0
  13. package/extensions/interrupt-bookmark/index.ts +354 -0
  14. package/extensions/interrupt-bookmark/package.json +20 -0
  15. package/extensions/skill-stats/README.md +84 -3
  16. package/extensions/skill-stats/index.ts +62 -3
  17. package/extensions/skill-stats/package.json +1 -1
  18. package/extensions/skill-stats/rollup.test.ts +643 -0
  19. package/extensions/skill-stats/rollup.ts +415 -0
  20. package/extensions/skill-stats/timeline.ts +101 -0
  21. package/package.json +1 -1
  22. package/scripts/lib/manifest.mjs +29 -0
  23. package/skills/review/review-defects/EXAMPLES.md +80 -4
  24. package/skills/review/review-defects/SKILL.md +45 -15
  25. package/skills/review/review-report/EXAMPLES.md +114 -0
  26. package/skills/review/review-report/SKILL.md +138 -0
  27. package/skills/review/review-report/scripts/report.mjs +1032 -0
  28. package/skills/workflow/hitl-queue/SKILL.md +134 -0
  29. package/skills/workflow/hitl-queue/scripts/queue.mjs +626 -0
  30. package/skills/workflow/plan/SKILL.md +13 -0
  31. package/skills/workflow/prioritise-work/CHALLENGE.md +139 -0
  32. package/skills/workflow/prioritise-work/SKILL.md +150 -0
  33. package/skills/workflow/prioritise-work/scripts/challenge.mjs +505 -0
  34. package/skills/workflow/prioritise-work/scripts/strategy.mjs +566 -0
  35. package/skills/workflow/prioritise-work/templates/strategy.md +61 -0
  36. package/skills/workflow/run-pipeline-stage/playbooks/review.md +5 -2
  37. package/skills/workflow/score-bead/FORMAT.md +73 -0
  38. package/skills/workflow/score-bead/SKILL.md +112 -0
  39. package/skills/workflow/score-bead/scripts/rice.mjs +664 -0
  40. package/skills/workflow/using-agent-skills/SKILL.md +6 -1
  41. package/token-map.json +8 -2
@@ -0,0 +1,505 @@
1
+ // @ts-check
2
+ // Turn a priority challenge into a strategy review, and refuse a silent reorder.
3
+ //
4
+ // "This should be higher" is a signal that the rubric is wrong, stale or shifted — not an
5
+ // instruction to edit one score. The only way an item moves is for the rubric that produces
6
+ // its score to change, and a rubric change is a `bump`: a new version, a new date, and one
7
+ // line in the change log saying why. This script is that rule made mechanical.
8
+ //
9
+ // It reads the strategy file, checks the version actually moved, diffs the order before
10
+ // against the order after, and prints one bead-trail entry recording the challenge and its
11
+ // resolution. It never writes to the tracker and never edits the strategy file: the trail
12
+ // entry is appended by the caller with `bd note`.
13
+ //
14
+ // The entry embeds the words the human used, so it must reach `bd` as data and never as a
15
+ // shell string. `--out <path>` writes the entry to a file, and `bd note <bead> --file
16
+ // <path>` reads it back verbatim — no quoting, no expansion, nothing to escape.
17
+ //
18
+ // The trail entry's shape is `run-pipeline-stage/scripts/trail-entry.mjs`'s — the header
19
+ // with a content fingerprint, then Outcome, Evidence and What is left. That script is the
20
+ // repo's one trail writer, and importing it across skills would break in the flat Claude
21
+ // Code install, so the four lines are rendered here instead and a test pins this renderer
22
+ // to that one.
23
+ //
24
+ // Zero runtime dependencies, async fs only. `skills/*/scripts` sits outside the
25
+ // check-no-sync scan, and this script stays async anyway so the rule holds everywhere.
26
+ //
27
+ // node challenge.mjs record --bead <id> --challenge "<words the human used>" \
28
+ // --resolution <changed|no-change> --reason "<one line>" --from-version <n> \
29
+ // [--before <ids>] [--after <ids>] [--file <path>] [--at <rfc3339>] [--out <path>] \
30
+ // [--json]
31
+ //
32
+ // Exit codes: 0 recorded, 1 refused, 2 the caller asked for something malformed.
33
+
34
+ import { createHash } from 'node:crypto';
35
+ import { readFile, writeFile } from 'node:fs/promises';
36
+ import { resolve } from 'node:path';
37
+ import { pathToFileURL } from 'node:url';
38
+
39
+ import { DEFAULT_FILE, UsageError, resolveStrategyPath, validateStrategy } from './strategy.mjs';
40
+
41
+ // ─── Contract ─────────────────────────────────────────────────────────────────
42
+
43
+ /** The two resolutions a strategy review can reach. @typedef {'changed' | 'no-change'} Resolution */
44
+
45
+ /**
46
+ * Both are valid outcomes. "no-change" is not a failure: the challenge was answered by the
47
+ * rubric as written, and the trail records that answer.
48
+ * @type {readonly Resolution[]}
49
+ */
50
+ export const RESOLUTIONS = /** @type {const} */ (['changed', 'no-change']);
51
+
52
+ /** The stage this entry carries. The trail reads a stage per entry, so a challenge has one. */
53
+ export const STAGE = 'challenge';
54
+
55
+ /** A resolved challenge ends the same way whether or not the rubric moved. */
56
+ export const OUTCOME = /** @type {const} */ ('done');
57
+
58
+ /** The fingerprint width, matching trail-entry.mjs. */
59
+ const FINGERPRINT_LENGTH = 16;
60
+
61
+ /** A refusal: the recording would hide the thing it exists to expose. Exit code 1. */
62
+ export class ChallengeError extends Error {}
63
+
64
+ /** One item that moved. Positions are 1-based, 1 being the top of the order.
65
+ * @typedef {object} Move
66
+ * @property {string} id
67
+ * @property {number} from
68
+ * @property {number} to
69
+ * @property {number} delta Positive when the item moved up.
70
+ */
71
+
72
+ /**
73
+ * What a positional diff found.
74
+ * @typedef {object} OrderDiff
75
+ * @property {Move[]} moved In both orders, at a different position.
76
+ * @property {{ id: string, to: number }[]} added Only in the new order.
77
+ * @property {{ id: string, from: number }[]} dropped Only in the old order.
78
+ * @property {boolean} identical
79
+ */
80
+
81
+ /**
82
+ * A recorded challenge.
83
+ * @typedef {object} ChallengeReport
84
+ * @property {string} bead
85
+ * @property {Resolution} resolution
86
+ * @property {number} fromVersion
87
+ * @property {number} version
88
+ * @property {string} updated
89
+ * @property {string} reason
90
+ * @property {string} evidence
91
+ * @property {string} remaining
92
+ * @property {string} entry
93
+ * @property {string} fingerprint
94
+ * @property {OrderDiff} [order] Absent when the caller named no order.
95
+ */
96
+
97
+ // ─── Value helpers ────────────────────────────────────────────────────────────
98
+
99
+ /** Collapse a value onto one line, so a field never breaks the entry's shape.
100
+ * @param {string} value @returns {string} */
101
+ export function oneLine(value) {
102
+ return value.replace(/\s+/g, ' ').trim();
103
+ }
104
+
105
+ /**
106
+ * Split a comma-separated order into bead ids. A duplicate id would make "where did it
107
+ * move to" ambiguous, so it is rejected rather than silently deduplicated.
108
+ * @param {string} value @returns {string[]}
109
+ */
110
+ export function parseOrder(value) {
111
+ const ids = value
112
+ .split(',')
113
+ .map((id) => id.trim())
114
+ .filter((id) => id !== '');
115
+ if (ids.length === 0) throw new UsageError('an order needs at least one bead id');
116
+ const seen = new Set();
117
+ for (const id of ids) {
118
+ if (seen.has(id)) throw new UsageError(`the order names "${id}" twice`);
119
+ seen.add(id);
120
+ }
121
+ return ids;
122
+ }
123
+
124
+ /**
125
+ * Diff two ranked id lists by position. Deterministic: items are reported by their new
126
+ * position, then the arrivals, then the departures by their old position, so two runs over
127
+ * the same input print the same words in the same order.
128
+ *
129
+ * The maps are filled from the lists, so iterating them already yields ascending positions;
130
+ * the sorts keep that order true whatever order the maps were filled in.
131
+ * @param {string[]} before @param {string[]} after @returns {OrderDiff}
132
+ */
133
+ export function diffOrder(before, after) {
134
+ /** @type {Map<string, number>} */
135
+ const beforeAt = new Map(before.map((id, index) => [id, index + 1]));
136
+ /** @type {Map<string, number>} */
137
+ const afterAt = new Map(after.map((id, index) => [id, index + 1]));
138
+
139
+ /** @type {Move[]} */
140
+ const moved = [];
141
+ /** @type {{ id: string, to: number }[]} */
142
+ const added = [];
143
+ /** @type {{ id: string, from: number }[]} */
144
+ const dropped = [];
145
+
146
+ for (const [id, to] of afterAt) {
147
+ const from = beforeAt.get(id);
148
+ if (from === undefined) added.push({ id, to });
149
+ else if (from !== to) moved.push({ id, from, to, delta: from - to });
150
+ }
151
+ for (const [id, from] of beforeAt) {
152
+ if (!afterAt.has(id)) dropped.push({ id, from });
153
+ }
154
+
155
+ moved.sort((a, b) => a.to - b.to);
156
+ added.sort((a, b) => a.to - b.to);
157
+ dropped.sort((a, b) => a.from - b.from);
158
+
159
+ return { moved, added, dropped, identical: moved.length === 0 && added.length === 0 && dropped.length === 0 };
160
+ }
161
+
162
+ /**
163
+ * Name every movement in one clause, so the trail says what moved rather than that
164
+ * something did.
165
+ * @param {OrderDiff} diff @returns {string}
166
+ */
167
+ export function describeMoves(diff) {
168
+ const parts = [
169
+ ...diff.moved.map((move) => `${move.id} #${move.from}→#${move.to}`),
170
+ ...diff.added.map((entry) => `${entry.id} new at #${entry.to}`),
171
+ ...diff.dropped.map((entry) => `${entry.id} dropped from #${entry.from}`),
172
+ ];
173
+ return parts.length === 0 ? 'nothing moved' : parts.join(', ');
174
+ }
175
+
176
+ // ─── The trail entry ──────────────────────────────────────────────────────────
177
+
178
+ /**
179
+ * The entry's content fingerprint, computed the way trail-entry.mjs computes it: the same
180
+ * fields fired twice are the same entry, so a repeated fire appends no near-duplicate.
181
+ * @param {object} input
182
+ * @param {string} input.beadId
183
+ * @param {string} input.stage
184
+ * @param {string} input.outcome
185
+ * @param {string} input.evidence
186
+ * @param {string} input.remaining
187
+ * @returns {string}
188
+ */
189
+ export function fingerprintEntry({ beadId, stage, outcome, evidence, remaining }) {
190
+ const key = [beadId, stage, outcome, evidence, remaining, '', '', '', ''].join('\u0000');
191
+ return createHash('sha256').update(key).digest('hex').slice(0, FINGERPRINT_LENGTH);
192
+ }
193
+
194
+ /**
195
+ * The note text: a timestamped header carrying the fingerprint, then the three mandatory
196
+ * fields. No refs line — a challenge names no commit, branch or pull request.
197
+ * @param {object} input
198
+ * @param {string} input.at
199
+ * @param {string} input.stage
200
+ * @param {string} input.outcome
201
+ * @param {string} input.evidence
202
+ * @param {string} input.remaining
203
+ * @param {string} input.fingerprint
204
+ * @returns {string}
205
+ */
206
+ export function buildEntry({ at, stage, outcome, evidence, remaining, fingerprint }) {
207
+ return [
208
+ `[${at}] [${stage}] trail:${fingerprint}`,
209
+ `Outcome: ${outcome}`,
210
+ `Evidence: ${oneLine(evidence)}`,
211
+ `What is left: ${oneLine(remaining)}`,
212
+ ].join('\n');
213
+ }
214
+
215
+ // ─── The review ───────────────────────────────────────────────────────────────
216
+
217
+ /**
218
+ * Hold one challenge against the strategy file and decide what may be recorded.
219
+ *
220
+ * Three refusals, and each one is a failure mode this skill exists to prevent:
221
+ * - the strategy is invalid, so no score can honestly be judged against it;
222
+ * - the resolution says "changed" but the version did not rise, or says "no-change" while
223
+ * the version rose — the record would contradict the file;
224
+ * - the order moved while the version stayed put. That is the silent reorder itself.
225
+ *
226
+ * @param {object} input
227
+ * @param {string} input.text The strategy file's contents.
228
+ * @param {string} input.beadId
229
+ * @param {string} input.challenge The words the human used.
230
+ * @param {Resolution} input.resolution
231
+ * @param {string} input.reason Why the rubric did or did not change.
232
+ * @param {number} input.fromVersion The version the challenged order was scored against.
233
+ * @param {string[]} [input.before]
234
+ * @param {string[]} [input.after]
235
+ * @param {string} input.at
236
+ * @returns {ChallengeReport}
237
+ */
238
+ export function reviewChallenge({ text, beadId, challenge, resolution, reason, fromVersion, before, after, at }) {
239
+ const strategy = validateStrategy(text);
240
+ if (!strategy.ok) {
241
+ throw new ChallengeError(
242
+ 'the strategy is not valid, so no score can be judged against it:\n' +
243
+ strategy.errors.map((error) => ` - ${error}`).join('\n'),
244
+ );
245
+ }
246
+ const version = /** @type {number} */ (strategy.version);
247
+ const updated = /** @type {string} */ (strategy.updated);
248
+ const cleanReason = oneLine(reason);
249
+ if (cleanReason === '') throw new ChallengeError('a challenge is recorded with its reasoning — pass --reason');
250
+ if (oneLine(challenge) === '') throw new ChallengeError('a challenge is recorded with the words that raised it — pass --challenge');
251
+
252
+ if (before === undefined !== (after === undefined)) {
253
+ throw new ChallengeError('--before and --after go together: one order to compare is not a comparison');
254
+ }
255
+ const order = before !== undefined && after !== undefined ? diffOrder(before, after) : undefined;
256
+
257
+ if (resolution === 'changed') {
258
+ if (version <= fromVersion) {
259
+ throw new ChallengeError(
260
+ `resolution "changed" but the strategy version did not rise (v${fromVersion} → v${version}): ` +
261
+ 'edit the rubric, then run "bump --reason", or record "no-change" if the rubric stands',
262
+ );
263
+ }
264
+ if (order === undefined) {
265
+ throw new ChallengeError('resolution "changed" needs --before and --after, so the record names what moved');
266
+ }
267
+ } else if (version !== fromVersion) {
268
+ throw new ChallengeError(
269
+ `the strategy version moved (v${fromVersion} → v${version}), so the resolution is "changed", not "no-change"`,
270
+ );
271
+ }
272
+
273
+ // The guard, whichever way round the caller passed the arguments: an order that moved
274
+ // while the version stayed put is a score edited without the rubric moving.
275
+ if (order !== undefined && !order.identical && version === fromVersion) {
276
+ throw new ChallengeError(
277
+ `the order moved (${describeMoves(order)}) but the strategy stayed at v${version}: ` +
278
+ 'a reorder without a rubric change is the silent reorder this skill refuses — ' +
279
+ 'change the rubric and bump, or leave the order alone',
280
+ );
281
+ }
282
+
283
+ const evidence =
284
+ resolution === 'changed'
285
+ ? `challenged "${oneLine(challenge)}"; strategy v${fromVersion} → v${version} (updated ${updated}); ` +
286
+ `rubric reason: ${cleanReason}; order ${/** @type {string[]} */ (after).length} items, ` +
287
+ `${/** @type {OrderDiff} */ (order).moved.length} moved: ${describeMoves(/** @type {OrderDiff} */ (order))}`
288
+ : `challenged "${oneLine(challenge)}" against v${version} (updated ${updated}); ` +
289
+ `no rubric change: ${cleanReason}; the order did not move`;
290
+
291
+ const remaining =
292
+ resolution === 'changed'
293
+ ? `nothing — scores are re-ranked against strategy v${version}`
294
+ : `nothing — the queue stands and the challenge is answered`;
295
+
296
+ const fingerprint = fingerprintEntry({ beadId, stage: STAGE, outcome: OUTCOME, evidence, remaining });
297
+ return {
298
+ bead: beadId,
299
+ resolution,
300
+ fromVersion,
301
+ version,
302
+ updated,
303
+ reason: cleanReason,
304
+ evidence: oneLine(evidence),
305
+ remaining,
306
+ entry: buildEntry({ at, stage: STAGE, outcome: OUTCOME, evidence, remaining, fingerprint }),
307
+ fingerprint,
308
+ ...(order === undefined ? {} : { order }),
309
+ };
310
+ }
311
+
312
+ // ─── CLI ──────────────────────────────────────────────────────────────────────
313
+
314
+ /** @type {readonly string[]} */
315
+ export const COMMANDS = /** @type {const} */ (['record']);
316
+
317
+ const USAGE = `Usage: node challenge.mjs record [options]
318
+
319
+ record Check the challenge against the strategy and print one bead-trail entry.
320
+
321
+ Options:
322
+ --bead <id> The bead whose priority was challenged. Required.
323
+ --challenge <words> What the human said. Required.
324
+ --resolution <r> changed | no-change. Required.
325
+ --reason <text> Why the rubric changed, or why it stands. Required.
326
+ --from-version <n> Strategy version the challenged order was scored against. Required.
327
+ --before <ids> The order before the review, highest first, comma-separated.
328
+ --after <ids> The order after the review, highest first, comma-separated.
329
+ --file <path> Strategy file (default: ${DEFAULT_FILE}, relative to the repository root)
330
+ --at <rfc3339> Timestamp for the entry header (default: now, UTC)
331
+ --out <path> Write the entry to a path, so the tracker reads it as data. Use this.
332
+ --json Print a machine report instead of the entry text
333
+ -h, --help Show this text
334
+
335
+ stdout is the trail entry alone; status goes to stderr. Append it from a file, never as a
336
+ shell string: the entry carries the words the human used, and a quote in those words must
337
+ not end the quoting.
338
+
339
+ node challenge.mjs record … --out /tmp/entry.txt
340
+ bd note <bead> --file /tmp/entry.txt
341
+
342
+ \`bd note <bead> "$(cat /tmp/entry.txt)"\` is the same thing: a command substitution is data,
343
+ not shell code, so nothing in the entry is expanded twice. This script never writes to the
344
+ tracker and never edits the strategy file.
345
+ `;
346
+
347
+ /**
348
+ * Parse the CLI's argv.
349
+ * @param {string[]} argv
350
+ * @returns {{ command: string, bead: string, challenge: string, resolution: Resolution,
351
+ * reason: string, fromVersion: number, before?: string[], after?: string[],
352
+ * file: string, at?: string, out?: string, json: boolean }}
353
+ */
354
+ export function parseArgs(argv) {
355
+ /** @type {Record<string, string>} */
356
+ const values = {};
357
+ /** @type {string | undefined} */
358
+ let command;
359
+ let json = false;
360
+
361
+ /** @type {readonly string[]} */
362
+ const flags = ['--bead', '--challenge', '--resolution', '--reason', '--from-version', '--before', '--after', '--file', '--at', '--out'];
363
+
364
+ for (let i = 0; i < argv.length; i++) {
365
+ const arg = argv[i];
366
+ if (arg === '--json') {
367
+ json = true;
368
+ continue;
369
+ }
370
+ if (flags.includes(arg)) {
371
+ const value = argv[++i];
372
+ if (value === undefined) throw new UsageError(`${arg} needs a value`);
373
+ values[arg] = value;
374
+ continue;
375
+ }
376
+ if (arg.startsWith('-')) throw new UsageError(`unexpected argument: ${arg}`);
377
+ if (command !== undefined) throw new UsageError(`unexpected argument: ${arg}`);
378
+ if (!COMMANDS.includes(arg)) throw new UsageError(`unknown command "${arg}"`);
379
+ command = arg;
380
+ }
381
+
382
+ if (command === undefined) throw new UsageError(`a command is required: ${COMMANDS.join(', ')}`);
383
+ for (const required of ['--bead', '--challenge', '--resolution', '--reason', '--from-version']) {
384
+ if (values[required] === undefined) throw new UsageError(`${required} is required`);
385
+ }
386
+
387
+ const resolution = values['--resolution'];
388
+ if (!RESOLUTIONS.includes(/** @type {Resolution} */ (resolution))) {
389
+ throw new UsageError(`--resolution must be ${RESOLUTIONS.join(' or ')}, got "${resolution}"`);
390
+ }
391
+
392
+ const rawVersion = values['--from-version'];
393
+ if (!/^\d+$/.test(rawVersion)) throw new UsageError(`--from-version must be an integer, got "${rawVersion}"`);
394
+
395
+ return {
396
+ command,
397
+ bead: values['--bead'],
398
+ challenge: values['--challenge'],
399
+ resolution: /** @type {Resolution} */ (resolution),
400
+ reason: values['--reason'],
401
+ fromVersion: Number(rawVersion),
402
+ ...(values['--before'] === undefined ? {} : { before: parseOrder(values['--before']) }),
403
+ ...(values['--after'] === undefined ? {} : { after: parseOrder(values['--after']) }),
404
+ file: values['--file'] ?? DEFAULT_FILE,
405
+ ...(values['--at'] === undefined ? {} : { at: values['--at'] }),
406
+ ...(values['--out'] === undefined ? {} : { out: values['--out'] }),
407
+ json,
408
+ };
409
+ }
410
+
411
+ /**
412
+ * @param {object} options
413
+ * @param {string[]} options.argv CLI arguments (without node/script).
414
+ * @param {(chunk: string) => void} [options.write] stdout sink; injectable for tests.
415
+ * @param {(chunk: string) => void} [options.writeError] stderr sink; injectable for tests.
416
+ * @param {string} [options.cwd] Directory to resolve from.
417
+ * @param {string} [options.now] Timestamp for the entry; injectable for tests.
418
+ * @returns {Promise<number>} process exit code.
419
+ */
420
+ export async function runCli({
421
+ argv,
422
+ write = (chunk) => process.stdout.write(chunk),
423
+ writeError = (chunk) => process.stderr.write(chunk),
424
+ cwd = process.cwd(),
425
+ now,
426
+ }) {
427
+ if (argv.includes('--help') || argv.includes('-h')) {
428
+ write(USAGE);
429
+ return 0;
430
+ }
431
+
432
+ /** @type {ReturnType<typeof parseArgs>} */
433
+ let options;
434
+ try {
435
+ options = parseArgs(argv);
436
+ } catch (error) {
437
+ writeError(`challenge: ${error instanceof Error ? error.message : String(error)}\n\n${USAGE}`);
438
+ return 2;
439
+ }
440
+
441
+ const file = await resolveStrategyPath({ file: options.file, cwd });
442
+
443
+ try {
444
+ /** @type {string} */
445
+ let text;
446
+ try {
447
+ text = await readFile(file, 'utf8');
448
+ } catch {
449
+ throw new ChallengeError(`${file} does not exist — there is no rubric to challenge; run "strategy.mjs init" and interview the owner`);
450
+ }
451
+
452
+ const report = reviewChallenge({
453
+ text,
454
+ beadId: options.bead,
455
+ challenge: options.challenge,
456
+ resolution: options.resolution,
457
+ reason: options.reason,
458
+ fromVersion: options.fromVersion,
459
+ before: options.before,
460
+ after: options.after,
461
+ at: options.at ?? now ?? new Date().toISOString(),
462
+ });
463
+
464
+ if (options.out !== undefined) {
465
+ await writeFile(resolve(cwd, options.out), `${report.entry}\n`, 'utf8');
466
+ writeError(` entry: ${resolve(cwd, options.out)}\n`);
467
+ }
468
+
469
+ if (options.json) {
470
+ write(`${JSON.stringify({ file, ...report }, null, 2)}\n`);
471
+ } else {
472
+ write(`${report.entry}\n`);
473
+ }
474
+
475
+ // The entry must reach the tracker as data, so the hint names a file and never a quoted
476
+ // string. A caller that passed no --out is told how to get one.
477
+ const next =
478
+ options.out === undefined
479
+ ? ` next: bd note ${report.bead} --file <path> — pass --out <path> to write the entry there`
480
+ : ` next: bd note ${report.bead} --file ${resolve(cwd, options.out)}`;
481
+
482
+ writeError(
483
+ `✓ ${report.bead}: resolution=${report.resolution}, strategy v${report.fromVersion} → v${report.version}` +
484
+ (report.order === undefined ? '\n' : `\n moved: ${describeMoves(report.order)}\n`) +
485
+ `${next}\n`,
486
+ );
487
+ return 0;
488
+ } catch (error) {
489
+ const kind = error instanceof UsageError ? 'usage' : 'refused';
490
+ writeError(`challenge ${kind}: ${error instanceof Error ? error.message : String(error)}\n`);
491
+ return error instanceof UsageError ? 2 : 1;
492
+ }
493
+ }
494
+
495
+ // Only run the CLI when invoked directly, so tests can import the module.
496
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
497
+ runCli({ argv: process.argv.slice(2) })
498
+ .then((code) => {
499
+ process.exitCode = code;
500
+ })
501
+ .catch((error) => {
502
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
503
+ process.exitCode = 1;
504
+ });
505
+ }