@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,756 @@
1
+ // @ts-check
2
+ // The strategy file: create it, validate it, version it.
3
+ //
4
+ // The prioritisation strategy is a committed document — the rubric a scorer reads
5
+ // before it judges a bead's reach, impact, confidence and effort. It is never a bead
6
+ // and never a scratch file: operational state belongs in the bead trail, and durable
7
+ // documents that are reviewed, diffed and versioned belong in git.
8
+ //
9
+ // Three subcommands, and nothing else:
10
+ //
11
+ // node strategy.mjs init [--file <path>] write the template
12
+ // node strategy.mjs validate [--file <path>] check the shape, then print it
13
+ // node strategy.mjs bump [--file <path>] [--today d] raise the version, set the date
14
+ //
15
+ // `validate` prints the rubric to stdout only after it passes, so a scorer can never
16
+ // read a stale or malformed strategy: the file and the proof of its shape arrive
17
+ // together. Status goes to stderr, so `validate > rubric.md` captures the rubric alone.
18
+ //
19
+ // The script reads and writes one file and shells out to nothing — it never calls `bd`.
20
+ // A test puts a stub `bd` on PATH and asserts it is never invoked.
21
+ //
22
+ // Zero runtime dependencies, async fs only. `skills/*/scripts` sits outside the
23
+ // check-no-sync scan, and this script stays async anyway so the rule holds everywhere.
24
+ //
25
+ // Exit codes: 0 the file is valid, 1 the file is invalid or the operation refused,
26
+ // 2 the caller asked for something malformed.
27
+
28
+ import { access, mkdir, readFile, writeFile } from 'node:fs/promises';
29
+ import { dirname, join, resolve } from 'node:path';
30
+ import { fileURLToPath, pathToFileURL } from 'node:url';
31
+
32
+ // ─── Contract ─────────────────────────────────────────────────────────────────
33
+
34
+ /**
35
+ * The sections every strategy must carry, in reading order. One interview question
36
+ * produces each one, so the template, the skill's questions and this list agree.
37
+ * @type {readonly string[]}
38
+ */
39
+ export const REQUIRED_SECTIONS = [
40
+ 'Current themes',
41
+ 'What counts as value',
42
+ 'How to judge confidence',
43
+ 'Explicitly out of scope',
44
+ 'How to calibrate effort',
45
+ ];
46
+
47
+ /**
48
+ * The default location, relative to the consuming repository's root: the working-docs
49
+ * root, beside the HITL handoff directories. It is the one file in that directory the
50
+ * repository commits.
51
+ */
52
+ export const DEFAULT_FILE = '.agent-cortex/working-docs/strategy.md';
53
+
54
+ /**
55
+ * The shipped template, resolved from this script rather than from a hardcoded path, so
56
+ * it is found in both install layouts (grouped on pi, flattened on Claude Code).
57
+ */
58
+ export const TEMPLATE_FILE = fileURLToPath(new URL('../templates/strategy.md', import.meta.url));
59
+
60
+ /**
61
+ * The section that records why the rubric changed. Optional in a hand-written file —
62
+ * `bump` adds it when it is absent — so a strategy created before this section existed
63
+ * stays valid.
64
+ */
65
+ export const CHANGE_LOG_SECTION = 'Change log';
66
+
67
+ /**
68
+ * The section that tunes the deadline factor a ranked queue applies. Optional in a
69
+ * hand-written file: a strategy created before the tiers existed ranks on
70
+ * `DEFAULT_DEADLINE_TIERS`, and `init` copies a table the owner can edit.
71
+ */
72
+ export const DEADLINE_TIERS_SECTION = 'Deadline tiers';
73
+
74
+ /**
75
+ * The deadline tiers the queue uses when the strategy carries none. The numbers live here
76
+ * once, as the documented default, so a caller that never opens the strategy still ranks
77
+ * predictably; a strategy overrides them, so the rise tunes without a code change.
78
+ *
79
+ * A boundary belongs to the tier that names it: with exactly one day left a bead earns the
80
+ * `within 1d` factor, and a millisecond more drops it to `within 2d`. A bead whose deadline
81
+ * is exactly now has not slipped yet, so it is not overdue; `overdue` covers the past.
82
+ */
83
+ export const DEFAULT_DEADLINE_TIERS = /** @type {DeadlineTier[]} */ ([
84
+ { kind: 'within', minutes: 1440, factor: 4 },
85
+ { kind: 'within', minutes: 2880, factor: 2 },
86
+ { kind: 'within', minutes: 10080, factor: 1.5 },
87
+ { kind: 'beyond', minutes: 10080, factor: 1 },
88
+ { kind: 'overdue', factor: 8 },
89
+ ]);
90
+
91
+ /** The shape of a date: four digits, two, two. Whether it exists is a separate question. */
92
+ const DATE = /^\d{4}-\d{2}-\d{2}$/;
93
+
94
+ /** A markdown heading, with its title captured. */
95
+ const HEADING = /^\s*##\s+(.*?)\s*$/;
96
+
97
+ /** The version and the date are stored as bold key/value lines, so a reader can grep them. */
98
+ const FIELD = /^[ \t]*[-*][ \t]*\*\*([A-Za-z]+):\*\*[ \t]*(\S.*?)[ \t]*$/;
99
+
100
+ /** The markup in front of a field's value, so a new value replaces the old one whole. */
101
+ const FIELD_PREFIX = /^([ \t]*[-*][ \t]*\*\*[A-Za-z]+:\*\*[ \t]*)\S.*?[ \t]*$/;
102
+
103
+ /** One change-log line: the date it landed, the version it produced, and the reason. */
104
+ const CHANGE_LOG_ENTRY = /^[ \t]*[-*][ \t]+(\d{4}-\d{2}-\d{2})[ \t]+v(\d+):[ \t]*(.+?)[ \t]*$/;
105
+
106
+ /** One row of the deadline-tier table, with the cells the pipes delimit. */
107
+ const TIER_ROW = /^[ \t]*\|(.*)\|[ \t]*$/;
108
+
109
+ /** The table's header row, skipped as a label rather than read as a tier. */
110
+ const TIER_HEADER = /^(remaining|time|when|deadline)$/i;
111
+
112
+ /** The table's `|---|:--:|` rule, which carries no value. */
113
+ const TIER_RULE = /^:?-{2,}:?$/;
114
+
115
+ /** `within 7d` and `<= 7d`: the factor earned at or inside this much time left. */
116
+ const TIER_WITHIN = /^(?:within|<=)\s+(\d+)\s*([mhd])$/i;
117
+
118
+ /** `more than 7d` and `> 7d`: the factor for anything further out. */
119
+ const TIER_BEYOND = /^(?:more than|>)\s+(\d+)\s*([mhd])$/i;
120
+
121
+ /** `overdue`: the factor for a deadline already passed. */
122
+ const TIER_OVERDUE = /^overdue$/i;
123
+
124
+ /** A factor: a positive number, in the plain form a reader can check. */
125
+ const TIER_FACTOR = /^\d+(?:\.\d+)?$/;
126
+
127
+ /** How long each unit in a tier row lasts. */
128
+ const TIER_UNIT_MINUTES = Object.freeze(/** @type {const} */ ({ m: 1, h: 60, d: 1440 }));
129
+
130
+ /** How many milliseconds a minute holds, for turning a tier's minutes into a distance. */
131
+ const MS_PER_MINUTE = 60_000;
132
+
133
+ /**
134
+ * One deadline tier: the factor a bead earns at a stated distance from its deadline.
135
+ * @typedef {{ kind: 'overdue', factor: number }
136
+ * | { kind: 'within', minutes: number, factor: number }
137
+ * | { kind: 'beyond', minutes: number, factor: number }} DeadlineTier
138
+ */
139
+
140
+ /**
141
+ * What `parseDeadlineTiers` found.
142
+ * @typedef {object} DeadlineTiersReport
143
+ * @property {boolean} ok
144
+ * @property {DeadlineTier[]} tiers The parsed tiers, or the documented default when absent.
145
+ * @property {string[]} errors One line per defect in the table.
146
+ */
147
+
148
+ /** A usage mistake: the caller asked for something malformed. Exit code 2. */
149
+ export class UsageError extends Error {}
150
+
151
+ /** The strategy file is absent, or its shape is wrong. Exit code 1. */
152
+ export class StrategyError extends Error {}
153
+
154
+ // ─── Types ────────────────────────────────────────────────────────────────────
155
+
156
+ /**
157
+ * What `validate` found.
158
+ * @typedef {object} StrategyReport
159
+ * @property {boolean} ok
160
+ * @property {number} [version] Present only when the version parses.
161
+ * @property {string} [updated] Present only when the date parses.
162
+ * @property {string[]} errors One line per defect, each naming the field or section.
163
+ */
164
+
165
+ /**
166
+ * What `bump` produced.
167
+ * @typedef {object} BumpReport
168
+ * @property {number} from
169
+ * @property {number} to
170
+ * @property {string} updated
171
+ * @property {string} reason
172
+ * @property {string} line The change-log line the bump appended.
173
+ * @property {string} text
174
+ */
175
+
176
+ // ─── Value helpers ────────────────────────────────────────────────────────────
177
+
178
+ /**
179
+ * Every `**Key:** value` line in the text, keyed by the lower-cased key.
180
+ * @param {string} text @returns {Map<string, string>}
181
+ */
182
+ function fields(text) {
183
+ /** @type {Map<string, string>} */
184
+ const found = new Map();
185
+ for (const line of text.split('\n')) {
186
+ const match = FIELD.exec(line);
187
+ if (match === null) continue;
188
+ const key = match[1].toLowerCase();
189
+ if (!found.has(key)) found.set(key, match[2]);
190
+ }
191
+ return found;
192
+ }
193
+
194
+ /**
195
+ * Replace the value of a `**Key:** value` line, keeping its markup and indentation.
196
+ * Throws when the line is absent, because a bump that cannot date the file has nothing
197
+ * honest to write.
198
+ * @param {string} text @param {string} key @param {string} value @returns {string}
199
+ */
200
+ function setField(text, key, value) {
201
+ const wanted = key.toLowerCase();
202
+ let replaced = false;
203
+ return text
204
+ .split('\n')
205
+ .map((line) => {
206
+ const match = FIELD.exec(line);
207
+ if (replaced || match === null || match[1].toLowerCase() !== wanted) return line;
208
+ replaced = true;
209
+ return line.replace(FIELD_PREFIX, (_match, prefix) => `${prefix}${value}`);
210
+ })
211
+ .join('\n');
212
+ }
213
+
214
+ /**
215
+ * The body lines of `## <title>`, or null when the heading is absent. The body stops at
216
+ * the next `##` heading; a `###` sub-heading stays body.
217
+ * @param {string[]} lines @param {string} title @returns {string[] | null}
218
+ */
219
+ function sectionBody(lines, title) {
220
+ const wanted = title.toLowerCase();
221
+ const start = lines.findIndex((line) => HEADING.exec(line)?.[1].toLowerCase() === wanted);
222
+ if (start === -1) return null;
223
+
224
+ const body = [];
225
+ for (const line of lines.slice(start + 1)) {
226
+ if (HEADING.test(line)) break;
227
+ body.push(line);
228
+ }
229
+ return body;
230
+ }
231
+
232
+ /**
233
+ * The minutes a tier row's `<number><unit>` names.
234
+ * @param {string} count @param {string} unit @returns {number}
235
+ */
236
+ function tierMinutes(count, unit) {
237
+ const key = /** @type {'m'|'h'|'d'} */ (unit.toLowerCase());
238
+ return Number(count) * TIER_UNIT_MINUTES[key];
239
+ }
240
+
241
+ /**
242
+ * One table row as a tier, or a message naming what the row got wrong.
243
+ * @param {string} when The "Remaining" cell.
244
+ * @param {string} factorText The "Factor" cell.
245
+ * @returns {{ tier: DeadlineTier } | { error: string }}
246
+ */
247
+ function parseTierRow(when, factorText) {
248
+ const factor = TIER_FACTOR.test(factorText) ? Number(factorText) : 0;
249
+ if (factor <= 0) return { error: `factor "${factorText}" is not a positive number` };
250
+
251
+ if (TIER_OVERDUE.test(when)) return { tier: { kind: 'overdue', factor } };
252
+ const within = TIER_WITHIN.exec(when);
253
+ if (within !== null) return { tier: { kind: 'within', minutes: tierMinutes(within[1], within[2]), factor } };
254
+ const beyond = TIER_BEYOND.exec(when);
255
+ if (beyond !== null) return { tier: { kind: 'beyond', minutes: tierMinutes(beyond[1], beyond[2]), factor } };
256
+ return { error: `"${when}" is not "overdue", "within <time>" or "more than <time>"` };
257
+ }
258
+
259
+ /**
260
+ * Read the deadline tiers out of a strategy: the `## Deadline tiers` table, or the
261
+ * documented default when the section is absent or carries no row.
262
+ *
263
+ * A table that is present must be complete — exactly one `overdue` row, exactly one
264
+ * `more than <time>` row, and at least one `within <time>` row — because a factor that
265
+ * silently fell back to a default would make the rise depend on a file the reader cannot
266
+ * see. Every defect is reported, so one run fixes the whole table.
267
+ * @param {string} text @returns {DeadlineTiersReport}
268
+ */
269
+ export function parseDeadlineTiers(text) {
270
+ const body = sectionBody(text.split('\n'), DEADLINE_TIERS_SECTION);
271
+ if (body === null) return { ok: true, tiers: [...DEFAULT_DEADLINE_TIERS], errors: [] };
272
+
273
+ /** @type {DeadlineTier[]} */
274
+ const rows = [];
275
+ /** @type {string[]} */
276
+ const errors = [];
277
+ let firstTableLine = true;
278
+
279
+ for (const line of body) {
280
+ const cells = TIER_ROW.exec(line)?.[1].split('|');
281
+ if (cells === undefined || cells.length !== 2) continue;
282
+ const when = cells[0].trim();
283
+ const factorText = cells[1].trim();
284
+ const isFirst = firstTableLine;
285
+ firstTableLine = false;
286
+ if (isFirst && TIER_HEADER.test(when)) continue;
287
+ if (TIER_RULE.test(when) || TIER_RULE.test(factorText)) continue;
288
+
289
+ const parsed = parseTierRow(when, factorText);
290
+ if ('error' in parsed) {
291
+ errors.push(`${DEADLINE_TIERS_SECTION}: row "| ${when} | ${factorText} |" — ${parsed.error}`);
292
+ continue;
293
+ }
294
+ rows.push(parsed.tier);
295
+ }
296
+
297
+ if (rows.length === 0 && errors.length === 0) {
298
+ return { ok: true, tiers: [...DEFAULT_DEADLINE_TIERS], errors: [] };
299
+ }
300
+
301
+ if (errors.length === 0) {
302
+ const count = (/** @type {DeadlineTier['kind']} */ kind) => rows.filter((tier) => tier.kind === kind).length;
303
+ if (count('overdue') !== 1) errors.push(`${DEADLINE_TIERS_SECTION}: needs exactly one "overdue" row, found ${count('overdue')}`);
304
+ if (count('beyond') !== 1) errors.push(`${DEADLINE_TIERS_SECTION}: needs exactly one "more than <time>" row, found ${count('beyond')}`);
305
+ if (count('within') === 0) errors.push(`${DEADLINE_TIERS_SECTION}: needs at least one "within <time>" row`);
306
+ }
307
+
308
+ if (errors.length > 0) return { ok: false, tiers: [...DEFAULT_DEADLINE_TIERS], errors };
309
+
310
+ const within = rows
311
+ .filter((tier) => tier.kind === 'within')
312
+ .sort((a, b) => (a.kind === 'within' && b.kind === 'within' ? a.minutes - b.minutes : 0));
313
+ const beyond = rows.filter((tier) => tier.kind === 'beyond');
314
+ const overdue = rows.filter((tier) => tier.kind === 'overdue');
315
+ return { ok: true, tiers: [...within, ...beyond, ...overdue], errors: [] };
316
+ }
317
+
318
+ /**
319
+ * What a deadline is worth `remainingMs` from now: the factor from the narrowest `within`
320
+ * tier the remaining time fits inside, the overdue factor once the moment has passed, and
321
+ * the `more than` factor beyond every tier.
322
+ *
323
+ * The boundaries are the whole contract, so each falls on one stated side: with exactly one
324
+ * day left a bead earns the `within 1d` factor, and a millisecond more drops it to
325
+ * `within 2d`; a bead whose deadline is exactly now has not slipped yet, so it is not
326
+ * overdue.
327
+ * @param {number} remainingMs
328
+ * @param {DeadlineTier[]} [tiers]
329
+ * @returns {{ factor: number, overdue: boolean }}
330
+ */
331
+ export function deadlineMultiplier(remainingMs, tiers = DEFAULT_DEADLINE_TIERS) {
332
+ /** @type {{ minutes: number, factor: number }[]} */
333
+ const within = [];
334
+ let beyond = 1;
335
+ let overdue = 1;
336
+ for (const tier of tiers) {
337
+ if (tier.kind === 'within') within.push({ minutes: tier.minutes, factor: tier.factor });
338
+ else if (tier.kind === 'beyond') beyond = tier.factor;
339
+ else overdue = tier.factor;
340
+ }
341
+ within.sort((a, b) => a.minutes - b.minutes);
342
+
343
+ if (remainingMs < 0) return { factor: overdue, overdue: true };
344
+ for (const tier of within) {
345
+ if (remainingMs <= tier.minutes * MS_PER_MINUTE) return { factor: tier.factor, overdue: false };
346
+ }
347
+ return { factor: beyond, overdue: false };
348
+ }
349
+
350
+ /**
351
+ * Whether a body carries anything a scorer can read. An HTML comment is guidance, not
352
+ * content, so a template copied and never filled in is empty rather than filled.
353
+ * @param {string[]} body @returns {boolean}
354
+ */
355
+ function hasContent(body) {
356
+ let inComment = false;
357
+ for (const line of body) {
358
+ const trimmed = line.trim();
359
+ if (inComment) {
360
+ if (trimmed.includes('-->')) inComment = false;
361
+ continue;
362
+ }
363
+ if (trimmed.startsWith('<!--')) {
364
+ if (!trimmed.includes('-->')) inComment = true;
365
+ continue;
366
+ }
367
+ if (trimmed !== '') return true;
368
+ }
369
+ return false;
370
+ }
371
+
372
+ /** Today's date in UTC, as the file records it. @returns {string} */
373
+ export function todayIso() {
374
+ return new Date().toISOString().slice(0, 10);
375
+ }
376
+
377
+ /** Collapse a value onto one line, so a change-log entry stays one line. @param {string} value @returns {string} */
378
+ function oneLine(value) {
379
+ return value.replace(/\s+/g, ' ').trim();
380
+ }
381
+
382
+ /**
383
+ * Whether `value` is a calendar date the file can commit to, not merely a date V8 can
384
+ * parse. V8's ISO parser rolls an impossible date into the following month — `2026-02-31`
385
+ * becomes 3 March — so `Date.parse` proves nothing here. Rebuild the date from its parts
386
+ * and compare the round trip instead: a rolled date no longer matches what was written.
387
+ * @param {string} value @returns {boolean}
388
+ */
389
+ function isCalendarDate(value) {
390
+ if (!DATE.test(value)) return false;
391
+ const [year, month, day] = value.split('-').map(Number);
392
+ const date = new Date(Date.UTC(year, month - 1, day));
393
+ return date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day;
394
+ }
395
+
396
+ /**
397
+ * Validate a strategy's shape: a positive-integer version, an ISO date, and every
398
+ * required section present and carrying content. Every defect is reported, so one run
399
+ * fixes the whole file.
400
+ * @param {string} text @returns {StrategyReport}
401
+ */
402
+ export function validateStrategy(text) {
403
+ /** @type {string[]} */
404
+ const errors = [];
405
+ const found = fields(text);
406
+
407
+ const rawVersion = found.get('version');
408
+ /** @type {number | undefined} */
409
+ let version;
410
+ if (rawVersion === undefined) {
411
+ errors.push('missing field: "**Version:**" — the integer this rubric revision carries');
412
+ } else if (!/^\d+$/.test(rawVersion)) {
413
+ errors.push(`version must be an integer, got "${rawVersion}"`);
414
+ } else if (Number(rawVersion) < 1) {
415
+ errors.push(`version must be 1 or more, got "${rawVersion}"`);
416
+ } else {
417
+ version = Number(rawVersion);
418
+ }
419
+
420
+ const updated = found.get('updated');
421
+ if (updated === undefined) {
422
+ errors.push('missing field: "**Updated:**" — the date this rubric last changed');
423
+ } else if (!isCalendarDate(updated)) {
424
+ errors.push(`updated must be a calendar date (YYYY-MM-DD), got "${updated}"`);
425
+ }
426
+
427
+ const lines = text.split('\n');
428
+ const tiers = parseDeadlineTiers(text);
429
+ if (!tiers.ok) errors.push(...tiers.errors);
430
+
431
+ for (const title of REQUIRED_SECTIONS) {
432
+ const body = sectionBody(lines, title);
433
+ if (body === null) errors.push(`missing required section: "## ${title}"`);
434
+ else if (!hasContent(body)) errors.push(`required section is empty: "## ${title}"`);
435
+ }
436
+
437
+ // The change log and the version answer the same question, so they must agree: the
438
+ // newest entry names the version the file carries. A hand-raised version, or a bump
439
+ // whose log line was deleted, leaves the record of *why* the rubric moved missing.
440
+ const changeLog = sectionBody(lines, CHANGE_LOG_SECTION);
441
+ const entries = (changeLog ?? []).map((line) => CHANGE_LOG_ENTRY.exec(line)).filter((match) => match !== null);
442
+ const newest = entries[entries.length - 1];
443
+ if (newest !== null && newest !== undefined && version !== undefined && Number(newest[2]) !== version) {
444
+ errors.push(
445
+ `the newest "## ${CHANGE_LOG_SECTION}" entry names v${newest[2]} but the version is v${version} — ` +
446
+ 'run "bump --reason" to move both together, never one alone',
447
+ );
448
+ }
449
+
450
+ return {
451
+ ok: errors.length === 0,
452
+ errors,
453
+ ...(version === undefined ? {} : { version }),
454
+ ...(updated === undefined ? {} : { updated }),
455
+ };
456
+ }
457
+
458
+ /**
459
+ * Append one line to the change-log section, adding the section when the file has none.
460
+ * The line lands last, because the log reads oldest first, and the section's own guidance
461
+ * comment stays above it.
462
+ * @param {string} text @param {string} line @returns {string}
463
+ */
464
+ function appendChangeLog(text, line) {
465
+ const lines = text.split('\n');
466
+ const start = lines.findIndex((each) => HEADING.exec(each)?.[1].toLowerCase() === CHANGE_LOG_SECTION.toLowerCase());
467
+ if (start === -1) {
468
+ const body = text.endsWith('\n') ? text : `${text}\n`;
469
+ return `${body}\n## ${CHANGE_LOG_SECTION}\n\n${line}\n`;
470
+ }
471
+
472
+ let end = lines.length;
473
+ for (let i = start + 1; i < lines.length; i++) {
474
+ if (HEADING.test(lines[i])) {
475
+ end = i;
476
+ break;
477
+ }
478
+ }
479
+
480
+ const body = lines.slice(start + 1, end);
481
+ while (body.length > 0 && body[body.length - 1].trim() === '') body.pop();
482
+ while (body.length > 0 && body[0].trim() === '') body.shift();
483
+ const tail = lines.slice(end);
484
+ // The log is one contiguous list, so the new line follows the previous one directly — but
485
+ // a guidance comment above it is not a list item, and markdown needs a blank line there.
486
+ const gap = body.length > 0 && body[body.length - 1].trim().endsWith('-->');
487
+ const rebuilt = [
488
+ ...lines.slice(0, start + 1),
489
+ '',
490
+ ...body,
491
+ ...(gap ? [''] : []),
492
+ line,
493
+ ...(tail.length > 0 ? [''] : []),
494
+ ...tail,
495
+ ];
496
+ const joined = rebuilt.join('\n');
497
+ return joined.endsWith('\n') ? joined : `${joined}\n`;
498
+ }
499
+
500
+ /**
501
+ * Raise the version by one, record the date and append the one-line reason, refusing a
502
+ * file that is not valid first: bumping a malformed rubric would only make the
503
+ * malformation look reviewed. A bump with no reason would leave the record of *why* the
504
+ * rubric moved missing, which is the same defect the change log exists to prevent.
505
+ * @param {string} text
506
+ * @param {{ today: string, reason: string }} options
507
+ * @returns {BumpReport}
508
+ */
509
+ export function bumpStrategy(text, { today, reason }) {
510
+ const report = validateStrategy(text);
511
+ if (!report.ok) {
512
+ throw new StrategyError(`refusing to bump an invalid strategy:\n${report.errors.map((e) => ` - ${e}`).join('\n')}`);
513
+ }
514
+ const cleanReason = oneLine(reason ?? '');
515
+ if (cleanReason === '') {
516
+ throw new StrategyError(
517
+ 'refusing to bump without a reason: pass --reason "<why the rubric changed>" so the change log says why',
518
+ );
519
+ }
520
+ const from = /** @type {number} */ (report.version);
521
+ const to = from + 1;
522
+ const line = `- ${today} v${to}: ${cleanReason}`;
523
+ const bumped = setField(setField(text, 'Version', String(to)), 'Updated', today);
524
+ return { from, to, updated: today, reason: cleanReason, line, text: appendChangeLog(bumped, line) };
525
+ }
526
+
527
+ // ─── CLI ──────────────────────────────────────────────────────────────────────
528
+
529
+ /** @type {readonly string[]} */
530
+ export const COMMANDS = /** @type {const} */ (['init', 'validate', 'bump']);
531
+
532
+ const USAGE = `Usage: node strategy.mjs <${COMMANDS.join('|')}> [options]
533
+
534
+ init Write the shipped template to the strategy path. Refuses to overwrite.
535
+ validate Check the shape and print the file to stdout only when it passes.
536
+ bump Raise the version by one, record today's date and append the reason.
537
+
538
+ Options:
539
+ --file <path> Strategy file (default: ${DEFAULT_FILE}, relative to the repository root)
540
+ --today <date> Date to record on init/bump (default: today, UTC; YYYY-MM-DD)
541
+ --reason <text> Why the rubric changed. Required by "bump": it becomes the change-log line.
542
+ -h, --help Show this text
543
+
544
+ The strategy is a committed file in the working-docs root, never a bead. This script
545
+ never writes to the tracker.
546
+ `;
547
+
548
+ /**
549
+ * Parse the CLI's argv.
550
+ * @param {string[]} argv
551
+ * @returns {{ command: string, file: string, today?: string, reason?: string }}
552
+ */
553
+ export function parseArgs(argv) {
554
+ /** @type {string | undefined} */
555
+ let command;
556
+ /** @type {string | undefined} */
557
+ let file;
558
+ /** @type {string | undefined} */
559
+ let today;
560
+ /** @type {string | undefined} */
561
+ let reason;
562
+
563
+ for (let i = 0; i < argv.length; i++) {
564
+ const arg = argv[i];
565
+ if (arg === '--file') {
566
+ file = argv[++i];
567
+ if (file === undefined) throw new UsageError('--file needs a value');
568
+ continue;
569
+ }
570
+ if (arg === '--reason') {
571
+ reason = argv[++i];
572
+ if (reason === undefined) throw new UsageError('--reason needs a value');
573
+ if (reason.trim() === '') throw new UsageError('--reason needs a non-empty value');
574
+ continue;
575
+ }
576
+ if (arg === '--today') {
577
+ today = argv[++i];
578
+ if (today === undefined) throw new UsageError('--today needs a value');
579
+ if (!isCalendarDate(today)) {
580
+ throw new UsageError(`--today must be a calendar date (YYYY-MM-DD), got "${today}"`);
581
+ }
582
+ continue;
583
+ }
584
+ if (arg.startsWith('-')) throw new UsageError(`unexpected argument: ${arg}`);
585
+ if (command !== undefined) throw new UsageError(`unexpected argument: ${arg}`);
586
+ if (!COMMANDS.includes(arg)) throw new UsageError(`unknown command "${arg}"`);
587
+ command = arg;
588
+ }
589
+
590
+ if (command === undefined) throw new UsageError(`a command is required: ${COMMANDS.join(', ')}`);
591
+ if (reason !== undefined && command !== 'bump') throw new UsageError('--reason only applies to "bump"');
592
+ if (command === 'bump' && reason === undefined) {
593
+ throw new UsageError('"bump" needs --reason "<why the rubric changed>" — the change log records it');
594
+ }
595
+ return { command, file: file ?? DEFAULT_FILE, ...(today === undefined ? {} : { today }), ...(reason === undefined ? {} : { reason }) };
596
+ }
597
+
598
+ /**
599
+ * The deepest ancestor of `start`, itself included, that carries a `.git` entry — the
600
+ * repository root the default strategy path is documented against. A worktree or submodule
601
+ * carries `.git` as a file, so existence is the whole test. Walks with `node:fs/promises`
602
+ * and never shells out, because this script spawns nothing. Falls back to `start` when no
603
+ * ancestor is a repository, so a run outside one writes where the caller stands.
604
+ *
605
+ * Exported for the same reason as `resolveStrategyPath`: another skill's default path is
606
+ * documented against the repository root, and two walkers would be two answers.
607
+ * @param {string} start @returns {Promise<string>}
608
+ */
609
+ export async function findRepositoryRoot(start) {
610
+ const from = resolve(start);
611
+ let directory = from;
612
+ for (;;) {
613
+ const found = await access(join(directory, '.git')).then(
614
+ () => true,
615
+ () => false,
616
+ );
617
+ if (found) return directory;
618
+ const parent = dirname(directory);
619
+ if (parent === directory) return from;
620
+ directory = parent;
621
+ }
622
+ }
623
+
624
+ /**
625
+ * The absolute path of the strategy file for one run. The default path resolves against
626
+ * the repository root, so a caller run from any subdirectory reads the one strategy the
627
+ * repository owns; an explicit `--file` is taken as given, relative to the cwd or
628
+ * absolute. Exported so another skill's script reads the strategy through the same rule
629
+ * rather than re-deriving it — one strategy, one location, one resolver.
630
+ * @param {object} [options]
631
+ * @param {string} [options.file] Path as the caller supplied it.
632
+ * @param {string} [options.cwd] Directory a relative path resolves against.
633
+ * @returns {Promise<string>}
634
+ */
635
+ export async function resolveStrategyPath({ file = DEFAULT_FILE, cwd = process.cwd() } = {}) {
636
+ const base = file === DEFAULT_FILE ? await findRepositoryRoot(cwd) : cwd;
637
+ return resolve(base, file);
638
+ }
639
+
640
+ /**
641
+ * Build the report text for one failed run.
642
+ * @param {string} file @param {string[]} errors @returns {string}
643
+ */
644
+ function formatErrors(file, errors) {
645
+ return `${file} is not a valid strategy:\n${errors.map((e) => ` - ${e}`).join('\n')}\n`;
646
+ }
647
+
648
+ /**
649
+ * @param {object} options
650
+ * @param {string[]} options.argv CLI arguments (without node/script).
651
+ * @param {(chunk: string) => void} [options.write] stdout sink; injectable for tests.
652
+ * @param {(chunk: string) => void} [options.writeError] stderr sink; injectable for tests.
653
+ * @param {string} [options.cwd] Directory to resolve from: the default
654
+ * path's search start, and the base for
655
+ * a relative --file.
656
+ * @param {string} [options.today] Date to record; injectable for tests.
657
+ * @returns {Promise<number>} process exit code.
658
+ */
659
+ export async function runCli({
660
+ argv,
661
+ write = (chunk) => process.stdout.write(chunk),
662
+ writeError = (chunk) => process.stderr.write(chunk),
663
+ cwd = process.cwd(),
664
+ today,
665
+ }) {
666
+ if (argv.includes('--help') || argv.includes('-h')) {
667
+ write(USAGE);
668
+ return 0;
669
+ }
670
+
671
+ /** @type {{ command: string, file: string, today?: string, reason?: string }} */
672
+ let options;
673
+ try {
674
+ options = parseArgs(argv);
675
+ } catch (error) {
676
+ writeError(`strategy: ${error instanceof Error ? error.message : String(error)}\n\n${USAGE}`);
677
+ return 2;
678
+ }
679
+
680
+ // The default path is documented as relative to the repository root, so `init` run from a
681
+ // subdirectory writes the one strategy the root owns instead of a second one. Any other
682
+ // `--file` — and every absolute path — is taken as given, relative to the cwd.
683
+ const file = await resolveStrategyPath({ file: options.file, cwd });
684
+ const date = today ?? options.today ?? todayIso();
685
+
686
+ try {
687
+ if (options.command === 'init') {
688
+ let template;
689
+ try {
690
+ template = await readFile(TEMPLATE_FILE, 'utf8');
691
+ } catch {
692
+ throw new StrategyError(`the shipped template is missing at ${TEMPLATE_FILE}`);
693
+ }
694
+ const existing = await readFile(file, 'utf8').then(
695
+ () => true,
696
+ () => false,
697
+ );
698
+ if (existing) {
699
+ throw new StrategyError(
700
+ `${file} already exists — run "validate" to check it, or "bump" to record an update`,
701
+ );
702
+ }
703
+ await mkdir(dirname(file), { recursive: true });
704
+ await writeFile(file, setField(template, 'Updated', date), 'utf8');
705
+ writeError(`✓ wrote ${file} — answer the five questions, then run "validate"\n`);
706
+ return 0;
707
+ }
708
+
709
+ /** @type {string} */
710
+ let text;
711
+ try {
712
+ text = await readFile(file, 'utf8');
713
+ } catch {
714
+ throw new StrategyError(
715
+ `${file} does not exist — run "init" and interview the owner, or pass --file <path>`,
716
+ );
717
+ }
718
+
719
+ if (options.command === 'bump') {
720
+ const bumped = bumpStrategy(text, { today: date, reason: options.reason ?? '' });
721
+ const check = validateStrategy(bumped.text);
722
+ if (!check.ok) throw new StrategyError(`the bump produced an invalid file — ${check.errors.join('; ')}`);
723
+ await writeFile(file, bumped.text, 'utf8');
724
+ writeError(`✓ ${file}: version ${bumped.from} → ${bumped.to}, updated ${bumped.updated}\n change log: ${bumped.line}\n`);
725
+ return 0;
726
+ }
727
+
728
+ const report = validateStrategy(text);
729
+ if (!report.ok) {
730
+ writeError(formatErrors(file, report.errors));
731
+ return 1;
732
+ }
733
+ writeError(
734
+ `✓ ${file}: version ${report.version}, updated ${report.updated}, ` +
735
+ `${REQUIRED_SECTIONS.length} required sections present\n`,
736
+ );
737
+ write(text.endsWith('\n') ? text : `${text}\n`);
738
+ return 0;
739
+ } catch (error) {
740
+ const kind = error instanceof UsageError ? 'usage' : 'failed';
741
+ writeError(`strategy ${kind}: ${error instanceof Error ? error.message : String(error)}\n`);
742
+ return error instanceof UsageError ? 2 : 1;
743
+ }
744
+ }
745
+
746
+ // Only run the CLI when invoked directly, so tests can import the module.
747
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
748
+ runCli({ argv: process.argv.slice(2) })
749
+ .then((code) => {
750
+ process.exitCode = code;
751
+ })
752
+ .catch((error) => {
753
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
754
+ process.exitCode = 1;
755
+ });
756
+ }