@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
|
@@ -5,6 +5,29 @@
|
|
|
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
|
+
// A closed bead's upstream is never the arbiter. Ralph cuts its branch from the
|
|
18
|
+
// remote base (`git worktree add -b feature/<id> … origin/<base>`), and
|
|
19
|
+
// `branch.autoSetupMerge` then makes that base the branch's upstream. A squash
|
|
20
|
+
// merge rewrites the commits, so the branch stays ahead of its own upstream for
|
|
21
|
+
// ever: counting commits against it refuses every landed ralph worktree, which is
|
|
22
|
+
// the one case the content test exists to decide. A closed bead is therefore
|
|
23
|
+
// decided by content alone, whatever its upstream is. An open bead keeps the
|
|
24
|
+
// upstream refusal, because nothing about an open bead is proven.
|
|
25
|
+
//
|
|
26
|
+
// The test proves content, never history. A branch removed here is discarded
|
|
27
|
+
// together with every commit the base does not contain — a reverted change and a
|
|
28
|
+
// run of empty commits both go, and so do their messages. The base is the remote's
|
|
29
|
+
// default branch, never a hardcoded name.
|
|
30
|
+
//
|
|
8
31
|
// Zero dependencies: run with `node prune-worktrees.mjs`.
|
|
9
32
|
|
|
10
33
|
import { execFile } from 'node:child_process';
|
|
@@ -154,15 +177,174 @@ async function classifyBead(id, exec) {
|
|
|
154
177
|
return { classification: status === 'closed' ? 'closed-bead' : 'open-bead', beadStatus: status };
|
|
155
178
|
}
|
|
156
179
|
|
|
180
|
+
/**
|
|
181
|
+
* Resolve the branch a worktree's content must be contained in before its local
|
|
182
|
+
* branch may be dropped. The remote's default branch is the generic answer:
|
|
183
|
+
* `origin/HEAD` is a symbolic ref to whatever the remote calls its default
|
|
184
|
+
* branch, so no branch name is baked in here.
|
|
185
|
+
* @param {string} repoRoot
|
|
186
|
+
* @param {(cwd: string, args: string[]) => Promise<{ ok: true, stdout: string } | { ok: false, stderr: string }>} git
|
|
187
|
+
* @returns {Promise<string | null>} the base ref, or null when git cannot name one.
|
|
188
|
+
*/
|
|
189
|
+
async function resolveBaseRef(repoRoot, git) {
|
|
190
|
+
const result = await git(repoRoot, ['symbolic-ref', '--short', 'refs/remotes/origin/HEAD']);
|
|
191
|
+
if (!result.ok) return null;
|
|
192
|
+
const ref = result.stdout.trim();
|
|
193
|
+
return ref === '' ? null : ref;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Split the output of `git diff --name-only -z` into paths. `-z` is what makes the
|
|
198
|
+
* round trip lossless: without it git C-quotes a path holding a non-ASCII byte, a
|
|
199
|
+
* double quote, a backslash or a newline, and a quoted name fed back as a
|
|
200
|
+
* pathspec names no path. With it, every path arrives raw and NUL-terminated, so
|
|
201
|
+
* a path holding a newline stays one path.
|
|
202
|
+
* @param {string} stdout
|
|
203
|
+
* @returns {string[]}
|
|
204
|
+
*/
|
|
205
|
+
function splitNulPaths(stdout) {
|
|
206
|
+
// The output ends with a NUL, so the split leaves one empty entry to drop. A
|
|
207
|
+
// path is never empty, so no real path is lost with it.
|
|
208
|
+
return stdout.split('\0').filter((entry) => entry !== '');
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* The opening clause of a content-proof refusal. Two situations reach the content
|
|
213
|
+
* check — a closed bead whose branch has lost its upstream, and a closed bead whose
|
|
214
|
+
* branch still tracks the base it was cut from — and both are refused for the same
|
|
215
|
+
* reason: the base does not hold the branch's content. The clause states which
|
|
216
|
+
* situation applies, so a reader can tell an unpublished branch from one whose
|
|
217
|
+
* upstream cannot prove it.
|
|
218
|
+
* @param {string} branch
|
|
219
|
+
* @param {string | null} upstream the branch's remote-tracking ref, or null when it has none.
|
|
220
|
+
* @returns {string}
|
|
221
|
+
*/
|
|
222
|
+
function contentLead(branch, upstream) {
|
|
223
|
+
return upstream === null
|
|
224
|
+
? `branch ${branch} has no remote-tracking branch`
|
|
225
|
+
: `branch ${branch} is not proven by ${upstream}`;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* True when `ref` names the worktree's own branch, locally or as its
|
|
230
|
+
* remote-tracking ref. Comparing such a ref with the branch is a self-comparison:
|
|
231
|
+
* the diff is empty whatever the branch holds, so it proves nothing.
|
|
232
|
+
* @param {string} path
|
|
233
|
+
* @param {string} ref
|
|
234
|
+
* @param {string} branch
|
|
235
|
+
* @param {(cwd: string, args: string[]) => Promise<{ ok: true, stdout: string } | { ok: false, stderr: string }>} git
|
|
236
|
+
* @returns {Promise<boolean>}
|
|
237
|
+
*/
|
|
238
|
+
async function namesOwnBranch(path, ref, branch, git) {
|
|
239
|
+
const resolved = await git(path, ['rev-parse', '--symbolic-full-name', ref]);
|
|
240
|
+
if (!resolved.ok) return false;
|
|
241
|
+
const name = resolved.stdout.trim();
|
|
242
|
+
if (name === `refs/heads/${branch}`) return true;
|
|
243
|
+
// `refs/remotes/<remote>/<branch>`, whatever the remote is called.
|
|
244
|
+
return name.startsWith('refs/remotes/') && name.endsWith(`/${branch}`);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Decide whether a closed bead's branch still holds work. Removing such a branch is
|
|
249
|
+
* only safe when the base holds the branch's own version of every path the branch
|
|
250
|
+
* changed — the squash-merge case. Anything unprovable keeps the worktree.
|
|
251
|
+
*
|
|
252
|
+
* One-sided containment, in three steps: take the merge base of the base and the
|
|
253
|
+
* branch, list the paths the branch changed since that merge base, then require the
|
|
254
|
+
* base to agree with the branch on those paths alone. Paths only the base added or
|
|
255
|
+
* changed lie outside that set and are ignored, so a base that advanced after the
|
|
256
|
+
* branch was cut still proves the branch landed.
|
|
257
|
+
*
|
|
258
|
+
* A status filter on the two-sided diff would be simpler and is wrong: `D` is the
|
|
259
|
+
* same status whether the branch deleted a path or the base never had it, so a
|
|
260
|
+
* filter cannot tell a deletion the base already carries from one it does not.
|
|
261
|
+
*
|
|
262
|
+
* Restricting to the branch's own paths is what makes an overlap detectable: when
|
|
263
|
+
* the base later changed a path the branch also changed, the two versions differ
|
|
264
|
+
* and the branch is not proven landed. A deletion on both sides agrees, so it is
|
|
265
|
+
* proven; a deletion on one side only is not.
|
|
266
|
+
*
|
|
267
|
+
* The branch's changed paths are read from git and then handed straight back to a
|
|
268
|
+
* second git call as pathspecs, so that round trip has to be exact. Two escapes
|
|
269
|
+
* make it inexact and both end in a false "proven": a path git would C-quote
|
|
270
|
+
* prints as a quoted string that names no path, and a path starting with `:` is
|
|
271
|
+
* read as pathspec magic instead of a name. Neither alone is sufficient to fix,
|
|
272
|
+
* so the first diff reads with `-z` and the second is run with
|
|
273
|
+
* `--literal-pathspecs`.
|
|
274
|
+
* @param {{ path: string, branch: string, beadClosed: boolean, upstream: string | null }} target
|
|
275
|
+
* `upstream` is the branch's remote-tracking ref, or null when it has none.
|
|
276
|
+
* @param {string | null} baseRef
|
|
277
|
+
* @param {(cwd: string, args: string[]) => Promise<{ ok: true, stdout: string } | { ok: false, stderr: string }>} git
|
|
278
|
+
* @returns {Promise<string | null>} a refusal reason, or null when nothing is lost.
|
|
279
|
+
*/
|
|
280
|
+
async function findLostWork({ path, branch, beadClosed, upstream }, baseRef, git) {
|
|
281
|
+
if (!beadClosed) {
|
|
282
|
+
return `branch ${branch} has no remote-tracking branch and its bead is not closed`;
|
|
283
|
+
}
|
|
284
|
+
/** @param {string} clause @returns {string} */
|
|
285
|
+
const refuse = (clause) => `${contentLead(branch, upstream)}: ${clause}`;
|
|
286
|
+
if (baseRef === null) {
|
|
287
|
+
return refuse('the base branch cannot be resolved, so its content cannot be proven landed');
|
|
288
|
+
}
|
|
289
|
+
const tip = await git(path, ['rev-parse', '--verify', 'HEAD']);
|
|
290
|
+
if (!tip.ok) {
|
|
291
|
+
const detail = tip.stderr.trim();
|
|
292
|
+
const suffix = detail === '' ? '' : `: ${detail}`;
|
|
293
|
+
return refuse(`its tip cannot be read${suffix}`);
|
|
294
|
+
}
|
|
295
|
+
const baseTip = await git(path, ['rev-parse', '--verify', '--quiet', `${baseRef}^{commit}`]);
|
|
296
|
+
if (!baseTip.ok) {
|
|
297
|
+
const detail = baseTip.stderr.trim();
|
|
298
|
+
const suffix = detail === '' ? '' : `: ${detail}`;
|
|
299
|
+
return refuse(`${baseRef} cannot be compared with it${suffix}`);
|
|
300
|
+
}
|
|
301
|
+
// A base that names the branch being deleted compares the branch with itself,
|
|
302
|
+
// whatever the branch holds. The test is on the ref, not on the tip: a branch cut
|
|
303
|
+
// from the base and never committed to holds the base's own commit, and that is a
|
|
304
|
+
// real proof — it has no content of its own to lose.
|
|
305
|
+
if (await namesOwnBranch(path, baseRef, branch, git)) {
|
|
306
|
+
return refuse(`${baseRef} names this branch, so the comparison would prove nothing`);
|
|
307
|
+
}
|
|
308
|
+
const mergeBase = await git(path, ['merge-base', baseRef, 'HEAD']);
|
|
309
|
+
if (!mergeBase.ok) {
|
|
310
|
+
const detail = mergeBase.stderr.trim();
|
|
311
|
+
const suffix = detail === '' ? '' : `: ${detail}`;
|
|
312
|
+
return refuse(`${baseRef} cannot be compared with it${suffix}`);
|
|
313
|
+
}
|
|
314
|
+
const touched = await git(path, ['diff', '--name-only', '-z', '--no-renames', mergeBase.stdout.trim(), 'HEAD', '--']);
|
|
315
|
+
if (!touched.ok) {
|
|
316
|
+
const detail = touched.stderr.trim();
|
|
317
|
+
const suffix = detail === '' ? '' : `: ${detail}`;
|
|
318
|
+
return refuse(`${baseRef} cannot be compared with it${suffix}`);
|
|
319
|
+
}
|
|
320
|
+
const changedPaths = splitNulPaths(touched.stdout);
|
|
321
|
+
// Nothing but empty commits: no content to prove, and no content to lose.
|
|
322
|
+
if (changedPaths.length === 0) return null;
|
|
323
|
+
// `--literal-pathspecs` is a git global option, so it precedes the subcommand.
|
|
324
|
+
// It keeps a leading `:` in a real filename from being read as pathspec magic.
|
|
325
|
+
const unproven = await git(path, ['--literal-pathspecs', 'diff', '--name-only', '-z', '--no-renames', 'HEAD', baseRef, '--', ...changedPaths]);
|
|
326
|
+
if (!unproven.ok) {
|
|
327
|
+
const detail = unproven.stderr.trim();
|
|
328
|
+
const suffix = detail === '' ? '' : `: ${detail}`;
|
|
329
|
+
return refuse(`${baseRef} cannot be compared with it${suffix}`);
|
|
330
|
+
}
|
|
331
|
+
const unprovenPaths = splitNulPaths(unproven.stdout).length;
|
|
332
|
+
if (unprovenPaths > 0) {
|
|
333
|
+
return refuse(`${baseRef} does not hold this branch's version of ${unprovenPaths} path(s), so its content is not landed`);
|
|
334
|
+
}
|
|
335
|
+
return null;
|
|
336
|
+
}
|
|
337
|
+
|
|
157
338
|
/**
|
|
158
339
|
* Reasons git refuses to tear a worktree down. A worktree removed anyway would
|
|
159
340
|
* throw away uncommitted work or an unpushed branch, so every reason keeps the
|
|
160
341
|
* worktree in place.
|
|
161
|
-
* @param {{ path: string, registered: { branch: string | null, locked: string | null } | undefined }} target
|
|
342
|
+
* @param {{ path: string, registered: { branch: string | null, locked: string | null } | undefined, beadClosed: boolean }} target
|
|
162
343
|
* @param {(cwd: string, args: string[]) => Promise<{ ok: true, stdout: string } | { ok: false, stderr: string }>} git
|
|
344
|
+
* @param {string | null} baseRef
|
|
163
345
|
* @returns {Promise<{ branch: string | null, reasons: string[] }>}
|
|
164
346
|
*/
|
|
165
|
-
async function findRefusals({ path, registered }, git) {
|
|
347
|
+
async function findRefusals({ path, registered, beadClosed }, git, baseRef) {
|
|
166
348
|
/** @type {string[]} */
|
|
167
349
|
const reasons = [];
|
|
168
350
|
|
|
@@ -182,11 +364,18 @@ async function findRefusals({ path, registered }, git) {
|
|
|
182
364
|
if (dirtyPaths > 0) reasons.push(`worktree is dirty: ${dirtyPaths} uncommitted path(s)`);
|
|
183
365
|
|
|
184
366
|
const upstream = await git(path, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}']);
|
|
185
|
-
|
|
186
|
-
|
|
367
|
+
const trackingRef = upstream.ok ? upstream.stdout.trim() : null;
|
|
368
|
+
|
|
369
|
+
// A closed bead is decided by content alone, never by its upstream: a ralph
|
|
370
|
+
// branch is cut from the remote base, so it tracks that base and stays ahead of
|
|
371
|
+
// it after a squash merge rewrites its commits. Counting commits against such an
|
|
372
|
+
// upstream refuses every landed ralph worktree. An open bead with no upstream has
|
|
373
|
+
// nothing to prove, so it lands here too and refuses below.
|
|
374
|
+
if (beadClosed || trackingRef === null) {
|
|
375
|
+
const lost = await findLostWork({ path, branch: registered.branch, beadClosed, upstream: trackingRef }, baseRef, git);
|
|
376
|
+
if (lost !== null) reasons.push(lost);
|
|
187
377
|
return { branch: registered.branch, reasons };
|
|
188
378
|
}
|
|
189
|
-
const trackingRef = upstream.stdout.trim();
|
|
190
379
|
const unpushed = await git(path, ['rev-list', '--count', `${trackingRef}..HEAD`]);
|
|
191
380
|
if (!unpushed.ok) return { branch: registered.branch, reasons: [...reasons, `branch ${registered.branch} cannot be compared with ${trackingRef}: ${unpushed.stderr.trim()}`] };
|
|
192
381
|
const unpushedCommits = Number.parseInt(unpushed.stdout.trim(), 10);
|
|
@@ -231,12 +420,14 @@ async function removeWorktree(repoRoot, { path, branch }, git) {
|
|
|
231
420
|
* @param {object} options
|
|
232
421
|
* @param {string} options.repoRoot Repository to prune.
|
|
233
422
|
* @param {boolean} [options.removeOrphans] Also remove worktrees whose bead is open or missing.
|
|
423
|
+
* @param {string} [options.base] Ref the content check compares against; defaults to the remote's default branch.
|
|
234
424
|
* @param {(args: string[]) => Promise<string>} [options.exec] bd runner; injectable for tests.
|
|
235
425
|
* @param {(cwd: string, args: string[]) => Promise<{ ok: true, stdout: string } | { ok: false, stderr: string }>} [options.git] git runner; injectable for tests.
|
|
236
426
|
* @returns {Promise<PruneResult>}
|
|
237
427
|
*/
|
|
238
|
-
export async function pruneWorktrees({ repoRoot, exec = runBd, removeOrphans = false, git = runGit }) {
|
|
428
|
+
export async function pruneWorktrees({ repoRoot, exec = runBd, removeOrphans = false, git = runGit, base }) {
|
|
239
429
|
const registered = await readRegisteredWorktrees(repoRoot);
|
|
430
|
+
const baseRef = base ?? (await resolveBaseRef(repoRoot, git));
|
|
240
431
|
/** @type {WorktreeEntry[]} */
|
|
241
432
|
const entries = [];
|
|
242
433
|
|
|
@@ -252,7 +443,11 @@ export async function pruneWorktrees({ repoRoot, exec = runBd, removeOrphans = f
|
|
|
252
443
|
// an unreadable bead is never removed at all.
|
|
253
444
|
const wanted = classification === 'closed-bead' || (classification !== 'unclassified' && removeOrphans);
|
|
254
445
|
if (wanted) {
|
|
255
|
-
const { branch, reasons } = await findRefusals(
|
|
446
|
+
const { branch, reasons } = await findRefusals(
|
|
447
|
+
{ path, registered: registered.get(path), beadClosed: classification === 'closed-bead' },
|
|
448
|
+
git,
|
|
449
|
+
baseRef,
|
|
450
|
+
);
|
|
256
451
|
entry.branch = branch;
|
|
257
452
|
if (reasons.length > 0) {
|
|
258
453
|
entry.outcome = 'refused';
|
|
@@ -275,19 +470,61 @@ export async function pruneWorktrees({ repoRoot, exec = runBd, removeOrphans = f
|
|
|
275
470
|
|
|
276
471
|
// ─── CLI ──────────────────────────────────────────────────────────────────────
|
|
277
472
|
|
|
278
|
-
const USAGE = `Usage: node prune-worktrees.mjs [--remove-orphans]
|
|
473
|
+
const USAGE = `Usage: node prune-worktrees.mjs [--remove-orphans] [--base <ref>]
|
|
279
474
|
|
|
280
475
|
Removes worktrees under .agent-cortex/worktrees whose bead is closed, then
|
|
281
476
|
reports the rest. A worktree whose bead is open or missing is an orphan: it is
|
|
282
477
|
only removed when --remove-orphans is passed. Run from anywhere inside the
|
|
283
478
|
repository.
|
|
479
|
+
|
|
480
|
+
A closed bead's branch is proven by content alone, whatever its upstream is: many
|
|
481
|
+
merge methods delete the remote branch when the work lands, and a branch cut from
|
|
482
|
+
the remote base tracks that base, which a squash merge leaves behind. Such a branch
|
|
483
|
+
is removed only when the base holds the branch's own version of every path the
|
|
484
|
+
branch changed since their merge base. That proves content and never history:
|
|
485
|
+
removal discards every commit the base does not contain, such as a reverted change
|
|
486
|
+
or an empty commit, together with its message. The base defaults to the remote's
|
|
487
|
+
default branch (origin/HEAD); --base names another ref, such as a release branch. A
|
|
488
|
+
base that names the worktree's own branch compares the branch with itself, so it
|
|
489
|
+
proves nothing: the worktree is kept and refused, as it is when no base resolves.
|
|
490
|
+
|
|
491
|
+
An open bead is proven by its upstream instead, and only with --remove-orphans: its
|
|
492
|
+
branch must carry an upstream and no commit unpushed to it. With no upstream it is
|
|
493
|
+
refused whatever its content says.
|
|
284
494
|
`;
|
|
285
495
|
|
|
286
496
|
/** Every flag the CLI accepts. */
|
|
287
497
|
const REMOVE_ORPHANS_FLAG = '--remove-orphans';
|
|
498
|
+
const BASE_FLAG = '--base';
|
|
288
499
|
|
|
289
500
|
class UsageError extends Error {}
|
|
290
501
|
|
|
502
|
+
/**
|
|
503
|
+
* @param {string[]} argv
|
|
504
|
+
* @returns {{ removeOrphans: boolean, base: string | undefined }}
|
|
505
|
+
*/
|
|
506
|
+
function parseArgs(argv) {
|
|
507
|
+
let removeOrphans = false;
|
|
508
|
+
/** @type {string | undefined} */
|
|
509
|
+
let base;
|
|
510
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
511
|
+
const arg = argv[index];
|
|
512
|
+
if (arg === REMOVE_ORPHANS_FLAG) {
|
|
513
|
+
removeOrphans = true;
|
|
514
|
+
continue;
|
|
515
|
+
}
|
|
516
|
+
if (arg === BASE_FLAG) {
|
|
517
|
+
const value = argv[index + 1];
|
|
518
|
+
if (value === undefined || value.startsWith('-')) throw new UsageError(`${BASE_FLAG} needs a git ref`);
|
|
519
|
+
base = value;
|
|
520
|
+
index += 1;
|
|
521
|
+
continue;
|
|
522
|
+
}
|
|
523
|
+
throw new UsageError(`unexpected argument: ${arg}`);
|
|
524
|
+
}
|
|
525
|
+
return { removeOrphans, base };
|
|
526
|
+
}
|
|
527
|
+
|
|
291
528
|
/**
|
|
292
529
|
* Resolve the repository root from any directory inside it — the main checkout
|
|
293
530
|
* or a linked worktree. The shared `.git` directory is one level below the root
|
|
@@ -313,14 +550,12 @@ export async function runCli({ argv, cwd = process.cwd(), exec = runBd, git = ru
|
|
|
313
550
|
write(USAGE);
|
|
314
551
|
return 0;
|
|
315
552
|
}
|
|
316
|
-
const
|
|
317
|
-
if (unknown.length > 0) throw new UsageError(`unexpected argument: ${unknown[0]}`);
|
|
318
|
-
const removeOrphans = argv.includes(REMOVE_ORPHANS_FLAG);
|
|
553
|
+
const { removeOrphans, base } = parseArgs(argv);
|
|
319
554
|
|
|
320
555
|
const repoRoot = await resolveRepoRoot(cwd);
|
|
321
556
|
if (repoRoot === null) throw new Error('not inside a git repository');
|
|
322
557
|
|
|
323
|
-
const { entries, exitCode } = await pruneWorktrees({ repoRoot, exec, removeOrphans, git });
|
|
558
|
+
const { entries, exitCode } = await pruneWorktrees({ repoRoot, exec, removeOrphans, git, base });
|
|
324
559
|
|
|
325
560
|
write(`Scanning ${join(repoRoot, '.agent-cortex', 'worktrees')}\n`);
|
|
326
561
|
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.",
|