@jaybeeuu/agent-cortex 2.7.0 → 2.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/CHANGELOG.md +412 -0
  2. package/README.md +87 -8
  3. package/agents/README.md +4 -4
  4. package/agents/guppi/agent.md +162 -0
  5. package/agents/guppi/claude/frontmatter.json +6 -0
  6. package/agents/guppi/pi/frontmatter.json +6 -0
  7. package/agents/plan/agent.md +6 -0
  8. package/agents/ralph/agent.md +17 -16
  9. package/agents/ralph/claude/wake.md +4 -4
  10. package/agents/ralph/pi/wake.md +15 -20
  11. package/agents/ralph-plan/agent.md +6 -0
  12. package/bin/agent-cortex.mjs +20 -1
  13. package/bin/installers/claude.mjs +1 -20
  14. package/bin/installers/pi.mjs +8 -2
  15. package/extensions/interrupt-bookmark/README.md +129 -0
  16. package/extensions/interrupt-bookmark/index.test.ts +717 -0
  17. package/extensions/interrupt-bookmark/index.ts +354 -0
  18. package/extensions/interrupt-bookmark/package.json +20 -0
  19. package/extensions/post-merge-watch/README.md +133 -0
  20. package/extensions/post-merge-watch/index.test.ts +247 -0
  21. package/extensions/post-merge-watch/index.ts +123 -0
  22. package/extensions/post-merge-watch/package.json +16 -0
  23. package/extensions/post-merge-watch/watch.test.ts +469 -0
  24. package/extensions/post-merge-watch/watch.ts +431 -0
  25. package/extensions/session-clear/README.md +74 -0
  26. package/extensions/session-clear/index.test.ts +524 -0
  27. package/extensions/session-clear/index.ts +159 -0
  28. package/extensions/session-clear/package.json +16 -0
  29. package/extensions/skill-stats/README.md +84 -3
  30. package/extensions/skill-stats/index.ts +62 -3
  31. package/extensions/skill-stats/package.json +1 -1
  32. package/extensions/skill-stats/rollup.test.ts +643 -0
  33. package/extensions/skill-stats/rollup.ts +415 -0
  34. package/extensions/skill-stats/timeline.ts +101 -0
  35. package/lib/cli.mjs +42 -1
  36. package/lib/self-update.mjs +553 -0
  37. package/package.json +1 -1
  38. package/scripts/lib/manifest.mjs +29 -0
  39. package/skills/engineering/implement-task/SKILL.md +124 -0
  40. package/skills/engineering/tdd/SKILL.md +2 -0
  41. package/skills/planning/create-task/REFERENCE.md +15 -6
  42. package/skills/planning/create-task/SKILL.md +1 -1
  43. package/skills/planning/create-task/pipeline.json +8 -0
  44. package/skills/planning/create-task/scripts/create-chores.mjs +27 -18
  45. package/skills/planning/create-task/templates/code.md +8 -3
  46. package/skills/planning/create-task/templates/document.md +8 -3
  47. package/skills/planning/create-task/templates/fix.md +8 -3
  48. package/skills/planning/create-task/templates/housekeeping.md +8 -4
  49. package/skills/planning/create-task/templates/post-merge.md +67 -0
  50. package/skills/planning/create-task/templates/review.md +8 -3
  51. package/skills/planning/create-task/templates/verify.md +8 -3
  52. package/skills/productivity/bd-tool/SKILL.md +55 -0
  53. package/skills/review/review-defects/EXAMPLES.md +198 -0
  54. package/skills/review/review-defects/SKILL.md +121 -0
  55. package/skills/review/review-report/EXAMPLES.md +114 -0
  56. package/skills/review/review-report/SKILL.md +138 -0
  57. package/skills/review/review-report/scripts/report.mjs +1032 -0
  58. package/skills/style/style-tests/EXAMPLES.md +30 -0
  59. package/skills/style/style-tests/SKILL.md +10 -6
  60. package/skills/workflow/hitl-queue/SKILL.md +134 -0
  61. package/skills/workflow/hitl-queue/scripts/queue.mjs +626 -0
  62. package/skills/workflow/plan/SKILL.md +27 -3
  63. package/skills/workflow/prioritise-work/CHALLENGE.md +139 -0
  64. package/skills/workflow/prioritise-work/SKILL.md +150 -0
  65. package/skills/workflow/prioritise-work/scripts/challenge.mjs +505 -0
  66. package/skills/workflow/prioritise-work/scripts/strategy.mjs +566 -0
  67. package/skills/workflow/prioritise-work/templates/strategy.md +61 -0
  68. package/skills/workflow/ralph/REFERENCE.md +124 -27
  69. package/skills/workflow/ralph/SKILL.md +24 -15
  70. package/skills/workflow/ralph/scripts/post-merge-check.mjs +651 -0
  71. package/skills/workflow/run-pipeline-stage/SKILL.md +37 -22
  72. package/skills/workflow/run-pipeline-stage/playbooks/code.md +14 -8
  73. package/skills/workflow/run-pipeline-stage/playbooks/document.md +2 -2
  74. package/skills/workflow/run-pipeline-stage/playbooks/fix.md +2 -2
  75. package/skills/workflow/run-pipeline-stage/playbooks/housekeeping.md +1 -1
  76. package/skills/workflow/run-pipeline-stage/playbooks/post-merge.md +54 -0
  77. package/skills/workflow/run-pipeline-stage/playbooks/review.md +37 -3
  78. package/skills/workflow/run-pipeline-stage/playbooks/verify.md +2 -2
  79. package/skills/workflow/run-pipeline-stage/prompts/stage-runner.md +55 -11
  80. package/skills/workflow/run-pipeline-stage/scripts/recover-brief.mjs +805 -0
  81. package/skills/workflow/run-pipeline-stage/scripts/trail-entry.mjs +702 -0
  82. package/skills/workflow/score-bead/FORMAT.md +73 -0
  83. package/skills/workflow/score-bead/SKILL.md +112 -0
  84. package/skills/workflow/score-bead/scripts/rice.mjs +664 -0
  85. package/skills/workflow/using-agent-skills/SKILL.md +21 -8
  86. package/token-map.README.md +27 -0
  87. package/token-map.json +16 -4
