@jaybeeuu/agent-cortex 2.8.0 → 2.10.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 (52) hide show
  1. package/CHANGELOG.md +304 -0
  2. package/README.md +30 -3
  3. package/agents/README.md +7 -2
  4. package/agents/guppi/agent.md +231 -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 +8 -23
  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/compose-agent.mjs +45 -7
  23. package/scripts/lib/manifest.mjs +29 -0
  24. package/skills/review/review-defects/EXAMPLES.md +80 -4
  25. package/skills/review/review-defects/SKILL.md +47 -16
  26. package/skills/review/review-report/EXAMPLES.md +118 -0
  27. package/skills/review/review-report/SKILL.md +138 -0
  28. package/skills/review/review-report/scripts/report.mjs +1032 -0
  29. package/skills/workflow/hitl-queue/BOUNDARY.md +122 -0
  30. package/skills/workflow/hitl-queue/DEADLINES.md +59 -0
  31. package/skills/workflow/hitl-queue/RECOMMEND.md +89 -0
  32. package/skills/workflow/hitl-queue/SCOPES.md +118 -0
  33. package/skills/workflow/hitl-queue/SKILL.md +150 -0
  34. package/skills/workflow/hitl-queue/scripts/boundary.mjs +493 -0
  35. package/skills/workflow/hitl-queue/scripts/queue.mjs +1012 -0
  36. package/skills/workflow/hitl-queue/scripts/recommend.mjs +492 -0
  37. package/skills/workflow/hitl-queue/scripts/scopes.mjs +479 -0
  38. package/skills/workflow/plan/SKILL.md +13 -0
  39. package/skills/workflow/prioritise-work/CHALLENGE.md +139 -0
  40. package/skills/workflow/prioritise-work/SKILL.md +150 -0
  41. package/skills/workflow/prioritise-work/scripts/challenge.mjs +505 -0
  42. package/skills/workflow/prioritise-work/scripts/strategy.mjs +756 -0
  43. package/skills/workflow/prioritise-work/templates/strategy.md +87 -0
  44. package/skills/workflow/ralph/REFERENCE.md +6 -1
  45. package/skills/workflow/ralph/scripts/prune-worktrees.mjs +185 -10
  46. package/skills/workflow/run-pipeline-stage/playbooks/review.md +5 -2
  47. package/skills/workflow/score-bead/FORMAT.md +73 -0
  48. package/skills/workflow/score-bead/SKILL.md +112 -0
  49. package/skills/workflow/score-bead/scripts/rice.mjs +664 -0
  50. package/skills/workflow/using-agent-skills/SKILL.md +6 -1
  51. package/token-map.README.md +14 -2
  52. package/token-map.json +17 -3
