@jaybeeuu/agent-cortex 2.9.0 → 2.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +131 -0
- package/README.md +8 -4
- package/agents/README.md +5 -0
- package/agents/guppi/agent.md +70 -1
- 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/SKILL.md +9 -9
- package/skills/workflow/prioritise-work/scripts/strategy.mjs +191 -1
- package/skills/workflow/prioritise-work/templates/strategy.md +27 -1
- package/skills/workflow/ralph/REFERENCE.md +6 -1
- package/skills/workflow/ralph/scripts/prune-worktrees.mjs +185 -10
- package/token-map.README.md +14 -2
- package/token-map.json +9 -1
|
@@ -64,6 +64,30 @@ export const TEMPLATE_FILE = fileURLToPath(new URL('../templates/strategy.md', i
|
|
|
64
64
|
*/
|
|
65
65
|
export const CHANGE_LOG_SECTION = 'Change log';
|
|
66
66
|
|
|
67
|
+
/**
|
|
68
|
+
* The section that tunes the deadline factor a ranked queue applies. Optional in a
|
|
69
|
+
* hand-written file: a strategy created before the tiers existed ranks on
|
|
70
|
+
* `DEFAULT_DEADLINE_TIERS`, and `init` copies a table the owner can edit.
|
|
71
|
+
*/
|
|
72
|
+
export const DEADLINE_TIERS_SECTION = 'Deadline tiers';
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The deadline tiers the queue uses when the strategy carries none. The numbers live here
|
|
76
|
+
* once, as the documented default, so a caller that never opens the strategy still ranks
|
|
77
|
+
* predictably; a strategy overrides them, so the rise tunes without a code change.
|
|
78
|
+
*
|
|
79
|
+
* A boundary belongs to the tier that names it: with exactly one day left a bead earns the
|
|
80
|
+
* `within 1d` factor, and a millisecond more drops it to `within 2d`. A bead whose deadline
|
|
81
|
+
* is exactly now has not slipped yet, so it is not overdue; `overdue` covers the past.
|
|
82
|
+
*/
|
|
83
|
+
export const DEFAULT_DEADLINE_TIERS = /** @type {DeadlineTier[]} */ ([
|
|
84
|
+
{ kind: 'within', minutes: 1440, factor: 4 },
|
|
85
|
+
{ kind: 'within', minutes: 2880, factor: 2 },
|
|
86
|
+
{ kind: 'within', minutes: 10080, factor: 1.5 },
|
|
87
|
+
{ kind: 'beyond', minutes: 10080, factor: 1 },
|
|
88
|
+
{ kind: 'overdue', factor: 8 },
|
|
89
|
+
]);
|
|
90
|
+
|
|
67
91
|
/** The shape of a date: four digits, two, two. Whether it exists is a separate question. */
|
|
68
92
|
const DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
69
93
|
|
|
@@ -79,6 +103,48 @@ const FIELD_PREFIX = /^([ \t]*[-*][ \t]*\*\*[A-Za-z]+:\*\*[ \t]*)\S.*?[ \t]*$/;
|
|
|
79
103
|
/** One change-log line: the date it landed, the version it produced, and the reason. */
|
|
80
104
|
const CHANGE_LOG_ENTRY = /^[ \t]*[-*][ \t]+(\d{4}-\d{2}-\d{2})[ \t]+v(\d+):[ \t]*(.+?)[ \t]*$/;
|
|
81
105
|
|
|
106
|
+
/** One row of the deadline-tier table, with the cells the pipes delimit. */
|
|
107
|
+
const TIER_ROW = /^[ \t]*\|(.*)\|[ \t]*$/;
|
|
108
|
+
|
|
109
|
+
/** The table's header row, skipped as a label rather than read as a tier. */
|
|
110
|
+
const TIER_HEADER = /^(remaining|time|when|deadline)$/i;
|
|
111
|
+
|
|
112
|
+
/** The table's `|---|:--:|` rule, which carries no value. */
|
|
113
|
+
const TIER_RULE = /^:?-{2,}:?$/;
|
|
114
|
+
|
|
115
|
+
/** `within 7d` and `<= 7d`: the factor earned at or inside this much time left. */
|
|
116
|
+
const TIER_WITHIN = /^(?:within|<=)\s+(\d+)\s*([mhd])$/i;
|
|
117
|
+
|
|
118
|
+
/** `more than 7d` and `> 7d`: the factor for anything further out. */
|
|
119
|
+
const TIER_BEYOND = /^(?:more than|>)\s+(\d+)\s*([mhd])$/i;
|
|
120
|
+
|
|
121
|
+
/** `overdue`: the factor for a deadline already passed. */
|
|
122
|
+
const TIER_OVERDUE = /^overdue$/i;
|
|
123
|
+
|
|
124
|
+
/** A factor: a positive number, in the plain form a reader can check. */
|
|
125
|
+
const TIER_FACTOR = /^\d+(?:\.\d+)?$/;
|
|
126
|
+
|
|
127
|
+
/** How long each unit in a tier row lasts. */
|
|
128
|
+
const TIER_UNIT_MINUTES = Object.freeze(/** @type {const} */ ({ m: 1, h: 60, d: 1440 }));
|
|
129
|
+
|
|
130
|
+
/** How many milliseconds a minute holds, for turning a tier's minutes into a distance. */
|
|
131
|
+
const MS_PER_MINUTE = 60_000;
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* One deadline tier: the factor a bead earns at a stated distance from its deadline.
|
|
135
|
+
* @typedef {{ kind: 'overdue', factor: number }
|
|
136
|
+
* | { kind: 'within', minutes: number, factor: number }
|
|
137
|
+
* | { kind: 'beyond', minutes: number, factor: number }} DeadlineTier
|
|
138
|
+
*/
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* What `parseDeadlineTiers` found.
|
|
142
|
+
* @typedef {object} DeadlineTiersReport
|
|
143
|
+
* @property {boolean} ok
|
|
144
|
+
* @property {DeadlineTier[]} tiers The parsed tiers, or the documented default when absent.
|
|
145
|
+
* @property {string[]} errors One line per defect in the table.
|
|
146
|
+
*/
|
|
147
|
+
|
|
82
148
|
/** A usage mistake: the caller asked for something malformed. Exit code 2. */
|
|
83
149
|
export class UsageError extends Error {}
|
|
84
150
|
|
|
@@ -163,6 +229,124 @@ function sectionBody(lines, title) {
|
|
|
163
229
|
return body;
|
|
164
230
|
}
|
|
165
231
|
|
|
232
|
+
/**
|
|
233
|
+
* The minutes a tier row's `<number><unit>` names.
|
|
234
|
+
* @param {string} count @param {string} unit @returns {number}
|
|
235
|
+
*/
|
|
236
|
+
function tierMinutes(count, unit) {
|
|
237
|
+
const key = /** @type {'m'|'h'|'d'} */ (unit.toLowerCase());
|
|
238
|
+
return Number(count) * TIER_UNIT_MINUTES[key];
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* One table row as a tier, or a message naming what the row got wrong.
|
|
243
|
+
* @param {string} when The "Remaining" cell.
|
|
244
|
+
* @param {string} factorText The "Factor" cell.
|
|
245
|
+
* @returns {{ tier: DeadlineTier } | { error: string }}
|
|
246
|
+
*/
|
|
247
|
+
function parseTierRow(when, factorText) {
|
|
248
|
+
const factor = TIER_FACTOR.test(factorText) ? Number(factorText) : 0;
|
|
249
|
+
if (factor <= 0) return { error: `factor "${factorText}" is not a positive number` };
|
|
250
|
+
|
|
251
|
+
if (TIER_OVERDUE.test(when)) return { tier: { kind: 'overdue', factor } };
|
|
252
|
+
const within = TIER_WITHIN.exec(when);
|
|
253
|
+
if (within !== null) return { tier: { kind: 'within', minutes: tierMinutes(within[1], within[2]), factor } };
|
|
254
|
+
const beyond = TIER_BEYOND.exec(when);
|
|
255
|
+
if (beyond !== null) return { tier: { kind: 'beyond', minutes: tierMinutes(beyond[1], beyond[2]), factor } };
|
|
256
|
+
return { error: `"${when}" is not "overdue", "within <time>" or "more than <time>"` };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Read the deadline tiers out of a strategy: the `## Deadline tiers` table, or the
|
|
261
|
+
* documented default when the section is absent or carries no row.
|
|
262
|
+
*
|
|
263
|
+
* A table that is present must be complete — exactly one `overdue` row, exactly one
|
|
264
|
+
* `more than <time>` row, and at least one `within <time>` row — because a factor that
|
|
265
|
+
* silently fell back to a default would make the rise depend on a file the reader cannot
|
|
266
|
+
* see. Every defect is reported, so one run fixes the whole table.
|
|
267
|
+
* @param {string} text @returns {DeadlineTiersReport}
|
|
268
|
+
*/
|
|
269
|
+
export function parseDeadlineTiers(text) {
|
|
270
|
+
const body = sectionBody(text.split('\n'), DEADLINE_TIERS_SECTION);
|
|
271
|
+
if (body === null) return { ok: true, tiers: [...DEFAULT_DEADLINE_TIERS], errors: [] };
|
|
272
|
+
|
|
273
|
+
/** @type {DeadlineTier[]} */
|
|
274
|
+
const rows = [];
|
|
275
|
+
/** @type {string[]} */
|
|
276
|
+
const errors = [];
|
|
277
|
+
let firstTableLine = true;
|
|
278
|
+
|
|
279
|
+
for (const line of body) {
|
|
280
|
+
const cells = TIER_ROW.exec(line)?.[1].split('|');
|
|
281
|
+
if (cells === undefined || cells.length !== 2) continue;
|
|
282
|
+
const when = cells[0].trim();
|
|
283
|
+
const factorText = cells[1].trim();
|
|
284
|
+
const isFirst = firstTableLine;
|
|
285
|
+
firstTableLine = false;
|
|
286
|
+
if (isFirst && TIER_HEADER.test(when)) continue;
|
|
287
|
+
if (TIER_RULE.test(when) || TIER_RULE.test(factorText)) continue;
|
|
288
|
+
|
|
289
|
+
const parsed = parseTierRow(when, factorText);
|
|
290
|
+
if ('error' in parsed) {
|
|
291
|
+
errors.push(`${DEADLINE_TIERS_SECTION}: row "| ${when} | ${factorText} |" — ${parsed.error}`);
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
rows.push(parsed.tier);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (rows.length === 0 && errors.length === 0) {
|
|
298
|
+
return { ok: true, tiers: [...DEFAULT_DEADLINE_TIERS], errors: [] };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (errors.length === 0) {
|
|
302
|
+
const count = (/** @type {DeadlineTier['kind']} */ kind) => rows.filter((tier) => tier.kind === kind).length;
|
|
303
|
+
if (count('overdue') !== 1) errors.push(`${DEADLINE_TIERS_SECTION}: needs exactly one "overdue" row, found ${count('overdue')}`);
|
|
304
|
+
if (count('beyond') !== 1) errors.push(`${DEADLINE_TIERS_SECTION}: needs exactly one "more than <time>" row, found ${count('beyond')}`);
|
|
305
|
+
if (count('within') === 0) errors.push(`${DEADLINE_TIERS_SECTION}: needs at least one "within <time>" row`);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (errors.length > 0) return { ok: false, tiers: [...DEFAULT_DEADLINE_TIERS], errors };
|
|
309
|
+
|
|
310
|
+
const within = rows
|
|
311
|
+
.filter((tier) => tier.kind === 'within')
|
|
312
|
+
.sort((a, b) => (a.kind === 'within' && b.kind === 'within' ? a.minutes - b.minutes : 0));
|
|
313
|
+
const beyond = rows.filter((tier) => tier.kind === 'beyond');
|
|
314
|
+
const overdue = rows.filter((tier) => tier.kind === 'overdue');
|
|
315
|
+
return { ok: true, tiers: [...within, ...beyond, ...overdue], errors: [] };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* What a deadline is worth `remainingMs` from now: the factor from the narrowest `within`
|
|
320
|
+
* tier the remaining time fits inside, the overdue factor once the moment has passed, and
|
|
321
|
+
* the `more than` factor beyond every tier.
|
|
322
|
+
*
|
|
323
|
+
* The boundaries are the whole contract, so each falls on one stated side: with exactly one
|
|
324
|
+
* day left a bead earns the `within 1d` factor, and a millisecond more drops it to
|
|
325
|
+
* `within 2d`; a bead whose deadline is exactly now has not slipped yet, so it is not
|
|
326
|
+
* overdue.
|
|
327
|
+
* @param {number} remainingMs
|
|
328
|
+
* @param {DeadlineTier[]} [tiers]
|
|
329
|
+
* @returns {{ factor: number, overdue: boolean }}
|
|
330
|
+
*/
|
|
331
|
+
export function deadlineMultiplier(remainingMs, tiers = DEFAULT_DEADLINE_TIERS) {
|
|
332
|
+
/** @type {{ minutes: number, factor: number }[]} */
|
|
333
|
+
const within = [];
|
|
334
|
+
let beyond = 1;
|
|
335
|
+
let overdue = 1;
|
|
336
|
+
for (const tier of tiers) {
|
|
337
|
+
if (tier.kind === 'within') within.push({ minutes: tier.minutes, factor: tier.factor });
|
|
338
|
+
else if (tier.kind === 'beyond') beyond = tier.factor;
|
|
339
|
+
else overdue = tier.factor;
|
|
340
|
+
}
|
|
341
|
+
within.sort((a, b) => a.minutes - b.minutes);
|
|
342
|
+
|
|
343
|
+
if (remainingMs < 0) return { factor: overdue, overdue: true };
|
|
344
|
+
for (const tier of within) {
|
|
345
|
+
if (remainingMs <= tier.minutes * MS_PER_MINUTE) return { factor: tier.factor, overdue: false };
|
|
346
|
+
}
|
|
347
|
+
return { factor: beyond, overdue: false };
|
|
348
|
+
}
|
|
349
|
+
|
|
166
350
|
/**
|
|
167
351
|
* Whether a body carries anything a scorer can read. An HTML comment is guidance, not
|
|
168
352
|
* content, so a template copied and never filled in is empty rather than filled.
|
|
@@ -241,6 +425,9 @@ export function validateStrategy(text) {
|
|
|
241
425
|
}
|
|
242
426
|
|
|
243
427
|
const lines = text.split('\n');
|
|
428
|
+
const tiers = parseDeadlineTiers(text);
|
|
429
|
+
if (!tiers.ok) errors.push(...tiers.errors);
|
|
430
|
+
|
|
244
431
|
for (const title of REQUIRED_SECTIONS) {
|
|
245
432
|
const body = sectionBody(lines, title);
|
|
246
433
|
if (body === null) errors.push(`missing required section: "## ${title}"`);
|
|
@@ -414,9 +601,12 @@ export function parseArgs(argv) {
|
|
|
414
601
|
* carries `.git` as a file, so existence is the whole test. Walks with `node:fs/promises`
|
|
415
602
|
* and never shells out, because this script spawns nothing. Falls back to `start` when no
|
|
416
603
|
* ancestor is a repository, so a run outside one writes where the caller stands.
|
|
604
|
+
*
|
|
605
|
+
* Exported for the same reason as `resolveStrategyPath`: another skill's default path is
|
|
606
|
+
* documented against the repository root, and two walkers would be two answers.
|
|
417
607
|
* @param {string} start @returns {Promise<string>}
|
|
418
608
|
*/
|
|
419
|
-
async function findRepositoryRoot(start) {
|
|
609
|
+
export async function findRepositoryRoot(start) {
|
|
420
610
|
const from = resolve(start);
|
|
421
611
|
let directory = from;
|
|
422
612
|
for (;;) {
|
|
@@ -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,16 @@ 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
|
|
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, branch with unpushed commits, and branch whose content is not proven landed.
|
|
240
|
+
|
|
241
|
+
**No upstream is not a refusal by itself.** A merge that deletes the branch also deletes the local branch's upstream, so a closed bead's branch with no remote-tracking branch is the normal end of landed work. The guard is content: the base must hold the branch's own version of every path the branch changed since their merge base. 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. On a bead that is not closed, no upstream stays a refusal on its own.
|
|
242
|
+
|
|
243
|
+
**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 resolves to the worktree's own commit, keeps the worktree and refuses.
|
|
239
244
|
|
|
240
245
|
**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
246
|
|
|
@@ -5,6 +5,20 @@
|
|
|
5
5
|
// reads bead state, not git ancestry: this repo squash-merges, so every ancestry
|
|
6
6
|
// test reports a landed branch as unmerged.
|
|
7
7
|
//
|
|
8
|
+
// A merge that deletes its branch also deletes the local branch's upstream, so a
|
|
9
|
+
// closed bead with no remote-tracking branch is the normal end of a landed branch.
|
|
10
|
+
// Only the content can tell that apart from a branch that was never pushed: the
|
|
11
|
+
// branch goes when the base holds the branch's own version of every path the
|
|
12
|
+
// branch changed since their merge base, and stays otherwise. The test is
|
|
13
|
+
// one-sided on purpose: this repo squash-merges, so a base that moved on after the
|
|
14
|
+
// branch was cut holds paths the branch never had, and demanding tree equality
|
|
15
|
+
// would keep every branch but the last one landed.
|
|
16
|
+
//
|
|
17
|
+
// The test proves content, never history. A branch removed here is discarded
|
|
18
|
+
// together with every commit the base does not contain — a reverted change and a
|
|
19
|
+
// run of empty commits both go, and so do their messages. The base is the remote's
|
|
20
|
+
// default branch, never a hardcoded name.
|
|
21
|
+
//
|
|
8
22
|
// Zero dependencies: run with `node prune-worktrees.mjs`.
|
|
9
23
|
|
|
10
24
|
import { execFile } from 'node:child_process';
|
|
@@ -154,15 +168,134 @@ async function classifyBead(id, exec) {
|
|
|
154
168
|
return { classification: status === 'closed' ? 'closed-bead' : 'open-bead', beadStatus: status };
|
|
155
169
|
}
|
|
156
170
|
|
|
171
|
+
/**
|
|
172
|
+
* Resolve the branch a worktree's content must be contained in before its local
|
|
173
|
+
* branch may be dropped. The remote's default branch is the generic answer:
|
|
174
|
+
* `origin/HEAD` is a symbolic ref to whatever the remote calls its default
|
|
175
|
+
* branch, so no branch name is baked in here.
|
|
176
|
+
* @param {string} repoRoot
|
|
177
|
+
* @param {(cwd: string, args: string[]) => Promise<{ ok: true, stdout: string } | { ok: false, stderr: string }>} git
|
|
178
|
+
* @returns {Promise<string | null>} the base ref, or null when git cannot name one.
|
|
179
|
+
*/
|
|
180
|
+
async function resolveBaseRef(repoRoot, git) {
|
|
181
|
+
const result = await git(repoRoot, ['symbolic-ref', '--short', 'refs/remotes/origin/HEAD']);
|
|
182
|
+
if (!result.ok) return null;
|
|
183
|
+
const ref = result.stdout.trim();
|
|
184
|
+
return ref === '' ? null : ref;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Split the output of `git diff --name-only -z` into paths. `-z` is what makes the
|
|
189
|
+
* round trip lossless: without it git C-quotes a path holding a non-ASCII byte, a
|
|
190
|
+
* double quote, a backslash or a newline, and a quoted name fed back as a
|
|
191
|
+
* pathspec names no path. With it, every path arrives raw and NUL-terminated, so
|
|
192
|
+
* a path holding a newline stays one path.
|
|
193
|
+
* @param {string} stdout
|
|
194
|
+
* @returns {string[]}
|
|
195
|
+
*/
|
|
196
|
+
function splitNulPaths(stdout) {
|
|
197
|
+
// The output ends with a NUL, so the split leaves one empty entry to drop. A
|
|
198
|
+
// path is never empty, so no real path is lost with it.
|
|
199
|
+
return stdout.split('\0').filter((entry) => entry !== '');
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Decide whether a branch with no upstream branch still holds work. Removing such
|
|
204
|
+
* a branch is only safe when the base holds the branch's own version of every path
|
|
205
|
+
* the branch changed — the squash-merge case. Anything unprovable keeps the
|
|
206
|
+
* worktree.
|
|
207
|
+
*
|
|
208
|
+
* One-sided containment, in three steps: take the merge base of the base and the
|
|
209
|
+
* branch, list the paths the branch changed since that merge base, then require the
|
|
210
|
+
* base to agree with the branch on those paths alone. Paths only the base added or
|
|
211
|
+
* changed lie outside that set and are ignored, so a base that advanced after the
|
|
212
|
+
* branch was cut still proves the branch landed.
|
|
213
|
+
*
|
|
214
|
+
* A status filter on the two-sided diff would be simpler and is wrong: `D` is the
|
|
215
|
+
* same status whether the branch deleted a path or the base never had it, so a
|
|
216
|
+
* filter cannot tell a deletion the base already carries from one it does not.
|
|
217
|
+
*
|
|
218
|
+
* Restricting to the branch's own paths is what makes an overlap detectable: when
|
|
219
|
+
* the base later changed a path the branch also changed, the two versions differ
|
|
220
|
+
* and the branch is not proven landed. A deletion on both sides agrees, so it is
|
|
221
|
+
* proven; a deletion on one side only is not.
|
|
222
|
+
*
|
|
223
|
+
* The branch's changed paths are read from git and then handed straight back to a
|
|
224
|
+
* second git call as pathspecs, so that round trip has to be exact. Two escapes
|
|
225
|
+
* make it inexact and both end in a false "proven": a path git would C-quote
|
|
226
|
+
* prints as a quoted string that names no path, and a path starting with `:` is
|
|
227
|
+
* read as pathspec magic instead of a name. Neither alone is sufficient to fix,
|
|
228
|
+
* so the first diff reads with `-z` and the second is run with
|
|
229
|
+
* `--literal-pathspecs`.
|
|
230
|
+
* @param {{ path: string, branch: string, beadClosed: boolean }} target
|
|
231
|
+
* @param {string | null} baseRef
|
|
232
|
+
* @param {(cwd: string, args: string[]) => Promise<{ ok: true, stdout: string } | { ok: false, stderr: string }>} git
|
|
233
|
+
* @returns {Promise<string | null>} a refusal reason, or null when nothing is lost.
|
|
234
|
+
*/
|
|
235
|
+
async function findLostWork({ path, branch, beadClosed }, baseRef, git) {
|
|
236
|
+
if (!beadClosed) {
|
|
237
|
+
return `branch ${branch} has no remote-tracking branch and its bead is not closed`;
|
|
238
|
+
}
|
|
239
|
+
if (baseRef === null) {
|
|
240
|
+
return `branch ${branch} has no remote-tracking branch and the base branch cannot be resolved, so its content cannot be proven landed`;
|
|
241
|
+
}
|
|
242
|
+
const tip = await git(path, ['rev-parse', '--verify', 'HEAD']);
|
|
243
|
+
if (!tip.ok) {
|
|
244
|
+
const detail = tip.stderr.trim();
|
|
245
|
+
const suffix = detail === '' ? '' : `: ${detail}`;
|
|
246
|
+
return `branch ${branch} has no remote-tracking branch and its tip cannot be read${suffix}`;
|
|
247
|
+
}
|
|
248
|
+
const baseTip = await git(path, ['rev-parse', '--verify', '--quiet', `${baseRef}^{commit}`]);
|
|
249
|
+
if (!baseTip.ok) {
|
|
250
|
+
const detail = baseTip.stderr.trim();
|
|
251
|
+
const suffix = detail === '' ? '' : `: ${detail}`;
|
|
252
|
+
return `branch ${branch} has no remote-tracking branch and ${baseRef} cannot be compared with it${suffix}`;
|
|
253
|
+
}
|
|
254
|
+
// A base that resolves to the branch being deleted compares the branch with
|
|
255
|
+
// itself: the diff is empty whatever the branch holds, so the guard cannot fail.
|
|
256
|
+
if (tip.stdout.trim() === baseTip.stdout.trim()) {
|
|
257
|
+
return `branch ${branch} has no remote-tracking branch and ${baseRef} resolves to this branch's own commit, so the comparison would prove nothing`;
|
|
258
|
+
}
|
|
259
|
+
const mergeBase = await git(path, ['merge-base', baseRef, 'HEAD']);
|
|
260
|
+
if (!mergeBase.ok) {
|
|
261
|
+
const detail = mergeBase.stderr.trim();
|
|
262
|
+
const suffix = detail === '' ? '' : `: ${detail}`;
|
|
263
|
+
return `branch ${branch} has no remote-tracking branch and ${baseRef} cannot be compared with it${suffix}`;
|
|
264
|
+
}
|
|
265
|
+
const touched = await git(path, ['diff', '--name-only', '-z', '--no-renames', mergeBase.stdout.trim(), 'HEAD', '--']);
|
|
266
|
+
if (!touched.ok) {
|
|
267
|
+
const detail = touched.stderr.trim();
|
|
268
|
+
const suffix = detail === '' ? '' : `: ${detail}`;
|
|
269
|
+
return `branch ${branch} has no remote-tracking branch and ${baseRef} cannot be compared with it${suffix}`;
|
|
270
|
+
}
|
|
271
|
+
const changedPaths = splitNulPaths(touched.stdout);
|
|
272
|
+
// Nothing but empty commits: no content to prove, and no content to lose.
|
|
273
|
+
if (changedPaths.length === 0) return null;
|
|
274
|
+
// `--literal-pathspecs` is a git global option, so it precedes the subcommand.
|
|
275
|
+
// It keeps a leading `:` in a real filename from being read as pathspec magic.
|
|
276
|
+
const unproven = await git(path, ['--literal-pathspecs', 'diff', '--name-only', '-z', '--no-renames', 'HEAD', baseRef, '--', ...changedPaths]);
|
|
277
|
+
if (!unproven.ok) {
|
|
278
|
+
const detail = unproven.stderr.trim();
|
|
279
|
+
const suffix = detail === '' ? '' : `: ${detail}`;
|
|
280
|
+
return `branch ${branch} has no remote-tracking branch and ${baseRef} cannot be compared with it${suffix}`;
|
|
281
|
+
}
|
|
282
|
+
const unprovenPaths = splitNulPaths(unproven.stdout).length;
|
|
283
|
+
if (unprovenPaths > 0) {
|
|
284
|
+
return `branch ${branch} has no remote-tracking branch and ${baseRef} does not hold this branch's version of ${unprovenPaths} path(s), so its commits may never have been pushed`;
|
|
285
|
+
}
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
|
|
157
289
|
/**
|
|
158
290
|
* Reasons git refuses to tear a worktree down. A worktree removed anyway would
|
|
159
291
|
* throw away uncommitted work or an unpushed branch, so every reason keeps the
|
|
160
292
|
* worktree in place.
|
|
161
|
-
* @param {{ path: string, registered: { branch: string | null, locked: string | null } | undefined }} target
|
|
293
|
+
* @param {{ path: string, registered: { branch: string | null, locked: string | null } | undefined, beadClosed: boolean }} target
|
|
162
294
|
* @param {(cwd: string, args: string[]) => Promise<{ ok: true, stdout: string } | { ok: false, stderr: string }>} git
|
|
295
|
+
* @param {string | null} baseRef
|
|
163
296
|
* @returns {Promise<{ branch: string | null, reasons: string[] }>}
|
|
164
297
|
*/
|
|
165
|
-
async function findRefusals({ path, registered }, git) {
|
|
298
|
+
async function findRefusals({ path, registered, beadClosed }, git, baseRef) {
|
|
166
299
|
/** @type {string[]} */
|
|
167
300
|
const reasons = [];
|
|
168
301
|
|
|
@@ -183,7 +316,8 @@ async function findRefusals({ path, registered }, git) {
|
|
|
183
316
|
|
|
184
317
|
const upstream = await git(path, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}']);
|
|
185
318
|
if (!upstream.ok) {
|
|
186
|
-
|
|
319
|
+
const lost = await findLostWork({ path, branch: registered.branch, beadClosed }, baseRef, git);
|
|
320
|
+
if (lost !== null) reasons.push(lost);
|
|
187
321
|
return { branch: registered.branch, reasons };
|
|
188
322
|
}
|
|
189
323
|
const trackingRef = upstream.stdout.trim();
|
|
@@ -231,12 +365,14 @@ async function removeWorktree(repoRoot, { path, branch }, git) {
|
|
|
231
365
|
* @param {object} options
|
|
232
366
|
* @param {string} options.repoRoot Repository to prune.
|
|
233
367
|
* @param {boolean} [options.removeOrphans] Also remove worktrees whose bead is open or missing.
|
|
368
|
+
* @param {string} [options.base] Ref the content check compares against; defaults to the remote's default branch.
|
|
234
369
|
* @param {(args: string[]) => Promise<string>} [options.exec] bd runner; injectable for tests.
|
|
235
370
|
* @param {(cwd: string, args: string[]) => Promise<{ ok: true, stdout: string } | { ok: false, stderr: string }>} [options.git] git runner; injectable for tests.
|
|
236
371
|
* @returns {Promise<PruneResult>}
|
|
237
372
|
*/
|
|
238
|
-
export async function pruneWorktrees({ repoRoot, exec = runBd, removeOrphans = false, git = runGit }) {
|
|
373
|
+
export async function pruneWorktrees({ repoRoot, exec = runBd, removeOrphans = false, git = runGit, base }) {
|
|
239
374
|
const registered = await readRegisteredWorktrees(repoRoot);
|
|
375
|
+
const baseRef = base ?? (await resolveBaseRef(repoRoot, git));
|
|
240
376
|
/** @type {WorktreeEntry[]} */
|
|
241
377
|
const entries = [];
|
|
242
378
|
|
|
@@ -252,7 +388,11 @@ export async function pruneWorktrees({ repoRoot, exec = runBd, removeOrphans = f
|
|
|
252
388
|
// an unreadable bead is never removed at all.
|
|
253
389
|
const wanted = classification === 'closed-bead' || (classification !== 'unclassified' && removeOrphans);
|
|
254
390
|
if (wanted) {
|
|
255
|
-
const { branch, reasons } = await findRefusals(
|
|
391
|
+
const { branch, reasons } = await findRefusals(
|
|
392
|
+
{ path, registered: registered.get(path), beadClosed: classification === 'closed-bead' },
|
|
393
|
+
git,
|
|
394
|
+
baseRef,
|
|
395
|
+
);
|
|
256
396
|
entry.branch = branch;
|
|
257
397
|
if (reasons.length > 0) {
|
|
258
398
|
entry.outcome = 'refused';
|
|
@@ -275,19 +415,56 @@ export async function pruneWorktrees({ repoRoot, exec = runBd, removeOrphans = f
|
|
|
275
415
|
|
|
276
416
|
// ─── CLI ──────────────────────────────────────────────────────────────────────
|
|
277
417
|
|
|
278
|
-
const USAGE = `Usage: node prune-worktrees.mjs [--remove-orphans]
|
|
418
|
+
const USAGE = `Usage: node prune-worktrees.mjs [--remove-orphans] [--base <ref>]
|
|
279
419
|
|
|
280
420
|
Removes worktrees under .agent-cortex/worktrees whose bead is closed, then
|
|
281
421
|
reports the rest. A worktree whose bead is open or missing is an orphan: it is
|
|
282
422
|
only removed when --remove-orphans is passed. Run from anywhere inside the
|
|
283
423
|
repository.
|
|
424
|
+
|
|
425
|
+
A closed bead's branch may have no remote-tracking branch, because many merge
|
|
426
|
+
methods delete the remote branch when the work lands. Such a branch is removed
|
|
427
|
+
only when the base holds the branch's own version of every path the branch changed
|
|
428
|
+
since their merge base. That proves content and never history: removal discards
|
|
429
|
+
every commit the base does not contain, such as a reverted change or an empty
|
|
430
|
+
commit, together with its message. The base defaults to the remote's default
|
|
431
|
+
branch (origin/HEAD); --base names another ref, such as a release branch. A base
|
|
432
|
+
that cannot be resolved, or that resolves to the worktree's own branch, keeps the
|
|
433
|
+
worktree and refuses.
|
|
284
434
|
`;
|
|
285
435
|
|
|
286
436
|
/** Every flag the CLI accepts. */
|
|
287
437
|
const REMOVE_ORPHANS_FLAG = '--remove-orphans';
|
|
438
|
+
const BASE_FLAG = '--base';
|
|
288
439
|
|
|
289
440
|
class UsageError extends Error {}
|
|
290
441
|
|
|
442
|
+
/**
|
|
443
|
+
* @param {string[]} argv
|
|
444
|
+
* @returns {{ removeOrphans: boolean, base: string | undefined }}
|
|
445
|
+
*/
|
|
446
|
+
function parseArgs(argv) {
|
|
447
|
+
let removeOrphans = false;
|
|
448
|
+
/** @type {string | undefined} */
|
|
449
|
+
let base;
|
|
450
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
451
|
+
const arg = argv[index];
|
|
452
|
+
if (arg === REMOVE_ORPHANS_FLAG) {
|
|
453
|
+
removeOrphans = true;
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
if (arg === BASE_FLAG) {
|
|
457
|
+
const value = argv[index + 1];
|
|
458
|
+
if (value === undefined || value.startsWith('-')) throw new UsageError(`${BASE_FLAG} needs a git ref`);
|
|
459
|
+
base = value;
|
|
460
|
+
index += 1;
|
|
461
|
+
continue;
|
|
462
|
+
}
|
|
463
|
+
throw new UsageError(`unexpected argument: ${arg}`);
|
|
464
|
+
}
|
|
465
|
+
return { removeOrphans, base };
|
|
466
|
+
}
|
|
467
|
+
|
|
291
468
|
/**
|
|
292
469
|
* Resolve the repository root from any directory inside it — the main checkout
|
|
293
470
|
* or a linked worktree. The shared `.git` directory is one level below the root
|
|
@@ -313,14 +490,12 @@ export async function runCli({ argv, cwd = process.cwd(), exec = runBd, git = ru
|
|
|
313
490
|
write(USAGE);
|
|
314
491
|
return 0;
|
|
315
492
|
}
|
|
316
|
-
const
|
|
317
|
-
if (unknown.length > 0) throw new UsageError(`unexpected argument: ${unknown[0]}`);
|
|
318
|
-
const removeOrphans = argv.includes(REMOVE_ORPHANS_FLAG);
|
|
493
|
+
const { removeOrphans, base } = parseArgs(argv);
|
|
319
494
|
|
|
320
495
|
const repoRoot = await resolveRepoRoot(cwd);
|
|
321
496
|
if (repoRoot === null) throw new Error('not inside a git repository');
|
|
322
497
|
|
|
323
|
-
const { entries, exitCode } = await pruneWorktrees({ repoRoot, exec, removeOrphans, git });
|
|
498
|
+
const { entries, exitCode } = await pruneWorktrees({ repoRoot, exec, removeOrphans, git, base });
|
|
324
499
|
|
|
325
500
|
write(`Scanning ${join(repoRoot, '.agent-cortex', 'worktrees')}\n`);
|
|
326
501
|
for (const entry of entries) {
|
package/token-map.README.md
CHANGED
|
@@ -87,6 +87,15 @@ rather than inventing a dotted form:
|
|
|
87
87
|
runtime env var (expanded per context by Claude Code) points at wherever the plugin
|
|
88
88
|
actually runs from. A literal home path in substituted files would break under
|
|
89
89
|
cache-copy, so the installer passes the env form through verbatim.
|
|
90
|
+
- **`layouts`** — the tree each installer stages a resource into, for the resources whose
|
|
91
|
+
shape differs per harness. Only `skills` differs: the repository nests every skill one group
|
|
92
|
+
deep (`skills/<group>/<name>/`), and `bin/installers/pi.mjs` preserves that, while Claude
|
|
93
|
+
Code's plugin scan discovers `skills/<name>/SKILL.md` directly under `skills/`, so
|
|
94
|
+
`bin/installers/claude.mjs` copies every skill flat. A `{{PATH:skills/…}}` row is authored
|
|
95
|
+
against the repository tree; resolution reads the harness's row here and drops the group
|
|
96
|
+
segment when the harness stages skills flat, so the row names a file the install contains.
|
|
97
|
+
The row is a harness fact, not a repository one, which is why it lives here rather than in
|
|
98
|
+
the resolver: a harness that stages skills differently changes this table, not the code.
|
|
90
99
|
- **`agents`** — the `agent-cortex:` name-prefix convention per harness (claude strips the
|
|
91
100
|
prefix during conversion). Agent names themselves do not get per-row mappings because
|
|
92
101
|
the prefix rule covers all of them — every agent is composed from `agents/<name>/` for
|
|
@@ -147,13 +156,16 @@ The full contract is embedded in `token-map.json` under `contract`. In summary,
|
|
|
147
156
|
3. For `TOOL`: look up the canonical key, replace with the harness name; null → drop/omit;
|
|
148
157
|
unknown → throw.
|
|
149
158
|
4. For `PATH`: named key → resolve from `paths`; anything else → resolve relative to
|
|
150
|
-
`plugin_root
|
|
159
|
+
`plugin_root`, after mapping a `skills/…` row onto the harness's staged shape from
|
|
160
|
+
`layouts`; `${ENV_VAR}` values pass through verbatim for runtime expansion.
|
|
151
161
|
5. Reject a map `version` they do not implement.
|
|
152
162
|
|
|
153
163
|
## Authoring guidance
|
|
154
164
|
|
|
155
165
|
- Write tokens with the **canonical authoring names**: `Use {{TOOL:task}} to spawn
|
|
156
|
-
subagents`, `Read {{PATH:skills/workflow/plan/SKILL.md}}`.
|
|
166
|
+
subagents`, `Read {{PATH:skills/workflow/plan/SKILL.md}}`. Write a skill path row in this
|
|
167
|
+
repository's grouped form, `skills/<group>/<name>/<file>`; each install maps it onto the
|
|
168
|
+
shape it stages (see `layouts`), so one authored row names a real file on both harnesses.
|
|
157
169
|
- Never embed a harness-specific name in the canonical sources (`agents/<name>/agent.md`,
|
|
158
170
|
`skills/**/SKILL.md`) — that defeats the map. The materialised Claude plugin
|
|
159
171
|
(`~/.agent-cortex/claude`), the generated `agents/*.agent.md` files, and any installed
|
package/token-map.json
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
"decision": "Adopt the {{TOOL:name}} / {{PATH:name}} / {{SECTION:name}} grammar already standardised by ADR #63 (agents/README.md) rather than a dotted {{tool.name}} form. The uppercase type prefix disambiguates token kinds at a glance, keeps tool/path/section names in one namespace collision-free, and matches the token grammar skills are already written against.",
|
|
9
9
|
"types": {
|
|
10
10
|
"TOOL": "{{TOOL:<canonical tool key>}} — replaced at install time with the bound harness's tool name (see tools).",
|
|
11
|
-
"PATH": "{{PATH:<path key or relative path>}} — replaced at install time with the harness-resolved path (see paths).",
|
|
11
|
+
"PATH": "{{PATH:<path key or relative path>}} — replaced at install time with the harness-resolved path (see paths; a relative arg under skills/ is mapped onto the harness's staged shape first — see layouts).",
|
|
12
12
|
"SECTION": "{{SECTION:<name>}} — replaced by the agent composer with <harness>/<name>.md section content. Not a global substitution; out of scope for token-map.json (see contract)."
|
|
13
13
|
}
|
|
14
14
|
},
|
|
@@ -51,6 +51,13 @@
|
|
|
51
51
|
"pi": { "base": "plugin_root", "relative": "docs/skills/skill-anatomy.md" }
|
|
52
52
|
}
|
|
53
53
|
},
|
|
54
|
+
"layouts": {
|
|
55
|
+
"description": "The tree each installer stages a resource into. `{{PATH:...}}` resolution reads it: a row is authored against the repository tree, so a harness that stages a resource in a different shape needs the row mapped onto its own. Only `skills` differs — Claude Code's plugin scan discovers `skills/<name>/SKILL.md` directly under `skills/`, so bin/installers/claude.mjs copies every skill flat and the group segment an authored row carries is dropped; bin/installers/pi.mjs preserves the repository grouping, so the row resolves as written.",
|
|
56
|
+
"skills": {
|
|
57
|
+
"claude": "flat",
|
|
58
|
+
"pi": "grouped"
|
|
59
|
+
}
|
|
60
|
+
},
|
|
54
61
|
"agents": {
|
|
55
62
|
"prefix": {
|
|
56
63
|
"claude": "",
|
|
@@ -110,6 +117,7 @@
|
|
|
110
117
|
"TOOL unknown: a {{TOOL:<key>}} with no entry in tools is a hard error — the map must be extended. This mirrors the generators (bin/installers/claude.mjs, bin/installers/pi.mjs), which throw on unknown tools.",
|
|
111
118
|
"PATH named key: replace {{PATH:<key>}} with the resolved value of paths.<key>.<harness>.",
|
|
112
119
|
"PATH relative: replace {{PATH:<relative>}} with plugin_root + '/' + <relative> (e.g. {{PATH:skills/workflow/plan/SKILL.md}}).",
|
|
120
|
+
"PATH relative skill: a relative arg whose first segment is `skills` is authored against the repository tree, which nests every skill at skills/<group>/<name>/<file>. Map it onto the harness's staged shape from layouts.skills.<harness>: `grouped` keeps it as written, `flat` drops the group segment (skills/<name>/<file>) because that installer copies skills flat. An arg with fewer than three segments is a hard error — no install has a skill one level under skills/.",
|
|
113
121
|
"PATH resolution: a value may be a literal string, an ${ENV_VAR} token, or a {base, relative} spec. Resolve {base, relative} by joining relative onto the resolved base. Leave ${ENV_VAR} values verbatim — the harness expands them at runtime (claude's ${CLAUDE_PLUGIN_ROOT}).",
|
|
114
122
|
"PATH unknown base: a {base, relative} spec whose base is not a key in paths is a hard error.",
|
|
115
123
|
"SECTION: {{SECTION:<name>}} is resolved by the agent composer from <agents_dir>/<harness>/<name>.md; token-map.json does not substitute it. Installers must leave SECTION tokens untouched for the composer.",
|