@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.
@@ -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
- if (!upstream.ok) {
186
- reasons.push(`branch ${registered.branch} has no remote-tracking branch`);
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({ path, registered: registered.get(path) }, git);
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 unknown = argv.filter((arg) => arg !== REMOVE_ORPHANS_FLAG);
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) {
@@ -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`; `${ENV_VAR}` values pass through verbatim for runtime expansion.
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.",