@@ -0,0 +1,150 @@
1
+ ---
2
+ name: prioritise-work
3
+ description: Sets, updates and validates the committed prioritisation strategy that bead scoring ranks against. Use when choosing what to work on next, when the strategy file is missing or stale, or when asked to "set the strategy".
4
+ ---
5
+
6
+ # Prioritise Work
7
+
8
+ Owns one artefact: the **strategy file**, the rubric a scorer reads before it judges a bead's reach, impact, confidence and effort. The skill interviews the owner to write or update it, and validates it before any scorer reads it.
9
+
10
+ The strategy is a **committed file**, never a bead and never a scratch note. A rubric is reviewed, diffed and versioned as text; a bead carries progress and decisions about *work*, not the durable document that judges work. The tracker convention draws the same line: operational state — progress, decisions, refs — goes in the bead trail, and a document a reviewer diffs stays a file. Keep the strategy a file even when a bead could hold the same words, because then nobody can review a change to the rubric.
11
+
12
+ ## When to use
13
+
14
+ - "What should I work on next?" — and there is no strategy file, or the one there is predates the current month.
15
+ - "Set the strategy", "update the priorities", "we are optimising something else this month".
16
+ - "This should be higher", "that is not the priority" — a challenge to the order, which is a strategy signal. See [CHALLENGE.md](CHALLENGE.md).
17
+ - A scorer reports a score with no `strategyVersion`, or with one older than the file's.
18
+ - Before ranking a backlog: the rubric has to be valid first.
19
+
20
+ ## When NOT to use
21
+
22
+ - Shaping one feature — use `plan`; that skill scopes a feature, this one orders the work.
23
+ - Recording an idea — use `record-idea`; tracker questions, or where a bead's state lives — use `bd-tool`.
24
+ - Writing a PRD or a design document — use `write-a-prd`.
25
+
26
+ ## The strategy file
27
+
28
+ | Item | Value |
29
+ |---|---|
30
+ | Default path | `.agent-cortex/working-docs/strategy.md`, relative to the repository root |
31
+ | Override | `--file <path>` on every subcommand |
32
+ | Contents | a version integer, a date, five required sections, and an optional `## Deadline tiers` table |
33
+ | Daily line | one `- daily: <scope>, <scope> — <why>` bullet under `## Current themes` |
34
+ | Lifetime | committed; edited by interview, then versioned with `bump` |
35
+
36
+ The working-docs root is the scratch directory the HITL handoff also uses: `.agent-cortex/working-docs/hitl/<bead-id>/handoff.md`. The strategy sits beside it and is the one file in that directory the repository commits.
37
+
38
+ The default path resolves against the **repository root**, found by walking up for a `.git` entry, not against the directory the shell is in. Run `init` from any subdirectory and you still get the one strategy the repository owns. An override behaves differently: a relative `--file` resolves against the current directory, and an absolute one is taken as given.
39
+
40
+ That directory's contents are usually ignored (`working-docs/*`), so track the strategy explicitly. Add the un-ignore line to the consuming repository's `.gitignore` once:
41
+
42
+ ```gitignore
43
+ !.agent-cortex/working-docs/strategy.md
44
+ ```
45
+
46
+ Use that rather than `git add -f`: an ignored file disappears from `git status`, so the next update is committed by nobody.
47
+
48
+ **One strategy, one workspace.** A rubric answers the question "what is worth doing here", so each bd workspace carries its own. The HITL queue reads several scopes at once; the **personal** scope's strategy is therefore also where the daily line lives, and the queue quotes it above the ranked list to say which scope the day belongs to. Write it under `## Current themes`, name the scopes first, then an em dash and the reason. `validate` does not require it: a strategy with no daily line is valid, and the queue says the note is missing rather than inventing one.
49
+
50
+ ## Workflow
51
+
52
+ ### First run — no strategy file
53
+
54
+ 1. Confirm the file is absent:
55
+
56
+ ```bash
57
+ node <skill-scripts>/strategy.mjs validate
58
+ ```
59
+
60
+ It exits 1 and names the path it looked for. Do not search the repository for an alternative: one strategy, one location, or two scorers rank against two rubrics.
61
+
62
+ 2. Write the shipped template to that path. This is the copy step — the script resolves its own template, so it works from either install layout:
63
+
64
+ ```bash
65
+ node <skill-scripts>/strategy.mjs init
66
+ ```
67
+
68
+ It stamps the date and leaves the version at `1`, and refuses to overwrite an existing file.
69
+
70
+ 3. Interview the owner. Ask one question at a time and write the answer under the section the table below names. Replace the guidance comment with the owner's own words; do not paraphrase a figure they gave.
71
+
72
+ 4. Validate before committing. `validate` prints the filled rubric to stdout only when it passes, and names every defective field or section on stderr otherwise:
73
+
74
+ ```bash
75
+ node <skill-scripts>/strategy.mjs validate
76
+ ```
77
+
78
+ 5. Commit the file with the un-ignore line. The interview is finished only when `validate` exits 0 and `git ls-files --error-unmatch .agent-cortex/working-docs/strategy.md` succeeds.
79
+
80
+ ### Later runs — the file exists
81
+
82
+ 6. Read the current strategy and check its `**Updated:**` date. A date from an earlier month is stale: re-run the interview before scoring anything against it.
83
+
84
+ 7. Ask the four questions again, plus the confidence question, and edit the sections that changed. Edit the deadline-tier table too when a due date should move a rank further or sooner.
85
+
86
+ 8. Bump the version and record the date and the reason, then validate:
87
+
88
+ ```bash
89
+ node <skill-scripts>/strategy.mjs bump --reason "<why the rubric changed>"
90
+ ```
91
+
92
+ The bump refuses a file that is not already valid and refuses an empty reason, raises the version by exactly one, writes today's date in UTC, and appends `- <date> v<n>: <reason>` to the change log. Report the old and new version to the owner.
93
+
94
+ 9. Commit the file. The version is what a score's `strategyVersion` points at, so a bump with no commit leaves scores pointing at a rubric nobody else has.
95
+
96
+ ### Interview questions
97
+
98
+ One question produces one section. Ask them in this order; each answer is one to four lines.
99
+
100
+ | Ask | Write into |
101
+ |---|---|
102
+ | What are you optimising this month? Name the outcome, not the feature. | `## Current themes` |
103
+ | What must a bead change to be worth doing now? | `## What counts as value` |
104
+ | When should a score be distrusted — which evidence is missing? | `## How to judge confidence` |
105
+ | What are you deliberately not doing this month, however good it looks? | `## Explicitly out of scope` |
106
+ | How long did comparable work take recently? Anchor the effort scale to it. | `## How to calibrate effort` |
107
+
108
+ The confidence question matters as much as the other four: effort and impact estimates are judgements, and the strategy is the only place the owner states how far to trust one.
109
+
110
+ ## The scripts
111
+
112
+ Two scripts, four subcommands, zero dependencies, no tracker access. `strategy.mjs` reads and writes the strategy file; `challenge.mjs` reads it and prints a trail entry. No run creates, tags or closes a bead.
113
+
114
+ | Command | Behaviour | Exit |
115
+ |---|---|---|
116
+ | `init` | Writes the template, stamped with today's date; refuses an existing file | 0 written, 1 refused |
117
+ | `validate` | Checks the version integer, the date, the change log, the deadline tiers and every required section; prints the file to stdout when valid | 0 valid, 1 invalid |
118
+ | `bump` | Refuses an invalid file or an empty `--reason`, raises the version by one, records today, appends the change-log line | 0 bumped, 1 refused |
119
+ | `record` (`challenge.mjs`) | Refuses an order or a resolution the strategy does not support; prints one bead-trail entry | 0 recorded, 1 refused |
120
+
121
+ `validate` splits its output: status and errors go to stderr, the rubric goes to stdout. `validate > rubric.md` captures the rubric alone, which is how a scorer loads it. A failing run names the file and lists one line per defect, so one fix round clears the whole file.
122
+
123
+ The date is checked as a **calendar** date, not as a shape. `2026-02-31` and `2026-04-31` fail in both `validate` and `--today`, because V8's ISO parser rolls them into the next month and accepts them; `2024-02-29` passes and `2026-02-29` does not. A rolled date records a change on a day it did not happen.
124
+
125
+ Exit code 2 means a malformed call — an unknown subcommand or a bad `--today`. `--today YYYY-MM-DD` exists so a test pins the recorded date; a person accepts the default.
126
+
127
+ ## Hand edits and forgotten bumps
128
+
129
+ A hand edit that changes a section but forgets `bump` still passes `validate`: the shape is intact, so nothing can tell the content moved. The protection is the version a score records — a scorer stores the `strategyVersion` it scored against, so a reader compares the two numbers and re-scores when they differ. The change log closes the rest of the gap: `validate` fails when its newest line names a version the header does not, so a raised version with no recorded reason is caught.
130
+
131
+ ## Red Flags
132
+
133
+ - Red flag: moving the strategy into a bead, a label or a scratch file because it is "just a rubric".
134
+ - Red flag: editing a strategy and skipping `bump`, or hand-raising the version, so the record hides the change.
135
+ - Red flag: editing a score to satisfy a challenge, instead of changing the rubric that produced it.
136
+ - Red flag: scoring against a file whose `validate` was never run — a malformed rubric scores silently.
137
+
138
+ ## Verification checklist
139
+
140
+ - [ ] `node <skill-scripts>/strategy.mjs validate` exits 0 and printed the rubric, and every section carries the owner's words.
141
+ - [ ] The version is one higher than the previous file for an update, or `1` for a new one, and the change log names it.
142
+ - [ ] A challenged order moved only after a bump, and the record names what moved.
143
+ - [ ] No bead was created, tagged or closed by this skill; `git ls-files --error-unmatch .agent-cortex/working-docs/strategy.md` succeeds.
144
+
145
+ ## See Also
146
+
147
+ - [CHALLENGE.md](CHALLENGE.md) — the sequence, the refusals and the no-change path when the order is challenged.
148
+ - [plan](../../workflow/plan/SKILL.md) — shapes one feature into beads; this skill orders the beads that exist.
149
+ - [bd-tool](../../productivity/bd-tool/SKILL.md) — where operational state lives, and why a durable document does not belong there.
150
+ - [score-bead](../../workflow/score-bead/SKILL.md) — turns this rubric into a RICE record on a bead; the queue's daily line is read by [hitl-queue](../hitl-queue/SCOPES.md).
@@ -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
+ }