@jaybeeuu/agent-cortex 2.9.0 → 2.11.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.
@@ -6,11 +6,12 @@
6
6
  // and never a scratch file: operational state belongs in the bead trail, and durable
7
7
  // documents that are reviewed, diffed and versioned belong in git.
8
8
  //
9
- // Three subcommands, and nothing else:
9
+ // Four subcommands, and nothing else:
10
10
  //
11
11
  // node strategy.mjs init [--file <path>] write the template
12
12
  // node strategy.mjs validate [--file <path>] check the shape, then print it
13
13
  // node strategy.mjs bump [--file <path>] [--today d] raise the version, set the date
14
+ // node strategy.mjs summary [--file <path>] the digest a run opens with
14
15
  //
15
16
  // `validate` prints the rubric to stdout only after it passes, so a scorer can never
16
17
  // read a stale or malformed strategy: the file and the proof of its shape arrive
@@ -22,8 +23,14 @@
22
23
  // Zero runtime dependencies, async fs only. `skills/*/scripts` sits outside the
23
24
  // check-no-sync scan, and this script stays async anyway so the rule holds everywhere.
24
25
  //
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.
26
+ // `summary` is what the skill prints before it asks anything, and it exists so the digest
27
+ // is read off the file rather than re-derived by an agent: it validates first, then reports
28
+ // the version, the newest change-log entry, the themes, the two admissibility lists and the
29
+ // deadline factors — each one read from the file, none restated from elsewhere.
30
+ //
31
+ // Exit codes: 0 the file is valid or summarised, 1 the file is invalid or the operation
32
+ // refused, 2 the caller asked for something malformed, 3 `summary` found no strategy file —
33
+ // a first run, which the skill answers with the interview and no digest.
27
34
 
28
35
  import { access, mkdir, readFile, writeFile } from 'node:fs/promises';
29
36
  import { dirname, join, resolve } from 'node:path';
