@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.
- package/CHANGELOG.md +167 -0
- package/README.md +8 -4
- package/agents/README.md +5 -0
- package/agents/guppi/agent.md +72 -2
- package/bin/installers/claude.mjs +7 -3
- package/package.json +1 -1
- package/scripts/lib/compose-agent.mjs +45 -7
- package/skills/review/review-defects/SKILL.md +2 -1
- package/skills/review/review-report/EXAMPLES.md +7 -3
- package/skills/workflow/hitl-queue/BOUNDARY.md +122 -0
- package/skills/workflow/hitl-queue/DEADLINES.md +59 -0
- package/skills/workflow/hitl-queue/RECOMMEND.md +89 -0
- package/skills/workflow/hitl-queue/SCOPES.md +118 -0
- package/skills/workflow/hitl-queue/SKILL.md +48 -32
- package/skills/workflow/hitl-queue/scripts/boundary.mjs +493 -0
- package/skills/workflow/hitl-queue/scripts/queue.mjs +464 -78
- package/skills/workflow/hitl-queue/scripts/recommend.mjs +492 -0
- package/skills/workflow/hitl-queue/scripts/scopes.mjs +479 -0
- package/skills/workflow/prioritise-work/REFERENCE.md +34 -0
- package/skills/workflow/prioritise-work/SKILL.md +29 -29
- package/skills/workflow/prioritise-work/scripts/strategy.mjs +381 -8
- package/skills/workflow/prioritise-work/templates/strategy.md +27 -1
- package/skills/workflow/ralph/REFERENCE.md +8 -1
- package/skills/workflow/ralph/scripts/prune-worktrees.mjs +247 -12
- package/token-map.README.md +14 -2
- package/token-map.json +9 -1
|
@@ -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
|
-
//
|
|
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
|
-
//
|
|
26
|
-
//
|
|
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.
|
|
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,
|
|
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
|
|