@@ -0,0 +1,566 @@
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
+ /** The shape of a date: four digits, two, two. Whether it exists is a separate question. */
68
+ const DATE = /^\d{4}-\d{2}-\d{2}$/;
69
+
70
+ /** A markdown heading, with its title captured. */
71
+ const HEADING = /^\s*##\s+(.*?)\s*$/;
72
+
73
+ /** The version and the date are stored as bold key/value lines, so a reader can grep them. */
74
+ const FIELD = /^[ \t]*[-*][ \t]*\*\*([A-Za-z]+):\*\*[ \t]*(\S.*?)[ \t]*$/;
75
+
76
+ /** The markup in front of a field's value, so a new value replaces the old one whole. */
77
+ const FIELD_PREFIX = /^([ \t]*[-*][ \t]*\*\*[A-Za-z]+:\*\*[ \t]*)\S.*?[ \t]*$/;
78
+
79
+ /** One change-log line: the date it landed, the version it produced, and the reason. */
80
+ const CHANGE_LOG_ENTRY = /^[ \t]*[-*][ \t]+(\d{4}-\d{2}-\d{2})[ \t]+v(\d+):[ \t]*(.+?)[ \t]*$/;
81
+
82
+ /** A usage mistake: the caller asked for something malformed. Exit code 2. */
83
+ export class UsageError extends Error {}
84
+
85
+ /** The strategy file is absent, or its shape is wrong. Exit code 1. */
86
+ export class StrategyError extends Error {}
87
+
88
+ // ─── Types ────────────────────────────────────────────────────────────────────
89
+
90
+ /**
91
+ * What `validate` found.
92
+ * @typedef {object} StrategyReport
93
+ * @property {boolean} ok
94
+ * @property {number} [version] Present only when the version parses.
95
+ * @property {string} [updated] Present only when the date parses.
96
+ * @property {string[]} errors One line per defect, each naming the field or section.
97
+ */
98
+
99
+ /**
100
+ * What `bump` produced.
101
+ * @typedef {object} BumpReport
102
+ * @property {number} from
103
+ * @property {number} to
104
+ * @property {string} updated
105
+ * @property {string} reason
106
+ * @property {string} line The change-log line the bump appended.
107
+ * @property {string} text
108
+ */
109
+
110
+ // ─── Value helpers ────────────────────────────────────────────────────────────
111
+
112
+ /**
113
+ * Every `**Key:** value` line in the text, keyed by the lower-cased key.
114
+ * @param {string} text @returns {Map<string, string>}
115
+ */
116
+ function fields(text) {
117
+ /** @type {Map<string, string>} */
118
+ const found = new Map();
119
+ for (const line of text.split('\n')) {
120
+ const match = FIELD.exec(line);
121
+ if (match === null) continue;
122
+ const key = match[1].toLowerCase();
123
+ if (!found.has(key)) found.set(key, match[2]);
124
+ }
125
+ return found;
126
+ }
127
+
128
+ /**
129
+ * Replace the value of a `**Key:** value` line, keeping its markup and indentation.
130
+ * Throws when the line is absent, because a bump that cannot date the file has nothing
131
+ * honest to write.
132
+ * @param {string} text @param {string} key @param {string} value @returns {string}
133
+ */
134
+ function setField(text, key, value) {
135
+ const wanted = key.toLowerCase();
136
+ let replaced = false;
137
+ return text
138
+ .split('\n')
139
+ .map((line) => {
140
+ const match = FIELD.exec(line);
141
+ if (replaced || match === null || match[1].toLowerCase() !== wanted) return line;
142
+ replaced = true;
143
+ return line.replace(FIELD_PREFIX, (_match, prefix) => `${prefix}${value}`);
144
+ })
145
+ .join('\n');
146
+ }
147
+
148
+ /**
149
+ * The body lines of `## <title>`, or null when the heading is absent. The body stops at
150
+ * the next `##` heading; a `###` sub-heading stays body.
151
+ * @param {string[]} lines @param {string} title @returns {string[] | null}
152
+ */
153
+ function sectionBody(lines, title) {
154
+ const wanted = title.toLowerCase();
155
+ const start = lines.findIndex((line) => HEADING.exec(line)?.[1].toLowerCase() === wanted);
156
+ if (start === -1) return null;
157
+
158
+ const body = [];
159
+ for (const line of lines.slice(start + 1)) {
160
+ if (HEADING.test(line)) break;
161
+ body.push(line);
162
+ }
163
+ return body;
164
+ }
165
+
166
+ /**
167
+ * Whether a body carries anything a scorer can read. An HTML comment is guidance, not
168
+ * content, so a template copied and never filled in is empty rather than filled.
169
+ * @param {string[]} body @returns {boolean}
170
+ */
171
+ function hasContent(body) {
172
+ let inComment = false;
173
+ for (const line of body) {
174
+ const trimmed = line.trim();
175
+ if (inComment) {
176
+ if (trimmed.includes('-->')) inComment = false;
177
+ continue;
178
+ }
179
+ if (trimmed.startsWith('<!--')) {
180
+ if (!trimmed.includes('-->')) inComment = true;
181
+ continue;
182
+ }
183
+ if (trimmed !== '') return true;
184
+ }
185
+ return false;
186
+ }
187
+
188
+ /** Today's date in UTC, as the file records it. @returns {string} */
189
+ export function todayIso() {
190
+ return new Date().toISOString().slice(0, 10);
191
+ }
192
+
193
+ /** Collapse a value onto one line, so a change-log entry stays one line. @param {string} value @returns {string} */
194
+ function oneLine(value) {
195
+ return value.replace(/\s+/g, ' ').trim();
196
+ }
197
+
198
+ /**
199
+ * Whether `value` is a calendar date the file can commit to, not merely a date V8 can
200
+ * parse. V8's ISO parser rolls an impossible date into the following month — `2026-02-31`
201
+ * becomes 3 March — so `Date.parse` proves nothing here. Rebuild the date from its parts
202
+ * and compare the round trip instead: a rolled date no longer matches what was written.
203
+ * @param {string} value @returns {boolean}
204
+ */
205
+ function isCalendarDate(value) {
206
+ if (!DATE.test(value)) return false;
207
+ const [year, month, day] = value.split('-').map(Number);
208
+ const date = new Date(Date.UTC(year, month - 1, day));
209
+ return date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day;
210
+ }
211
+
212
+ /**
213
+ * Validate a strategy's shape: a positive-integer version, an ISO date, and every
214
+ * required section present and carrying content. Every defect is reported, so one run
215
+ * fixes the whole file.
216
+ * @param {string} text @returns {StrategyReport}
217
+ */
218
+ export function validateStrategy(text) {
219
+ /** @type {string[]} */
220
+ const errors = [];
221
+ const found = fields(text);
222
+
223
+ const rawVersion = found.get('version');
224
+ /** @type {number | undefined} */
225
+ let version;
226
+ if (rawVersion === undefined) {
227
+ errors.push('missing field: "**Version:**" — the integer this rubric revision carries');
228
+ } else if (!/^\d+$/.test(rawVersion)) {
229
+ errors.push(`version must be an integer, got "${rawVersion}"`);
230
+ } else if (Number(rawVersion) < 1) {
231
+ errors.push(`version must be 1 or more, got "${rawVersion}"`);
232
+ } else {
233
+ version = Number(rawVersion);
234
+ }
235
+
236
+ const updated = found.get('updated');
237
+ if (updated === undefined) {
238
+ errors.push('missing field: "**Updated:**" — the date this rubric last changed');
239
+ } else if (!isCalendarDate(updated)) {
240
+ errors.push(`updated must be a calendar date (YYYY-MM-DD), got "${updated}"`);
241
+ }
242
+
243
+ const lines = text.split('\n');
244
+ for (const title of REQUIRED_SECTIONS) {
245
+ const body = sectionBody(lines, title);
246
+ if (body === null) errors.push(`missing required section: "## ${title}"`);
247
+ else if (!hasContent(body)) errors.push(`required section is empty: "## ${title}"`);
248
+ }
249
+
250
+ // The change log and the version answer the same question, so they must agree: the
251
+ // newest entry names the version the file carries. A hand-raised version, or a bump
252
+ // whose log line was deleted, leaves the record of *why* the rubric moved missing.
253
+ const changeLog = sectionBody(lines, CHANGE_LOG_SECTION);
254
+ const entries = (changeLog ?? []).map((line) => CHANGE_LOG_ENTRY.exec(line)).filter((match) => match !== null);
255
+ const newest = entries[entries.length - 1];
256
+ if (newest !== null && newest !== undefined && version !== undefined && Number(newest[2]) !== version) {
257
+ errors.push(
258
+ `the newest "## ${CHANGE_LOG_SECTION}" entry names v${newest[2]} but the version is v${version} — ` +
259
+ 'run "bump --reason" to move both together, never one alone',
260
+ );
261
+ }
262
+
263
+ return {
264
+ ok: errors.length === 0,
265
+ errors,
266
+ ...(version === undefined ? {} : { version }),
267
+ ...(updated === undefined ? {} : { updated }),
268
+ };
269
+ }
270
+
271
+ /**
272
+ * Append one line to the change-log section, adding the section when the file has none.
273
+ * The line lands last, because the log reads oldest first, and the section's own guidance
274
+ * comment stays above it.
275
+ * @param {string} text @param {string} line @returns {string}
276
+ */
277
+ function appendChangeLog(text, line) {
278
+ const lines = text.split('\n');
279
+ const start = lines.findIndex((each) => HEADING.exec(each)?.[1].toLowerCase() === CHANGE_LOG_SECTION.toLowerCase());
280
+ if (start === -1) {
281
+ const body = text.endsWith('\n') ? text : `${text}\n`;
282
+ return `${body}\n## ${CHANGE_LOG_SECTION}\n\n${line}\n`;
283
+ }
284
+
285
+ let end = lines.length;
286
+ for (let i = start + 1; i < lines.length; i++) {
287
+ if (HEADING.test(lines[i])) {
288
+ end = i;
289
+ break;
290
+ }
291
+ }
292
+
293
+ const body = lines.slice(start + 1, end);
294
+ while (body.length > 0 && body[body.length - 1].trim() === '') body.pop();
295
+ while (body.length > 0 && body[0].trim() === '') body.shift();
296
+ const tail = lines.slice(end);
297
+ // The log is one contiguous list, so the new line follows the previous one directly — but
298
+ // a guidance comment above it is not a list item, and markdown needs a blank line there.
299
+ const gap = body.length > 0 && body[body.length - 1].trim().endsWith('-->');
300
+ const rebuilt = [
301
+ ...lines.slice(0, start + 1),
302
+ '',
303
+ ...body,
304
+ ...(gap ? [''] : []),
305
+ line,
306
+ ...(tail.length > 0 ? [''] : []),
307
+ ...tail,
308
+ ];
309
+ const joined = rebuilt.join('\n');
310
+ return joined.endsWith('\n') ? joined : `${joined}\n`;
311
+ }
312
+
313
+ /**
314
+ * Raise the version by one, record the date and append the one-line reason, refusing a
315
+ * file that is not valid first: bumping a malformed rubric would only make the
316
+ * malformation look reviewed. A bump with no reason would leave the record of *why* the
317
+ * rubric moved missing, which is the same defect the change log exists to prevent.
318
+ * @param {string} text
319
+ * @param {{ today: string, reason: string }} options
320
+ * @returns {BumpReport}
321
+ */
322
+ export function bumpStrategy(text, { today, reason }) {
323
+ const report = validateStrategy(text);
324
+ if (!report.ok) {
325
+ throw new StrategyError(`refusing to bump an invalid strategy:\n${report.errors.map((e) => ` - ${e}`).join('\n')}`);
326
+ }
327
+ const cleanReason = oneLine(reason ?? '');
328
+ if (cleanReason === '') {
329
+ throw new StrategyError(
330
+ 'refusing to bump without a reason: pass --reason "<why the rubric changed>" so the change log says why',
331
+ );
332
+ }
333
+ const from = /** @type {number} */ (report.version);
334
+ const to = from + 1;
335
+ const line = `- ${today} v${to}: ${cleanReason}`;
336
+ const bumped = setField(setField(text, 'Version', String(to)), 'Updated', today);
337
+ return { from, to, updated: today, reason: cleanReason, line, text: appendChangeLog(bumped, line) };
338
+ }
339
+
340
+ // ─── CLI ──────────────────────────────────────────────────────────────────────
341
+
342
+ /** @type {readonly string[]} */
343
+ export const COMMANDS = /** @type {const} */ (['init', 'validate', 'bump']);
344
+
345
+ const USAGE = `Usage: node strategy.mjs <${COMMANDS.join('|')}> [options]
346
+
347
+ init Write the shipped template to the strategy path. Refuses to overwrite.
348
+ validate Check the shape and print the file to stdout only when it passes.
349
+ bump Raise the version by one, record today's date and append the reason.
350
+
351
+ Options:
352
+ --file <path> Strategy file (default: ${DEFAULT_FILE}, relative to the repository root)
353
+ --today <date> Date to record on init/bump (default: today, UTC; YYYY-MM-DD)
354
+ --reason <text> Why the rubric changed. Required by "bump": it becomes the change-log line.
355
+ -h, --help Show this text
356
+
357
+ The strategy is a committed file in the working-docs root, never a bead. This script
358
+ never writes to the tracker.
359
+ `;
360
+
361
+ /**
362
+ * Parse the CLI's argv.
363
+ * @param {string[]} argv
364
+ * @returns {{ command: string, file: string, today?: string, reason?: string }}
365
+ */
366
+ export function parseArgs(argv) {
367
+ /** @type {string | undefined} */
368
+ let command;
369
+ /** @type {string | undefined} */
370
+ let file;
371
+ /** @type {string | undefined} */
372
+ let today;
373
+ /** @type {string | undefined} */
374
+ let reason;
375
+
376
+ for (let i = 0; i < argv.length; i++) {
377
+ const arg = argv[i];
378
+ if (arg === '--file') {
379
+ file = argv[++i];
380
+ if (file === undefined) throw new UsageError('--file needs a value');
381
+ continue;
382
+ }
383
+ if (arg === '--reason') {
384
+ reason = argv[++i];
385
+ if (reason === undefined) throw new UsageError('--reason needs a value');
386
+ if (reason.trim() === '') throw new UsageError('--reason needs a non-empty value');
387
+ continue;
388
+ }
389
+ if (arg === '--today') {
390
+ today = argv[++i];
391
+ if (today === undefined) throw new UsageError('--today needs a value');
392
+ if (!isCalendarDate(today)) {
393
+ throw new UsageError(`--today must be a calendar date (YYYY-MM-DD), got "${today}"`);
394
+ }
395
+ continue;
396
+ }
397
+ if (arg.startsWith('-')) throw new UsageError(`unexpected argument: ${arg}`);
398
+ if (command !== undefined) throw new UsageError(`unexpected argument: ${arg}`);
399
+ if (!COMMANDS.includes(arg)) throw new UsageError(`unknown command "${arg}"`);
400
+ command = arg;
401
+ }
402
+
403
+ if (command === undefined) throw new UsageError(`a command is required: ${COMMANDS.join(', ')}`);
404
+ if (reason !== undefined && command !== 'bump') throw new UsageError('--reason only applies to "bump"');
405
+ if (command === 'bump' && reason === undefined) {
406
+ throw new UsageError('"bump" needs --reason "<why the rubric changed>" — the change log records it');
407
+ }
408
+ return { command, file: file ?? DEFAULT_FILE, ...(today === undefined ? {} : { today }), ...(reason === undefined ? {} : { reason }) };
409
+ }
410
+
411
+ /**
412
+ * The deepest ancestor of `start`, itself included, that carries a `.git` entry — the
413
+ * repository root the default strategy path is documented against. A worktree or submodule
414
+ * carries `.git` as a file, so existence is the whole test. Walks with `node:fs/promises`
415
+ * and never shells out, because this script spawns nothing. Falls back to `start` when no
416
+ * ancestor is a repository, so a run outside one writes where the caller stands.
417
+ * @param {string} start @returns {Promise<string>}
418
+ */
419
+ async function findRepositoryRoot(start) {
420
+ const from = resolve(start);
421
+ let directory = from;
422
+ for (;;) {
423
+ const found = await access(join(directory, '.git')).then(
424
+ () => true,
425
+ () => false,
426
+ );
427
+ if (found) return directory;
428
+ const parent = dirname(directory);
429
+ if (parent === directory) return from;
430
+ directory = parent;
431
+ }
432
+ }
433
+
434
+ /**
435
+ * The absolute path of the strategy file for one run. The default path resolves against
436
+ * the repository root, so a caller run from any subdirectory reads the one strategy the
437
+ * repository owns; an explicit `--file` is taken as given, relative to the cwd or
438
+ * absolute. Exported so another skill's script reads the strategy through the same rule
439
+ * rather than re-deriving it — one strategy, one location, one resolver.
440
+ * @param {object} [options]
441
+ * @param {string} [options.file] Path as the caller supplied it.
442
+ * @param {string} [options.cwd] Directory a relative path resolves against.
443
+ * @returns {Promise<string>}
444
+ */
445
+ export async function resolveStrategyPath({ file = DEFAULT_FILE, cwd = process.cwd() } = {}) {
446
+ const base = file === DEFAULT_FILE ? await findRepositoryRoot(cwd) : cwd;
447
+ return resolve(base, file);
448
+ }
449
+
450
+ /**
451
+ * Build the report text for one failed run.
452
+ * @param {string} file @param {string[]} errors @returns {string}
453
+ */
454
+ function formatErrors(file, errors) {
455
+ return `${file} is not a valid strategy:\n${errors.map((e) => ` - ${e}`).join('\n')}\n`;
456
+ }
457
+
458
+ /**
459
+ * @param {object} options
460
+ * @param {string[]} options.argv CLI arguments (without node/script).
461
+ * @param {(chunk: string) => void} [options.write] stdout sink; injectable for tests.
462
+ * @param {(chunk: string) => void} [options.writeError] stderr sink; injectable for tests.
463
+ * @param {string} [options.cwd] Directory to resolve from: the default
464
+ * path's search start, and the base for
465
+ * a relative --file.
466
+ * @param {string} [options.today] Date to record; injectable for tests.
467
+ * @returns {Promise<number>} process exit code.
468
+ */
469
+ export async function runCli({
470
+ argv,
471
+ write = (chunk) => process.stdout.write(chunk),
472
+ writeError = (chunk) => process.stderr.write(chunk),
473
+ cwd = process.cwd(),
474
+ today,
475
+ }) {
476
+ if (argv.includes('--help') || argv.includes('-h')) {
477
+ write(USAGE);
478
+ return 0;
479
+ }
480
+
481
+ /** @type {{ command: string, file: string, today?: string, reason?: string }} */
482
+ let options;
483
+ try {
484
+ options = parseArgs(argv);
485
+ } catch (error) {
486
+ writeError(`strategy: ${error instanceof Error ? error.message : String(error)}\n\n${USAGE}`);
487
+ return 2;
488
+ }
489
+
490
+ // The default path is documented as relative to the repository root, so `init` run from a
491
+ // subdirectory writes the one strategy the root owns instead of a second one. Any other
492
+ // `--file` — and every absolute path — is taken as given, relative to the cwd.
493
+ const file = await resolveStrategyPath({ file: options.file, cwd });
494
+ const date = today ?? options.today ?? todayIso();
495
+
496
+ try {
497
+ if (options.command === 'init') {
498
+ let template;
499
+ try {
500
+ template = await readFile(TEMPLATE_FILE, 'utf8');
501
+ } catch {
502
+ throw new StrategyError(`the shipped template is missing at ${TEMPLATE_FILE}`);
503
+ }
504
+ const existing = await readFile(file, 'utf8').then(
505
+ () => true,
506
+ () => false,
507
+ );
508
+ if (existing) {
509
+ throw new StrategyError(
510
+ `${file} already exists — run "validate" to check it, or "bump" to record an update`,
511
+ );
512
+ }
513
+ await mkdir(dirname(file), { recursive: true });
514
+ await writeFile(file, setField(template, 'Updated', date), 'utf8');
515
+ writeError(`✓ wrote ${file} — answer the five questions, then run "validate"\n`);
516
+ return 0;
517
+ }
518
+
519
+ /** @type {string} */
520
+ let text;
521
+ try {
522
+ text = await readFile(file, 'utf8');
523
+ } catch {
524
+ throw new StrategyError(
525
+ `${file} does not exist — run "init" and interview the owner, or pass --file <path>`,
526
+ );
527
+ }
528
+
529
+ if (options.command === 'bump') {
530
+ const bumped = bumpStrategy(text, { today: date, reason: options.reason ?? '' });
531
+ const check = validateStrategy(bumped.text);
532
+ if (!check.ok) throw new StrategyError(`the bump produced an invalid file — ${check.errors.join('; ')}`);
533
+ await writeFile(file, bumped.text, 'utf8');
534
+ writeError(`✓ ${file}: version ${bumped.from} → ${bumped.to}, updated ${bumped.updated}\n change log: ${bumped.line}\n`);
535
+ return 0;
536
+ }
537
+
538
+ const report = validateStrategy(text);
539
+ if (!report.ok) {
540
+ writeError(formatErrors(file, report.errors));
541
+ return 1;
542
+ }
543
+ writeError(
544
+ `✓ ${file}: version ${report.version}, updated ${report.updated}, ` +
545
+ `${REQUIRED_SECTIONS.length} required sections present\n`,
546
+ );
547
+ write(text.endsWith('\n') ? text : `${text}\n`);
548
+ return 0;
549
+ } catch (error) {
550
+ const kind = error instanceof UsageError ? 'usage' : 'failed';
551
+ writeError(`strategy ${kind}: ${error instanceof Error ? error.message : String(error)}\n`);
552
+ return error instanceof UsageError ? 2 : 1;
553
+ }
554
+ }
555
+
556
+ // Only run the CLI when invoked directly, so tests can import the module.
557
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
558
+ runCli({ argv: process.argv.slice(2) })
559
+ .then((code) => {
560
+ process.exitCode = code;
561
+ })
562
+ .catch((error) => {
563
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
564
+ process.exitCode = 1;
565
+ });
566
+ }
@@ -0,0 +1,61 @@
1
+ # Prioritisation strategy
2
+
3
+ <!--
4
+ This file is the rubric a scorer reads before it judges a bead's reach, impact,
5
+ confidence and effort. Keep it short enough that you actually maintain it.
6
+
7
+ It lives in the repository as a committed file, never in the tracker, so a reviewer can
8
+ diff how the judgement changed. Every `##` section below is required: leave one empty and
9
+ the validator names it. Set the version and the date with
10
+ `node <skill-scripts>/strategy.mjs bump` after you edit the content.
11
+ -->
12
+
13
+ - **Version:** 1
14
+ - **Updated:** YYYY-MM-DD
15
+
16
+ ## Current themes
17
+
18
+ <!--
19
+ What you are optimising this month, as two to four themes. Name the outcome rather than
20
+ the feature: "shorter review turnaround", not "review tooling".
21
+ -->
22
+
23
+ ## What counts as value
24
+
25
+ <!--
26
+ What a bead must change to be worth doing now. One line per value, written so a scorer
27
+ can answer yes or no: "unblocks a human gate", "removes a manual step".
28
+ -->
29
+
30
+ ## How to judge confidence
31
+
32
+ <!--
33
+ When to score confidence low: no evidence in the repository, unclear scope, no known
34
+ user, an unproven dependency. Give the figure for each case, for example "0.3 with no
35
+ reproduction, 0.8 with a failing test".
36
+ -->
37
+
38
+ ## Explicitly out of scope
39
+
40
+ <!--
41
+ Work you are deliberately not doing this month, however good it looks. A scorer reads
42
+ this to stop a shiny bead from outranking the theme.
43
+ -->
44
+
45
+ ## How to calibrate effort
46
+
47
+ <!--
48
+ Anchor the effort scale to work you have recently completed here, so two scorers agree.
49
+ For example: 1 = half a day, one file; 3 = two to three days, one module with its tests;
50
+ 5 = a week or more, several modules or a migration.
51
+ -->
52
+
53
+ ## Change log
54
+
55
+ <!--
56
+ One line per change to this rubric, oldest first, written by
57
+ `node <skill-scripts>/strategy.mjs bump --reason "<why>"`. The newest line must name the
58
+ version above: `validate` fails when the two disagree, because a version that moved with
59
+ no recorded reason is a rubric change nobody can review.
60
+ -->
61
+