@@ -64,6 +71,30 @@ export const TEMPLATE_FILE = fileURLToPath(new URL('../templates/strategy.md', i
64
71
  */
65
72
  export const CHANGE_LOG_SECTION = 'Change log';
66
73
 
74
+ /**
75
+ * The section that tunes the deadline factor a ranked queue applies. Optional in a
76
+ * hand-written file: a strategy created before the tiers existed ranks on
77
+ * `DEFAULT_DEADLINE_TIERS`, and `init` copies a table the owner can edit.
78
+ */
79
+ export const DEADLINE_TIERS_SECTION = 'Deadline tiers';
80
+
81
+ /**
82
+ * The deadline tiers the queue uses when the strategy carries none. The numbers live here
83
+ * once, as the documented default, so a caller that never opens the strategy still ranks
84
+ * predictably; a strategy overrides them, so the rise tunes without a code change.
85
+ *
86
+ * A boundary belongs to the tier that names it: with exactly one day left a bead earns the
87
+ * `within 1d` factor, and a millisecond more drops it to `within 2d`. A bead whose deadline
88
+ * is exactly now has not slipped yet, so it is not overdue; `overdue` covers the past.
89
+ */
90
+ export const DEFAULT_DEADLINE_TIERS = /** @type {DeadlineTier[]} */ ([
91
+ { kind: 'within', minutes: 1440, factor: 4 },
92
+ { kind: 'within', minutes: 2880, factor: 2 },
93
+ { kind: 'within', minutes: 10080, factor: 1.5 },
94
+ { kind: 'beyond', minutes: 10080, factor: 1 },
95
+ { kind: 'overdue', factor: 8 },
96
+ ]);
97
+
67
98
  /** The shape of a date: four digits, two, two. Whether it exists is a separate question. */
68
99
  const DATE = /^\d{4}-\d{2}-\d{2}$/;
69
100
 
@@ -79,6 +110,49 @@ const FIELD_PREFIX = /^([ \t]*[-*][ \t]*\*\*[A-Za-z]+:\*\*[ \t]*)\S.*?[ \t]*$/;
79
110
  /** One change-log line: the date it landed, the version it produced, and the reason. */
80
111
  const CHANGE_LOG_ENTRY = /^[ \t]*[-*][ \t]+(\d{4}-\d{2}-\d{2})[ \t]+v(\d+):[ \t]*(.+?)[ \t]*$/;
81
112
 
113
+ /** One row of the deadline-tier table, with the cells the pipes delimit. */
114
+ const TIER_ROW = /^[ \t]*\|(.*)\|[ \t]*$/;
115
+
116
+ /** The table's header row, skipped as a label rather than read as a tier. */
117
+ const TIER_HEADER = /^(remaining|time|when|deadline)$/i;
118
+
119
+ /** The table's `|---|:--:|` rule, which carries no value. */
120
+ const TIER_RULE = /^:?-{2,}:?$/;
121
+
122
+ /** `within 7d` and `<= 7d`: the factor earned at or inside this much time left. */
123
+ const TIER_WITHIN = /^(?:within|<=)\s+(\d+)\s*([mhd])$/i;
124
+
125
+ /** `more than 7d` and `> 7d`: the factor for anything further out. */
126
+ const TIER_BEYOND = /^(?:more than|>)\s+(\d+)\s*([mhd])$/i;
127
+
128
+ /** `overdue`: the factor for a deadline already passed. */
129
+ const TIER_OVERDUE = /^overdue$/i;
130
+
131
+ /** A factor: a positive number, in the plain form a reader can check. */
132
+ const TIER_FACTOR = /^\d+(?:\.\d+)?$/;
133
+
134
+ /** How long each unit in a tier row lasts. */
135
+ const TIER_UNIT_MINUTES = Object.freeze(/** @type {const} */ ({ m: 1, h: 60, d: 1440 }));
136
+
137
+ /** How many milliseconds a minute holds, for turning a tier's minutes into a distance. */
138
+ const MS_PER_MINUTE = 60_000;
139
+
140
+ /**
141
+ * One deadline tier: the factor a bead earns at a stated distance from its deadline.
142
+ * @typedef {{ kind: 'overdue', factor: number }
143
+ * | { kind: 'within', minutes: number, factor: number }
144
+ * | { kind: 'beyond', minutes: number, factor: number }} DeadlineTier
145
+ */
146
+
147
+ /**
148
+ * What `parseDeadlineTiers` found.
149
+ * @typedef {object} DeadlineTiersReport
150
+ * @property {boolean} ok
151
+ * @property {DeadlineTier[]} tiers The parsed tiers, or the documented default when absent.
152
+ * @property {string[]} errors One line per defect in the table.
153
+ * @property {'file' | 'default'} source Whether the tiers were read from the file.
154
+ */
155
+
82
156
  /** A usage mistake: the caller asked for something malformed. Exit code 2. */
83
157
  export class UsageError extends Error {}
84
158
 
@@ -163,6 +237,126 @@ function sectionBody(lines, title) {
163
237
  return body;
164
238
  }
165
239
 
240
+ /**
241
+ * The minutes a tier row's `<number><unit>` names.
242
+ * @param {string} count @param {string} unit @returns {number}
243
+ */
244
+ function tierMinutes(count, unit) {
245
+ const key = /** @type {'m'|'h'|'d'} */ (unit.toLowerCase());
246
+ return Number(count) * TIER_UNIT_MINUTES[key];
247
+ }
248
+
249
+ /**
250
+ * One table row as a tier, or a message naming what the row got wrong.
251
+ * @param {string} when The "Remaining" cell.
252
+ * @param {string} factorText The "Factor" cell.
253
+ * @returns {{ tier: DeadlineTier } | { error: string }}
254
+ */
255
+ function parseTierRow(when, factorText) {
256
+ const factor = TIER_FACTOR.test(factorText) ? Number(factorText) : 0;
257
+ if (factor <= 0) return { error: `factor "${factorText}" is not a positive number` };
258
+
259
+ if (TIER_OVERDUE.test(when)) return { tier: { kind: 'overdue', factor } };
260
+ const within = TIER_WITHIN.exec(when);
261
+ if (within !== null) return { tier: { kind: 'within', minutes: tierMinutes(within[1], within[2]), factor } };
262
+ const beyond = TIER_BEYOND.exec(when);
263
+ if (beyond !== null) return { tier: { kind: 'beyond', minutes: tierMinutes(beyond[1], beyond[2]), factor } };
264
+ return { error: `"${when}" is not "overdue", "within <time>" or "more than <time>"` };
265
+ }
266
+
267
+ /**
268
+ * Read the deadline tiers out of a strategy: the `## Deadline tiers` table, or the
269
+ * documented default when the section is absent or carries no row.
270
+ *
271
+ * A table that is present must be complete — exactly one `overdue` row, exactly one
272
+ * `more than <time>` row, and at least one `within <time>` row — because a factor that
273
+ * silently fell back to a default would make the rise depend on a file the reader cannot
274
+ * see. Every defect is reported, so one run fixes the whole table.
275
+ * @param {string} text @returns {DeadlineTiersReport}
276
+ */
277
+ export function parseDeadlineTiers(text) {
278
+ const body = sectionBody(text.split('\n'), DEADLINE_TIERS_SECTION);
279
+ if (body === null) return { ok: true, tiers: [...DEFAULT_DEADLINE_TIERS], errors: [], source: 'default' };
280
+
281
+ /** @type {DeadlineTier[]} */
282
+ const rows = [];
283
+ /** @type {string[]} */
284
+ const errors = [];
285
+ let firstTableLine = true;
286
+
287
+ for (const line of body) {
288
+ const cells = TIER_ROW.exec(line)?.[1].split('|');
289
+ if (cells === undefined || cells.length !== 2) continue;
290
+ const when = cells[0].trim();
291
+ const factorText = cells[1].trim();
292
+ const isFirst = firstTableLine;
293
+ firstTableLine = false;
294
+ if (isFirst && TIER_HEADER.test(when)) continue;
295
+ if (TIER_RULE.test(when) || TIER_RULE.test(factorText)) continue;
296
+
297
+ const parsed = parseTierRow(when, factorText);
298
+ if ('error' in parsed) {
299
+ errors.push(`${DEADLINE_TIERS_SECTION}: row "| ${when} | ${factorText} |" — ${parsed.error}`);
300
+ continue;
301
+ }
302
+ rows.push(parsed.tier);
303
+ }
304
+
305
+ // A heading with no readable row states no tiers, so the default applies and the file is
306
+ // not credited with a table it does not carry.
307
+ if (rows.length === 0 && errors.length === 0) {
308
+ return { ok: true, tiers: [...DEFAULT_DEADLINE_TIERS], errors: [], source: 'default' };
309
+ }
310
+
311
+ if (errors.length === 0) {
312
+ const count = (/** @type {DeadlineTier['kind']} */ kind) => rows.filter((tier) => tier.kind === kind).length;
313
+ if (count('overdue') !== 1) errors.push(`${DEADLINE_TIERS_SECTION}: needs exactly one "overdue" row, found ${count('overdue')}`);
314
+ if (count('beyond') !== 1) errors.push(`${DEADLINE_TIERS_SECTION}: needs exactly one "more than <time>" row, found ${count('beyond')}`);
315
+ if (count('within') === 0) errors.push(`${DEADLINE_TIERS_SECTION}: needs at least one "within <time>" row`);
316
+ }
317
+
318
+ if (errors.length > 0) return { ok: false, tiers: [...DEFAULT_DEADLINE_TIERS], errors, source: 'file' };
319
+
320
+ const within = rows
321
+ .filter((tier) => tier.kind === 'within')
322
+ .sort((a, b) => (a.kind === 'within' && b.kind === 'within' ? a.minutes - b.minutes : 0));
323
+ const beyond = rows.filter((tier) => tier.kind === 'beyond');
324
+ const overdue = rows.filter((tier) => tier.kind === 'overdue');
325
+ return { ok: true, tiers: [...within, ...beyond, ...overdue], errors: [], source: 'file' };
326
+ }
327
+
328
+ /**
329
+ * What a deadline is worth `remainingMs` from now: the factor from the narrowest `within`
330
+ * tier the remaining time fits inside, the overdue factor once the moment has passed, and
331
+ * the `more than` factor beyond every tier.
332
+ *
333
+ * The boundaries are the whole contract, so each falls on one stated side: with exactly one
334
+ * day left a bead earns the `within 1d` factor, and a millisecond more drops it to
335
+ * `within 2d`; a bead whose deadline is exactly now has not slipped yet, so it is not
336
+ * overdue.
337
+ * @param {number} remainingMs
338
+ * @param {DeadlineTier[]} [tiers]
339
+ * @returns {{ factor: number, overdue: boolean }}
340
+ */
341
+ export function deadlineMultiplier(remainingMs, tiers = DEFAULT_DEADLINE_TIERS) {
342
+ /** @type {{ minutes: number, factor: number }[]} */
343
+ const within = [];
344
+ let beyond = 1;
345
+ let overdue = 1;
346
+ for (const tier of tiers) {
347
+ if (tier.kind === 'within') within.push({ minutes: tier.minutes, factor: tier.factor });
348
+ else if (tier.kind === 'beyond') beyond = tier.factor;
349
+ else overdue = tier.factor;
350
+ }
351
+ within.sort((a, b) => a.minutes - b.minutes);
352
+
353
+ if (remainingMs < 0) return { factor: overdue, overdue: true };
354
+ for (const tier of within) {
355
+ if (remainingMs <= tier.minutes * MS_PER_MINUTE) return { factor: tier.factor, overdue: false };
356
+ }
357
+ return { factor: beyond, overdue: false };
358
+ }
359
+
166
360
  /**
167
361
  * Whether a body carries anything a scorer can read. An HTML comment is guidance, not
168
362
  * content, so a template copied and never filled in is empty rather than filled.
@@ -241,6 +435,9 @@ export function validateStrategy(text) {
241
435
  }
242
436
 
243
437
  const lines = text.split('\n');
438
+ const tiers = parseDeadlineTiers(text);
439
+ if (!tiers.ok) errors.push(...tiers.errors);
440
+
244
441
  for (const title of REQUIRED_SECTIONS) {
245
442
  const body = sectionBody(lines, title);
246
443
  if (body === null) errors.push(`missing required section: "## ${title}"`);
@@ -337,16 +534,161 @@ export function bumpStrategy(text, { today, reason }) {
337
534
  return { from, to, updated: today, reason: cleanReason, line, text: appendChangeLog(bumped, line) };
338
535
  }
339
536
 
537
+ // ─── Summary ──────────────────────────────────────────────────────────────────
538
+
539
+ /**
540
+ * How many bullets the digest quotes from each admissibility list. Two keeps the digest a
541
+ * handful of lines; the count of what it left out points the reader back at the file.
542
+ */
543
+ const SUMMARY_ITEMS = 2;
544
+
545
+ /** The longest one bullet may run in the digest, so one answer cannot re-print a section. */
546
+ const SUMMARY_ITEM_LENGTH = 100;
547
+
548
+ /**
549
+ * The items under one heading: each bullet with the continuation lines that belong to it,
550
+ * and the template's HTML-comment guidance skipped. A bullet that wraps over several lines
551
+ * is one item, so a quoting rule is read whole rather than sliced at its first line.
552
+ * @param {string[]} lines @param {string} title @returns {string[]}
553
+ */
554
+ function sectionItems(lines, title) {
555
+ const body = sectionBody(lines, title);
556
+ if (body === null) return [];
557
+
558
+ /** @type {string[]} */
559
+ const items = [];
560
+ let inComment = false;
561
+ for (const line of body) {
562
+ const trimmed = line.trim();
563
+ if (inComment) {
564
+ if (trimmed.includes('-->')) inComment = false;
565
+ continue;
566
+ }
567
+ if (trimmed.startsWith('<!--')) {
568
+ if (!trimmed.includes('-->')) inComment = true;
569
+ continue;
570
+ }
571
+ const bullet = /^[-*][ \t]+(.+)$/.exec(trimmed);
572
+ if (bullet !== null) {
573
+ items.push(bullet[1].trim());
574
+ continue;
575
+ }
576
+ if (trimmed === '' || items.length === 0) continue;
577
+ items[items.length - 1] = `${items[items.length - 1]} ${trimmed}`;
578
+ }
579
+ return items;
580
+ }
581
+
582
+ /**
583
+ * One item as the digest shows it, cut when it is long. A cut item says so, because a
584
+ * silently shortened rule reads as the whole rule.
585
+ * @param {string} item @returns {string}
586
+ */
587
+ function shorten(item) {
588
+ return item.length <= SUMMARY_ITEM_LENGTH ? item : `${item.slice(0, SUMMARY_ITEM_LENGTH).trimEnd()}…`;
589
+ }
590
+
591
+ /**
592
+ * One section as a single digest line: at most `SUMMARY_ITEMS` bullets, then how many the
593
+ * digest left out, so the reader knows the file still says more.
594
+ * @param {string[]} items @returns {string}
595
+ */
596
+ function digestLine(items) {
597
+ const shown = items.slice(0, SUMMARY_ITEMS).map(shorten);
598
+ if (shown.length === 0) return '(none written)';
599
+ const hidden = items.length - shown.length;
600
+ return hidden === 0 ? shown.join(' · ') : `${shown.join(' · ')} · (+${hidden} more in the file)`;
601
+ }
602
+
603
+ /**
604
+ * A tier's distance in the largest unit that divides it, so the digest repeats the spelling
605
+ * the table uses: 1440 minutes reads `1d`, and 90 reads `90m`.
606
+ * @param {number} minutes @returns {string}
607
+ */
608
+ function spanText(minutes) {
609
+ if (minutes % TIER_UNIT_MINUTES.d === 0) return `${minutes / TIER_UNIT_MINUTES.d}d`;
610
+ if (minutes % TIER_UNIT_MINUTES.h === 0) return `${minutes / TIER_UNIT_MINUTES.h}h`;
611
+ return `${minutes}m`;
612
+ }
613
+
614
+ /**
615
+ * One tier as the digest shows it: the phrase the table's row uses, then the factor.
616
+ * @param {DeadlineTier} tier @returns {string}
617
+ */
618
+ function tierText(tier) {
619
+ if (tier.kind === 'overdue') return `overdue ×${tier.factor}`;
620
+ return `${tier.kind === 'within' ? 'within' : 'more than'} ${spanText(tier.minutes)} ×${tier.factor}`;
621
+ }
622
+
623
+ /**
624
+ * The deadline factors as one digest line, narrowest row first. The order is part of the
625
+ * rule — the queue multiplies by the narrowest row the remaining time fits inside — so the
626
+ * whole table prints: a truncated order would summarise a different rule.
627
+ * @param {DeadlineTier[]} tiers @returns {string}
628
+ */
629
+ function deadlineLine(tiers) {
630
+ return tiers.map(tierText).join(' · ');
631
+ }
632
+
633
+ /**
634
+ * When the rubric last changed and why: the newest change-log entry, because the log is
635
+ * written oldest first and `validate` already proves it names the version the file carries.
636
+ * A file with no entry says so rather than borrowing the header date as a reason.
637
+ * @param {string[]} lines @param {string} updated @returns {string}
638
+ */
639
+ function lastChange(lines, updated) {
640
+ const entries = (sectionBody(lines, CHANGE_LOG_SECTION) ?? [])
641
+ .map((line) => CHANGE_LOG_ENTRY.exec(line))
642
+ .filter((match) => match !== null);
643
+ const newest = entries[entries.length - 1];
644
+ if (newest === null || newest === undefined) return `${updated} — no change-log entry, so no reason is on file`;
645
+ return `${newest[1]} v${newest[2]}: ${newest[3]}`;
646
+ }
647
+
648
+ /**
649
+ * The digest the skill opens with: the file it read, the version, the newest change-log
650
+ * entry, the themes, the two admissibility lists, and the deadline factors when the file
651
+ * states them — a handful of lines, never a re-print.
652
+ *
653
+ * Nothing here is a ranking order, and the labels must not suggest one. `## What counts as
654
+ * value` and `## Explicitly out of scope` are admissibility: the test a bead passes to earn
655
+ * work, and the work the month rules out. What orders the beads that pass them is the
656
+ * scoring rubric — RICE with its multipliers — not this file. The one ordered rule set the
657
+ * file does state is `## Deadline tiers`, printed in its own order and omitted entirely when
658
+ * the section is absent, because the documented default then applies and the file is silent.
659
+ *
660
+ * None of these is a section of its own and none must become one. `validate` gates every
661
+ * strategy already committed, so a new required section would fail all of them.
662
+ * @param {string} text @param {string} file @param {StrategyReport} report A passing report.
663
+ * @returns {string}
664
+ */
665
+ export function summariseStrategy(text, file, report) {
666
+ const lines = text.split('\n');
667
+ const digest = [
668
+ `${file} — strategy v${report.version}`,
669
+ ` last change: ${lastChange(lines, report.updated ?? 'no date recorded')}`,
670
+ ` themes: ${digestLine(sectionItems(lines, 'Current themes'))}`,
671
+ ` admissibility — what counts as value: ${digestLine(sectionItems(lines, 'What counts as value'))}`,
672
+ ` admissibility — out of scope: ${digestLine(sectionItems(lines, 'Explicitly out of scope'))}`,
673
+ ];
674
+ const tiers = parseDeadlineTiers(text);
675
+ if (tiers.source === 'file') digest.push(` deadline factors: ${deadlineLine(tiers.tiers)}`);
676
+ return `${digest.join('\n')}\n`;
677
+ }
678
+
340
679
  // ─── CLI ──────────────────────────────────────────────────────────────────────
341
680
 
342
681
  /** @type {readonly string[]} */
343
- export const COMMANDS = /** @type {const} */ (['init', 'validate', 'bump']);
682
+ export const COMMANDS = /** @type {const} */ (['init', 'validate', 'bump', 'summary']);
344
683
 
345
684
  const USAGE = `Usage: node strategy.mjs <${COMMANDS.join('|')}> [options]
346
685
 
347
686
  init Write the shipped template to the strategy path. Refuses to overwrite.
348
687
  validate Check the shape and print the file to stdout only when it passes.
349
688
  bump Raise the version by one, record today's date and append the reason.
689
+ summary Print the digest to open a run with: path, version, last change, admissibility,
690
+ deadline factors. Exits 3 when no strategy file exists, so a first run is told
691
+ apart from a strategy that is present but invalid.
350
692
 
351
693
  Options:
352
694
  --file <path> Strategy file (default: ${DEFAULT_FILE}, relative to the repository root)
@@ -414,9 +756,12 @@ export function parseArgs(argv) {
414
756
  * carries `.git` as a file, so existence is the whole test. Walks with `node:fs/promises`
415
757
  * and never shells out, because this script spawns nothing. Falls back to `start` when no
416
758
  * ancestor is a repository, so a run outside one writes where the caller stands.
759
+ *
760
+ * Exported for the same reason as `resolveStrategyPath`: another skill's default path is
761
+ * documented against the repository root, and two walkers would be two answers.
417
762
  * @param {string} start @returns {Promise<string>}
418
763
  */
419
- async function findRepositoryRoot(start) {
764
+ export async function findRepositoryRoot(start) {
420
765
  const from = resolve(start);
421
766
  let directory = from;
422
767
  for (;;) {
@@ -447,6 +792,15 @@ export async function resolveStrategyPath({ file = DEFAULT_FILE, cwd = process.c
447
792
  return resolve(base, file);
448
793
  }
449
794
 
795
+ /**
796
+ * The one line every subcommand prints when the strategy file is not there yet. Reused so
797
+ * `summary` and `validate` cannot drift into two descriptions of the same absence.
798
+ * @param {string} file @returns {string}
799
+ */
800
+ function missingMessage(file) {
801
+ return `${file} does not exist — run "init" and interview the owner, or pass --file <path>`;
802
+ }
803
+
450
804
  /**
451
805
  * Build the report text for one failed run.
452
806
  * @param {string} file @param {string[]} errors @returns {string}
@@ -516,14 +870,33 @@ export async function runCli({
516
870
  return 0;
517
871
  }
518
872
 
873
+ // `summary` separates the two states the skill answers differently: no file is a first
874
+ // run, which takes the interview and no digest; a file that fails the shape check is
875
+ // reported as what it is, never as the current strategy.
876
+ if (options.command === 'summary') {
877
+ /** @type {string} */
878
+ let current;
879
+ try {
880
+ current = await readFile(file, 'utf8');
881
+ } catch {
882
+ writeError(`${missingMessage(file)}\n`);
883
+ return 3;
884
+ }
885
+ const currentReport = validateStrategy(current);
886
+ if (!currentReport.ok) {
887
+ writeError(formatErrors(file, currentReport.errors));
888
+ return 1;
889
+ }
890
+ write(summariseStrategy(current, file, currentReport));
891
+ return 0;
892
+ }
893
+
519
894
  /** @type {string} */
520
895
  let text;
521
896
  try {
522
897
  text = await readFile(file, 'utf8');
523
898
  } catch {
524
- throw new StrategyError(
525
- `${file} does not exist — run "init" and interview the owner, or pass --file <path>`,
526
- );
899
+ throw new StrategyError(missingMessage(file));
527
900
  }
528
901
 
529
902
  if (options.command === 'bump') {
@@ -6,7 +6,8 @@ confidence and effort. Keep it short enough that you actually maintain it.
6
6
 
7
7
  It lives in the repository as a committed file, never in the tracker, so a reviewer can
8
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
9
+ the validator names it. `## Deadline tiers` is the one exception — omit it and the queue
10
+ ranks on its documented default. Set the version and the date with
10
11
  `node <skill-scripts>/strategy.mjs bump` after you edit the content.
11
12
  -->
12
13
 
@@ -50,6 +51,31 @@ For example: 1 = half a day, one file; 3 = two to three days, one module with it
50
51
  5 = a week or more, several modules or a migration.
51
52
  -->
52
53
 
54
+ ## Deadline tiers
55
+
56
+ <!--
57
+ What a bd `--due` date does to a bead's rank in the HITL queue. The queue multiplies its
58
+ total by the factor of the narrowest row the remaining time fits inside, and a bead with
59
+ no deadline keeps the "more than" factor. Write each time as a number and one of `m`, `h`
60
+ or `d`, as in `within 1d`; read the rows in this order, narrowest first. A table needs
61
+ exactly one `overdue` row, exactly one `more than` row and at least one `within` row, and
62
+ `validate` names any row it cannot read.
63
+
64
+ A boundary belongs to the row that names it: exactly one day left earns 4, and a
65
+ millisecond more earns 2. A bead whose deadline is exactly now has not slipped yet, so it
66
+ is not overdue. A strategy with no table falls back to the queue's documented default, so
67
+ the rows below are the shipped values: edit a number, then `bump`, and the next queue run
68
+ uses it.
69
+ -->
70
+
71
+ | Remaining | Factor |
72
+ |---|---|
73
+ | within 1d | 4 |
74
+ | within 2d | 2 |
75
+ | within 7d | 1.5 |
76
+ | more than 7d | 1 |
77
+ | overdue | 8 |
78
+
53
79
  ## Change log
54
80
 
55
81
  <!--
@@ -231,11 +231,18 @@ A worktree outlives its bead: ralph leaves the directory behind when a feature c
231
231
  ```bash
232
232
  node skills/workflow/ralph/scripts/prune-worktrees.mjs # remove closed-bead worktrees, report the rest
233
233
  node skills/workflow/ralph/scripts/prune-worktrees.mjs --remove-orphans # also remove open-bead and missing-bead worktrees
234
+ node skills/workflow/ralph/scripts/prune-worktrees.mjs --base <ref> # prove content against <ref>, not origin/HEAD
234
235
  ```
235
236
 
236
237
  **Candidacy comes from bead state, never from git ancestry.** The repo squash-merges, so every landed branch still reports as unmerged. A directory named after a bead that `bd show` reports `closed` is the only proof that admits removal; an open bead, a missing bead, or a bead `bd` could not answer for is kept unless `--remove-orphans` asks otherwise. A bead `bd` could not read is never removed, whatever the flags say.
237
238
 
238
- **A candidate that fails a guard is refused, and the reason is printed.** The guards are: dirty working tree, `locked` worktree, detached HEAD, directory not registered with git, branch with no upstream, and branch with unpushed commits.
239
+ **A candidate that fails a guard is refused, and the reason is printed.** The guards are: dirty working tree, `locked` worktree, detached HEAD, directory not registered with git or unreadable, and one proof guard that follows the bead's state — unpushed commits on an open bead, unproven content on a closed one.
240
+
241
+ **A closed bead is decided by content alone, whatever its upstream is.** Ralph cuts its branch from the remote base (`git worktree add -b feature/<id> … origin/main`), so `branch.autoSetupMerge` makes that base the branch's upstream, and a squash merge rewrites the commits: the branch stays ahead of its own upstream for ever. Counting commits against that upstream refuses every landed ralph worktree. So the guard is content, upstream or not: the base must hold the branch's own version of every path the branch changed since their merge base. No upstream is not a refusal by itself — a merge that deletes the branch also deletes its upstream, so no remote-tracking branch is the normal end of landed work. That compares content, never history, so removal discards every commit the base does not contain, an empty commit or a change the branch added and reverted included, together with its message. The test is one-sided, so a base that moved on after the branch was cut still proves the branch landed.
242
+
243
+ **An open bead is decided by its upstream.** With no upstream it is refused on that alone, whatever its content says; with one, every commit must be pushed to it.
244
+
245
+ **The base is the remote's default branch,** read from `origin/HEAD` so that no branch name is hardcoded. `--base <ref>` names another, such as a release branch. A base that cannot be resolved, or that names the worktree's own branch, keeps the worktree and refuses.
239
246
 
240
247
  **Removal is three commands.** `git worktree remove <path>`, then `git branch -D <branch>`, then `git push origin --delete <branch>`. The remote branch name is the worktree's own checked-out branch, so ralph's `feature/<parent-id>` deletes `origin/feature/<parent-id>`. A remote ref that git already reports as gone is not a failure.
241
248