@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.
- package/CHANGELOG.md +173 -0
- package/README.md +26 -3
- package/agents/README.md +2 -2
- 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-plan/agent.md +6 -0
- 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/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/package.json +1 -1
- package/scripts/lib/manifest.mjs +29 -0
- package/skills/review/review-defects/EXAMPLES.md +80 -4
- package/skills/review/review-defects/SKILL.md +45 -15
- 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/workflow/hitl-queue/SKILL.md +134 -0
- package/skills/workflow/hitl-queue/scripts/queue.mjs +626 -0
- package/skills/workflow/plan/SKILL.md +13 -0
- 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/run-pipeline-stage/playbooks/review.md +5 -2
- 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 +6 -1
- 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
|
+
}
|