@jaybeeuu/agent-cortex 2.5.1 → 2.7.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.
@@ -0,0 +1,190 @@
1
+ // The read side of the installers' fingerprints: `ctx version` reports which
2
+ // harnesses are installed and how their version compares with the running CLI.
3
+ //
4
+ // Read-only by construction — the module only stats and reads, so the command
5
+ // path never writes, installs, or reaches the network. A harness with no
6
+ // installer gets no entry in the table, so it can never appear in the report.
7
+
8
+ import { readFile } from "node:fs/promises";
9
+ import { homedir } from "node:os";
10
+ import { join } from "node:path";
11
+ import { isDirectory } from "../scripts/lib/fs.mjs";
12
+
13
+ /**
14
+ * Where one harness records the version it was installed at: an artifact root
15
+ * under the home install directory, and the version file inside it.
16
+ * @typedef {object} HarnessFingerprint
17
+ * @property {"claude" | "pi"} harness
18
+ * @property {string[]} root Path segments of the artifact root, relative to home
19
+ * @property {string[]} file Path segments of the version file, relative to the root
20
+ */
21
+
22
+ // Ordered as the installers are listed; the report keeps this order.
23
+ /** @type {HarnessFingerprint[]} */
24
+ const HARNESS_FINGERPRINTS = [
25
+ { harness: "claude", root: [".agent-cortex", "claude"], file: [".claude-plugin", "plugin.json"] },
26
+ { harness: "pi", root: [".agent-cortex", "pi"], file: ["package.json"] },
27
+ ];
28
+
29
+ /**
30
+ * How an installed copy relates to the running CLI. `unknown` covers both a
31
+ * fingerprint that cannot be read and a version that is not a plain release —
32
+ * an unreadable install is never reported as `current`. `missing` means the
33
+ * harness has no artifact root at all: it was never installed.
34
+ * @typedef {"current" | "stale" | "ahead" | "unknown" | "missing"} Staleness
35
+ */
36
+
37
+ /**
38
+ * One installed harness in the report.
39
+ * @typedef {object} HarnessVersion
40
+ * @property {string} harness
41
+ * @property {string} installed The installed version, or "unknown"
42
+ * @property {string} cli The running CLI version
43
+ * @property {Staleness} staleness
44
+ */
45
+
46
+ /**
47
+ * Everything the `version` command reports.
48
+ * @typedef {object} VersionReport
49
+ * @property {string} cli The running CLI version
50
+ * @property {HarnessVersion[]} harnesses One entry per installed harness
51
+ */
52
+
53
+ const UNKNOWN = "unknown";
54
+
55
+ // Only plain releases are compared. A prerelease or a range is not "current"
56
+ // just because its numeric core matches, so it reports unknown instead.
57
+ const PLAIN_RELEASE = /^(\d+)\.(\d+)\.(\d+)$/;
58
+
59
+ /**
60
+ * Compare two plain semver releases.
61
+ * @param {string} a
62
+ * @param {string} b
63
+ * @returns {-1 | 0 | 1 | null} The order, or null when either side is not a plain release
64
+ */
65
+ function compareReleases(a, b) {
66
+ const left = PLAIN_RELEASE.exec(a);
67
+ const right = PLAIN_RELEASE.exec(b);
68
+ if (left === null || right === null) return null;
69
+ for (let segment = 1; segment <= 3; segment++) {
70
+ const l = Number(left[segment]);
71
+ const r = Number(right[segment]);
72
+ if (l !== r) return l < r ? -1 : 1;
73
+ }
74
+ return 0;
75
+ }
76
+
77
+ /**
78
+ * @param {string | null} installed null when the fingerprint is unreadable
79
+ * @param {string} cli
80
+ * @returns {Staleness}
81
+ */
82
+ function stalenessOf(installed, cli) {
83
+ if (installed === null) return "unknown";
84
+ const order = compareReleases(installed, cli);
85
+ if (order === null) return "unknown";
86
+ if (order < 0) return "stale";
87
+ return order > 0 ? "ahead" : "current";
88
+ }
89
+
90
+ /**
91
+ * Read the version one harness recorded at install time.
92
+ * @param {string} home
93
+ * @param {HarnessFingerprint} fingerprint
94
+ * @returns {Promise<{ installed: boolean, version: string | null }>} `installed`
95
+ * is false when the artifact root is absent; `version` is null when the
96
+ * fingerprint is missing, malformed, or carries no version string
97
+ */
98
+ async function readInstalledVersion(home, fingerprint) {
99
+ const root = join(home, ...fingerprint.root);
100
+ if (!(await isDirectory(root))) return { installed: false, version: null };
101
+ try {
102
+ /** @type {{ version?: unknown }} */
103
+ const manifest = JSON.parse(await readFile(join(root, ...fingerprint.file), "utf-8"));
104
+ const version = manifest?.version;
105
+ return { installed: true, version: typeof version === "string" && version !== "" ? version : null };
106
+ } catch {
107
+ return { installed: true, version: null };
108
+ }
109
+ }
110
+
111
+ /**
112
+ * One harness in the update table: unlike a report entry, every supported
113
+ * harness appears, so an absent install shows as `installed: null` / `missing`.
114
+ * @typedef {object} HarnessStatus
115
+ * @property {string} harness
116
+ * @property {string | null} installed The installed version, or null when the harness is not installed
117
+ * @property {string} cli The running CLI version
118
+ * @property {Staleness} staleness
119
+ */
120
+
121
+ /**
122
+ * Report the running CLI version and the installed version of every harness
123
+ * whose artifact root exists.
124
+ * @param {{ cli: string, home?: string }} options `home` defaults to the real
125
+ * home directory; tests pass a fixture directory
126
+ * @returns {Promise<VersionReport>}
127
+ */
128
+ export async function collectVersionReport({ cli, home = homedir() }) {
129
+ /** @type {HarnessVersion[]} */
130
+ const harnesses = [];
131
+ for (const fingerprint of HARNESS_FINGERPRINTS) {
132
+ const { installed, version } = await readInstalledVersion(home, fingerprint);
133
+ if (!installed) continue;
134
+ harnesses.push({
135
+ harness: fingerprint.harness,
136
+ installed: version ?? UNKNOWN,
137
+ cli,
138
+ staleness: stalenessOf(version, cli),
139
+ });
140
+ }
141
+ return { cli, harnesses };
142
+ }
143
+
144
+ /**
145
+ * Read the status of every harness, including the ones that are not installed:
146
+ * `ctx update` selects from this full list, so a harness with no artifact root
147
+ * is reported as missing rather than omitted.
148
+ * @param {{ cli: string, home?: string }} options `home` defaults to the real
149
+ * home directory; tests pass a fixture directory
150
+ * @returns {Promise<HarnessStatus[]>}
151
+ */
152
+ export async function collectHarnessStatus({ cli, home = homedir() }) {
153
+ /** @type {HarnessStatus[]} */
154
+ const statuses = [];
155
+ for (const fingerprint of HARNESS_FINGERPRINTS) {
156
+ const { installed, version } = await readInstalledVersion(home, fingerprint);
157
+ statuses.push({
158
+ harness: fingerprint.harness,
159
+ installed: version,
160
+ cli,
161
+ staleness: installed ? stalenessOf(version, cli) : "missing",
162
+ });
163
+ }
164
+ return statuses;
165
+ }
166
+
167
+ /**
168
+ * Render the report as the default, human-readable output: the CLI version on
169
+ * the first line, then one line per installed harness. A harness whose version
170
+ * could not be read prints `unknown` twice — once for the version and once for
171
+ * the marker, because neither is known.
172
+ * @param {VersionReport} report
173
+ * @returns {string} Text without a trailing newline
174
+ */
175
+ export function formatVersionReport(report) {
176
+ const lines = [report.cli];
177
+ for (const entry of report.harnesses) {
178
+ lines.push(`${entry.harness} ${entry.installed} ${entry.staleness}`);
179
+ }
180
+ return lines.join("\n");
181
+ }
182
+
183
+ /**
184
+ * Render the report as JSON, one record per installed harness.
185
+ * @param {VersionReport} report
186
+ * @returns {string} JSON without a trailing newline
187
+ */
188
+ export function serialiseVersionReport(report) {
189
+ return JSON.stringify(report, null, 2);
190
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jaybeeuu/agent-cortex",
3
- "version": "2.5.1",
3
+ "version": "2.7.0",
4
4
  "description": "Personal PI package with custom agents, skills, and extensions",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -21,6 +21,7 @@
21
21
  "scripts/",
22
22
  "skills/",
23
23
  "agents/",
24
+ "docs/skills/skill-anatomy.md",
24
25
  "extensions/*.ts",
25
26
  "extensions/lib/",
26
27
  "extensions/*/*.ts",
@@ -0,0 +1,67 @@
1
+ // Documentation a staged install ships.
2
+ //
3
+ // Skills are installed onto machines that have no copy of this checkout, so a
4
+ // skill that reads a repository document — write-a-skill and refactor-skill both
5
+ // read docs/skills/skill-anatomy.md — needs that document inside the install.
6
+ // The list is derived from token-map.json rather than declared again here: a
7
+ // `paths` entry whose relative path sits under `docs/` is a document skills
8
+ // reference by install-relative path, so the token map stays the single source
9
+ // of where the install puts it.
10
+ //
11
+ // The copies are verbatim, and no token substitution runs over them. A shipped doc
12
+ // therefore must not print a substitution token: one that needs to teach the
13
+ // syntax describes the wrapper in words (see the tool-reference section of the
14
+ // anatomy guide). test/installed-docs.test.mjs fails when a shipped doc carries
15
+ // one, because the materialised plugin must contain no literal token.
16
+ //
17
+ // Zero dependencies so it runs on the CI Node (20) and local Node alike.
18
+
19
+ import { copyFile, mkdir } from "node:fs/promises";
20
+ import { dirname, join } from "node:path";
21
+ import { isFile } from "./fs.mjs";
22
+
23
+ /**
24
+ * Package-relative paths of the docs an install ships, sorted.
25
+ * @param {import("./compose-agent.mjs").TokenMap} tokenMap
26
+ * @returns {string[]}
27
+ */
28
+ export function shippedDocs(tokenMap) {
29
+ const docs = new Set();
30
+ for (const entry of Object.values(tokenMap.paths ?? {})) {
31
+ if (entry === null || typeof entry !== "object") continue;
32
+ for (const spec of Object.values(entry)) {
33
+ if (spec === null || typeof spec !== "object") continue;
34
+ const { relative } = /** @type {{ relative?: unknown }} */ (spec);
35
+ if (typeof relative === "string" && relative.startsWith("docs/")) docs.add(relative);
36
+ }
37
+ }
38
+ return [...docs].sort();
39
+ }
40
+
41
+ /**
42
+ * Copy every shipped doc from the package root into the staged tree, at the same
43
+ * relative path. A missing source is a hard error: the token map points a skill
44
+ * at a document the install would otherwise not contain.
45
+ * @param {{ root: string, output: string, dryRun?: boolean,
46
+ * tokenMap: import("./compose-agent.mjs").TokenMap }} options
47
+ * @returns {Promise<{ files: string[], dir: string }>}
48
+ */
49
+ export async function installDocs({ root, output, dryRun = false, tokenMap }) {
50
+ /** @type {string[]} */
51
+ const files = [];
52
+ for (const relative of shippedDocs(tokenMap)) {
53
+ const from = join(root, relative);
54
+ if (!(await isFile(from))) {
55
+ throw new Error(
56
+ `token-map.json points a skill at ${relative}, but ${from} does not exist — ship the document or drop the path entry`,
57
+ );
58
+ }
59
+ if (!dryRun) {
60
+ const to = join(output, relative);
61
+ await mkdir(dirname(to), { recursive: true });
62
+ await copyFile(from, to);
63
+ }
64
+ files.push(relative);
65
+ }
66
+ return { files, dir: join(output, "docs") };
67
+ }
@@ -16,12 +16,19 @@ node <skill-scripts>/create-chores.mjs --parent <bead-id> [--priority <0-4>]
16
16
 
17
17
  ## Stage chore beads
18
18
 
19
- One per stage in `pipeline.json` (currently `code`, `verify`, `review`, `document`):
19
+ One per stage in `pipeline.json` (currently `code`, `verify`, `review`, `document`, `housekeeping`):
20
20
 
21
21
  - **Type**: `chore`
22
22
  - **Title**: `[<parent-id>] <stage title>` (e.g. `[abc-123] Code`)
23
- - **Labels**: `stage:<id>` (e.g. `stage:code`, `stage:verify`)
24
- - **Dependencies**: `parent-child` to the parent bead; `blocks` between stages per each stage's `dependsOn` (the script requires `dependsOn` targets to appear earlier in `pipeline.json`).
23
+ - **Labels**: `stage:<id>` (e.g. `stage:code`, `stage:verify`), plus any `labels` the stage declares — `housekeeping` adds `lifecycle:housekeeping`
24
+ - **Dependencies**: `parent-child` to the parent bead; `blocks` between stages per each stage's `dependsOn` (the script requires `dependsOn` targets to appear earlier in `pipeline.json`)
25
+
26
+ **A stage may wait on the gate instead of a stage.** The reserved `dependsOn` target
27
+ `featurePrReview` means "blocked behind the PR gate bead". The gate is not a stage — it is created
28
+ after them — so the script wires those stages to it in a second pass, once the gate exists. The
29
+ `housekeeping` stage uses it: its chore must not become ready until a human has merged the PR and
30
+ closed the gate, and the `blocks` edge keeps it out of `bd ready` while the feature is in flight.
31
+ The gate itself still waits on the last stage that does not wait on the gate (`document`).
25
32
 
26
33
  ## PR gate bead (`featurePrReview`)
27
34
 
@@ -87,6 +94,7 @@ The deliverable this feature produces and the problem it solves.
87
94
  | verify | Verify | code |
88
95
  | review | Review | verify |
89
96
  | document | Document | review |
97
+ | housekeeping | Housekeeping | `featurePrReview` — the PR gate bead, not a stage |
90
98
 
91
99
  `maxFixRounds: 4` — the maximum fix-retry rounds allowed for a stage's verify loop before the PR gate. Adjust stages only in `pipeline.json`; the script and templates follow it.
92
100
 
@@ -141,7 +141,7 @@ Steps 2–6 create beads; step 7 expands one. A bead that already exists but has
141
141
  Input: title `Add export API to invoices`, description `…`, priority `2`, parent `feature-456`. Output: the bead ID plus the script's JSON mapping stage IDs → chore bead IDs, with `featurePrReview` as the HITL PR gate:
142
142
 
143
143
  ```json
144
- {"code": "abc123", "verify": "def456", "review": "ghi789", "document": "jkl012", "featurePrReview": "bd-mno345"}
144
+ {"code": "abc123", "verify": "def456", "review": "ghi789", "document": "jkl012", "housekeeping": "mno345", "featurePrReview": "bd-pqr678"}
145
145
  ```
146
146
 
147
147
  Feature path input: "several endpoints, a UI, and a migration". Output: `feature-456` (`execution:hitl`), `plan-457` ("Plan: …", `execution:hitl`, owned by the feature, blocking every child), and one `execution:afk` impl child per deliverable, each expanded into its own stage chores plus PR gate.
@@ -28,6 +28,14 @@
28
28
  "description": "Review changes and update documentation if key decisions, behaviour changes, or constraints were introduced.",
29
29
  "template": "templates/document.md",
30
30
  "dependsOn": ["review"]
31
+ },
32
+ {
33
+ "id": "housekeeping",
34
+ "title": "Housekeeping",
35
+ "description": "Remove the worktrees of closed beads. Run `node <ralph-scripts>/prune-worktrees.mjs` from the main checkout and retry once if it exits non-zero. The exit status is 1 whenever any worktree survives — an open bead counts as survival — so fail this chore only when a closed-bead worktree is still refused after the retry, and leave it open. Never close it over a worktree the script did not remove.",
36
+ "template": "templates/housekeeping.md",
37
+ "labels": ["lifecycle:housekeeping"],
38
+ "dependsOn": ["featurePrReview"]
31
39
  }
32
40
  ]
33
41
  }
@@ -27,6 +27,7 @@ import { fileURLToPath } from 'node:url';
27
27
  * @property {string} description
28
28
  * @property {string} template
29
29
  * @property {string[]} dependsOn
30
+ * @property {string[]} [labels] Labels beside `stage:<id>`; e.g. `lifecycle:housekeeping`
30
31
  */
31
32
 
32
33
  /**
@@ -136,6 +137,10 @@ function wireBlocks(beadId, dependsOnId, blocks) {
136
137
 
137
138
  // ─── Main ─────────────────────────────────────────────────────────────────────
138
139
 
140
+ // Reserved `dependsOn` target: the PR gate bead. The gate is not a stage — it is
141
+ // created after them — so a stage that waits on it is wired in a second pass.
142
+ const GATE_ROLE = 'featurePrReview';
143
+
139
144
  const __filename = fileURLToPath(import.meta.url);
140
145
  const __dirname = dirname(__filename);
141
146
 
@@ -150,6 +155,10 @@ const adoptable = readAdoptable(parentId);
150
155
  /** @type {Map<string, string>} */
151
156
  const stageToBeadId = new Map();
152
157
 
158
+ /** Stages that wait on the gate rather than on another stage, held until it exists.
159
+ * @type {Array<{ beadId: string, blocks: Set<string> }>} */
160
+ const gateDependents = [];
161
+
153
162
  for (const stage of pipeline.stages) {
154
163
  const adopted = adoptable.get(`stage:${stage.id}`);
155
164
 
@@ -160,7 +169,7 @@ for (const stage of pipeline.stages) {
160
169
  '--type', 'chore',
161
170
  '--description', JSON.stringify(stage.description),
162
171
  '--priority', priority,
163
- '--labels', `stage:${stage.id}`,
172
+ '--labels', [`stage:${stage.id}`, ...(stage.labels ?? [])].join(','),
164
173
  '--parent', parentId,
165
174
  '--silent',
166
175
  ]);
@@ -171,6 +180,7 @@ for (const stage of pipeline.stages) {
171
180
  const blocks = adopted ? adopted.blocks : new Set();
172
181
 
173
182
  for (const depStageId of stage.dependsOn) {
183
+ if (depStageId === GATE_ROLE) { continue; }
174
184
  const depBeadId = stageToBeadId.get(depStageId);
175
185
  if (!depBeadId) {
176
186
  console.error(`Stage '${stage.id}' depends on '${depStageId}', but it has not been created yet. Check dependsOn ordering in pipeline.json.`);
@@ -178,15 +188,17 @@ for (const stage of pipeline.stages) {
178
188
  }
179
189
  wireBlocks(beadId, depBeadId, blocks);
180
190
  }
181
- }
182
191
 
183
- const finalStageId = pipeline.stages.at(-1)?.id;
184
- if (!finalStageId) {
185
- console.error('No pipeline stages found in pipeline.json.');
186
- process.exit(1);
192
+ if (stage.dependsOn.includes(GATE_ROLE)) { gateDependents.push({ beadId, blocks }); }
187
193
  }
188
194
 
189
- const documentStageBeadId = stageToBeadId.get('document') ?? stageToBeadId.get(finalStageId);
195
+ // The gate waits on the last stage that does not itself wait on the gate, so the
196
+ // chain stays acyclic once a stage can follow the gate.
197
+ const chainStageIds = pipeline.stages
198
+ .filter((stage) => !stage.dependsOn.includes(GATE_ROLE))
199
+ .map((stage) => stage.id);
200
+
201
+ const documentStageBeadId = stageToBeadId.get('document') ?? stageToBeadId.get(chainStageIds.at(-1) ?? '');
190
202
  if (!documentStageBeadId) {
191
203
  console.error('Could not resolve the final stage bead needed for PR gate dependency.');
192
204
  process.exit(1);
@@ -220,6 +232,12 @@ const prGateBeadId = adoptedGate
220
232
  const gateBlocks = adoptedGate ? adoptedGate.blocks : new Set();
221
233
  wireBlocks(prGateBeadId, documentStageBeadId, gateBlocks);
222
234
 
235
+ // Second pass: the stages that wait on the gate finally get their dep, now that the
236
+ // gate bead exists.
237
+ for (const { beadId, blocks } of gateDependents) {
238
+ wireBlocks(beadId, prGateBeadId, blocks);
239
+ }
240
+
223
241
  /** @type {Record<string, string>} */
224
242
  const result = {
225
243
  ...Object.fromEntries(stageToBeadId),
@@ -0,0 +1,65 @@
1
+ ## Project context
2
+ <bd prime output — verbatim>
3
+
4
+ ## Task
5
+ <bd show <id> output — verbatim>
6
+
7
+ ## Progress log
8
+ Write progress to: `.agent-cortex/ralph/ralph-<bead-id>.log`
9
+ Log stage start, each pruner run with its exit status, and stage complete (see Progress Logging in
10
+ run-pipeline-stage skill).
11
+
12
+ ## Instructions
13
+ This stage removes worktrees, so **do not work in the bead worktree**. Run every command from the
14
+ repository's main checkout — the directory that holds `.agent-cortex/worktrees/` — because the
15
+ worktree this run may remove can be your own.
16
+
17
+ 1. **Run the pruner once** from the main checkout:
18
+ ```bash
19
+ node <ralph-scripts>/prune-worktrees.mjs
20
+ ```
21
+ `<ralph-scripts>` is the absolute path to `skills/workflow/ralph/scripts/`, resolved from wherever
22
+ that skill was loaded. Read the per-worktree report it prints — it lists every scanned worktree,
23
+ its classification (`closed-bead`, `open-bead`, `orphan`, `unclassified`) and its outcome
24
+ (`removed`, `kept`, `refused`).
25
+
26
+ 2. **Retry once on a non-zero exit.** Exit status `1` means at least one scanned worktree survived
27
+ the run, and an open bead counts as survival — a normal run against a live backlog exits `1`. So
28
+ run the same command a second time when the first run exits non-zero. One retry, never more.
29
+
30
+ 3. **Decide the outcome from the report, not the exit status.** After the retry:
31
+ - **SUCCESS** — no worktree classified `closed-bead` is left in `refused` state. Report every
32
+ worktree the run removed and every one it kept, with the printed reason.
33
+ - **BLOCKED** — a `closed-bead` worktree is still `refused`, or the pruner could not run at all
34
+ (missing node, unreadable directory). A refused closed-bead worktree is a worktree this run did
35
+ not remove, whatever the reason printed.
36
+
37
+ 4. **Never close this chore over a worktree the script did not remove.** This stage is the one
38
+ exception to the close-on-both-outcomes rule, because this stage is last in the chain and nothing
39
+ waits on its close. Close it only when every `closed-bead` worktree is gone; the worktree removal
40
+ is the only thing this stage completes.
41
+
42
+ On BLOCKED, **do not close the chore**. Leave it open — return it to `open` status if your claim
43
+ moved it to `in_progress` — report the surviving worktrees with the reasons the pruner printed,
44
+ and hand the chore back to a human. Create no `stage:fix` chore and no fresh `stage:housekeeping`
45
+ chore: there is nothing for a fix round to repair, and no later stage to re-block. A `kept` or
46
+ `refused` entry for an open bead is a normal report, not a failure.
47
+
48
+ Do not delete a worktree by hand, do not pass `--remove-orphans`, and do not edit the pruner. This
49
+ stage reports what the script did.
50
+
51
+ End your response with a ---REPORT--- block. The `run-pipeline-stage` skill's
52
+ `prompts/stage-runner.md` holds the authoritative REPORT contract — if this block and that prompt
53
+ differ, follow the prompt and correct this template.
54
+ ---REPORT---
55
+ BEAD_ID: <id>
56
+ STAGE_COMPLETED: housekeeping
57
+ SUMMARY: <2–3 sentences: worktrees removed, worktrees kept and why>
58
+ FILES_CHANGED: none
59
+ COMMIT: none
60
+ PUSHED: no
61
+ PR: none
62
+ OUTCOME: <SUCCESS|BLOCKED>
63
+ BLOCKING_ISSUES: ← include only if OUTCOME is BLOCKED; one line per issue
64
+ - <worktree path and the reason the pruner printed>
65
+ ---
@@ -25,7 +25,7 @@ Clarify the skill goal with the user. Ask what triggers users would actually say
25
25
 
26
26
  ### 2. Draft from the anatomy template
27
27
 
28
- Read `docs/skills/skill-anatomy.md` as the source of truth. Follow the annotated template for section ordering and front-matter fields. Target ~150 lines for `SKILL.md` but stay under the 150-line hard cap. When the workflow branches or has decision points, include one inline ASCII workflow diagram per the anatomy doc's diagram style guide — ASCII only, never Mermaid.
28
+ Read `{{PATH:skill_anatomy}}` as the source of truth. Follow the annotated template for section ordering and front-matter fields. Target ~150 lines for `SKILL.md` but stay under the 150-line hard cap. When the workflow branches or has decision points, include one inline ASCII workflow diagram per the anatomy doc's diagram style guide — ASCII only, never Mermaid.
29
29
 
30
30
  ### 3. Apply voice and tone rules
31
31
 
@@ -62,7 +62,7 @@ Output: A new `skills/review-security/SKILL.md` with front matter, When to use,
62
62
 
63
63
  ## Verification checklist
64
64
 
65
- - [ ] `docs/skills/skill-anatomy.md` was read before drafting
65
+ - [ ] `{{PATH:skill_anatomy}}` was read before drafting
66
66
  - [ ] Required sections present: `## When to use`, `## Workflow`, `## Verification checklist`
67
67
  - [ ] Section order follows the anatomy template
68
68
  - [ ] Workflow diagram (if present) is ASCII-only and follows the anatomy diagram style guide
@@ -8,7 +8,7 @@ description: Refactor an existing SKILL.md to the canonical anatomy template thr
8
8
  ## When to use
9
9
 
10
10
  - A skill's SKILL.md predates the anatomy template and needs upgrading.
11
- - You want to align a skill with `docs/skills/skill-anatomy.md` before the refactor wave touches it.
11
+ - You want to align a skill with `{{PATH:skill_anatomy}}` before the refactor wave touches it.
12
12
  - You want to compare a skill's structure to the current template standard.
13
13
  - Trigger phrases: "refactor this skill", "restructure this skill", "review this skill", "upgrade X to the template", "align with anatomy".
14
14
 
@@ -27,7 +27,7 @@ Read the target skill's `skills/<name>/SKILL.md` and the canonical anatomy refer
27
27
 
28
28
  ```bash
29
29
  cat skills/<name>/SKILL.md
30
- cat docs/skills/skill-anatomy.md
30
+ cat {{PATH:skill_anatomy}}
31
31
  ```
32
32
 
33
33
  If the target has no `SKILL.md` yet, abort — this skill upgrades existing files; use `write-a-skill` for new creation.
@@ -47,7 +47,7 @@ are fine.
47
47
 
48
48
  ## Expansion Guard
49
49
 
50
- Stage chores are pre-created, never created on demand. `create-chores.mjs` (in `skills/planning/create-task/`) reads `pipeline.json` and creates the whole chain in one shot — `code` → `verify` → `review` → `document`, plus the `featurePrReview` HITL gate — with `blocks` deps that encode the order, and with the parent's priority and inherited labels. It is idempotent: a re-run adopts the chores that already exist under the parent, creates only the missing ones, and skips deps that are already wired. Ralph's dispatch is therefore always "claim what `bd ready -l execution:afk` returns".
50
+ Stage chores are pre-created, never created on demand. `create-chores.mjs` (in `skills/planning/create-task/`) reads `pipeline.json` and creates the whole chain in one shot — `code` → `verify` → `review` → `document`, plus the `featurePrReview` HITL gate, plus the `housekeeping` chore blocked behind that gate — with `blocks` deps that encode the order, and with the parent's priority and inherited labels. It is idempotent: a re-run adopts the chores that already exist under the parent, creates only the missing ones, and skips deps that are already wired. Ralph's dispatch is therefore always "claim what `bd ready -l execution:afk` returns".
51
51
 
52
52
  A ready `execution:afk` task bead with **no** `stage:*` chore children has nothing to dispatch. Expand it, then dispatch the resulting `stage:code` chore:
53
53
 
@@ -57,7 +57,7 @@ A ready `execution:afk` task bead with **no** `stage:*` chore children has nothi
57
57
  ```
58
58
  Expect `0` printed. `grep -c` exits `1` when it counts zero matches, so add `|| true` when the
59
59
  check runs under `set -e` or inside an `&&` chain — the printed count is the answer, not the exit
60
- status. A bead with a full chain — one `stage:*` chore per stage in `pipeline.json` (four for the
60
+ status. A bead with a full chain — one `stage:*` chore per stage in `pipeline.json` (five for the
61
61
  shipped pipeline) plus the `lifecycle:feature-pr` gate — never needs an expansion. The check
62
62
  answers "no chores at all", so a **partial** chain (a lower count from a failed run) passes it
63
63
  and its missing stages never arrive. Nothing detects that automatically: re-run step 2's command
@@ -72,7 +72,7 @@ A ready `execution:afk` task bead with **no** `stage:*` chore children has nothi
72
72
  ```json
73
73
  { "kind": "expand", "choreId": null, "parentId": "<parent-id>", "title": "<parent title>", "agentId": "<agent-id>", "logLine": 1 }
74
74
  ```
75
- 4. **Report the mapping** the worker returns — `code`, `verify`, `review`, `document`, `featurePrReview` — and close nothing. The worker owns no bead on this run; the expansion is bookkeeping, not a stage.
75
+ 4. **Report the mapping** the worker returns — `code`, `verify`, `review`, `document`, `housekeeping`, `featurePrReview` — and close nothing. The worker owns no bead on this run; the expansion is bookkeeping, not a stage.
76
76
  5. **Dispatch normally.** The next `bd ready -l execution:afk` pass picks up the `stage:code` chore, and the pre-created chain carries the rest.
77
77
  6. **Handle the completion as an expansion completion**, not a stage completion — see below.
78
78
 
@@ -97,7 +97,7 @@ On completion, remove the entry from `inflight` and confirm the chore tree exist
97
97
  bd children <parent-id> --json | grep -c '"stage:'
98
98
  ```
99
99
 
100
- Expect one match per `stage:*` label in the chain — four for the shipped `pipeline.json`; the
100
+ Expect one match per `stage:*` label in the chain — five for the shipped `pipeline.json`; the
101
101
  `featurePrReview` gate carries `lifecycle:feature-pr` instead, so it is not counted. A lower count
102
102
  means the expansion did not finish: `0` created nothing, and a partial chain leaves stages that
103
103
  will never become ready. Either way, surface the worker's output in chat, record the parent for the
@@ -204,6 +204,37 @@ For each parent task bead (`<parent-id>`):
204
204
 
205
205
  ---
206
206
 
207
+ ## Pruning Worktrees
208
+
209
+ A worktree outlives its bead: ralph leaves the directory behind when a feature closes, and a crashed run leaves one with no bead at all. `skills/workflow/ralph/scripts/prune-worktrees.mjs` converges `.agent-cortex/worktrees/*` with bead state.
210
+
211
+ ```bash
212
+ node skills/workflow/ralph/scripts/prune-worktrees.mjs # remove closed-bead worktrees, report the rest
213
+ node skills/workflow/ralph/scripts/prune-worktrees.mjs --remove-orphans # also remove open-bead and missing-bead worktrees
214
+ ```
215
+
216
+ **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.
217
+
218
+ **A candidate that fails a guard is refused, and the reason is printed.** The guards are: dirty working tree, `locked` worktree, detached HEAD, directory not registered with git, branch with no upstream, and branch with unpushed commits.
219
+
220
+ **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.
221
+
222
+ **Exit status is 1 whenever any scanned worktree survived the run.** An open bead counts as survival, so a normal run against a live backlog exits 1. Read the per-worktree report; do not treat the status as a verdict on the worktree you asked about.
223
+
224
+ **An unregistered stale directory is refused forever.** Earlier git runs may have dropped its registration, so every scan reports it and never removes it. Clean it up by hand: `git worktree prune`, then `rm -rf <path>`.
225
+
226
+ ### Housekeeping chore
227
+
228
+ Pruning is a pipeline stage, not a habit: `create-chores.mjs` pre-creates a `stage:housekeeping` chore for every expanded task, carrying `lifecycle:housekeeping` and blocked behind that task's `lifecycle:feature-pr` gate. It stays out of `bd ready` until a human closes the gate, so it never competes with pipeline work.
229
+
230
+ - **Executor.** A dispatched worker — the `stage:housekeeping` chore in `bd ready -l execution:afk` — runs `prune-worktrees.mjs` from the **main checkout**, never from the feature worktree: the worktree it removes can be the parent's own. Ralph dispatches it with the main checkout as cwd for that reason.
231
+ - **Retry once.** Exit status `1` means some scanned worktree survived, and an open bead counts as survival, so a live backlog always exits non-zero. The worker therefore retries the run once. The retry is the whole allowance — never a third run.
232
+ - **Never close over a worktree the script did not remove.** The outcome comes from the per-worktree report, not the exit status: the chore succeeds when no `closed-bead` worktree is left `refused`. A closed-bead worktree still `refused` after the retry fails the chore, and the chore is left **open** with the printed reasons in its notes. A `kept` or `refused` entry for an open bead is a normal report, not a failure.
233
+
234
+ Ralph creates nothing for this stage and closes nothing: the worker owns the chore, as it owns every leaf bead.
235
+
236
+ ---
237
+
207
238
  ## Feature PR Gate
208
239
 
209
240
  When the `document` chore for a parent task completes:
@@ -219,15 +250,15 @@ When the `document` chore for a parent task completes:
219
250
  4. Find the child HITL task bead for this parent with label `lifecycle:feature-pr` and `execution:hitl` (created by `create-task`).
220
251
  5. Update that HITL bead with the PR URL/status (comment or note) so the reviewer has the link.
221
252
  6. Do not schedule new parent features while this gate is **unblocked** — an unblocked gate is a feature genuinely awaiting a human merge (see _Gate predicate_ below).
222
- 7. Once the PR is merged, a human closes the HITL PR gate bead, which unblocks the scheduling brake. After that, close the parent feature bead:
253
+ 7. Once the PR is merged, a human closes the HITL PR gate bead, which unblocks the scheduling brake **and** the pre-created `stage:housekeeping` chore. After that, close the parent feature bead:
223
254
  ```bash
224
255
  bd close <parent-id>
225
256
  ```
226
- Only then continue with next parent features.
257
+ The housekeeping run that follows removes the parent's worktree, because its bead now reads `closed`. Only then continue with next parent features.
227
258
 
228
259
  ### Gate predicate — parked vs pending
229
260
 
230
- Ralph's only scheduling brake is a gate that is **unblocked**. `create-chores` pre-creates a `lifecycle:feature-pr` gate for *every* planned feature, blocked behind that feature's own `document` chore; such a gate is **parked** — the feature has not reached its PR stage — and must never stop dispatch. Only when the `document` chore closes does the gate unblock, and that is the state in which a human must merge.
261
+ Ralph's only scheduling brake is a gate that is **unblocked**. `create-chores` pre-creates a `lifecycle:feature-pr` gate for *every* planned feature, blocked behind that feature's own `document` chore; such a gate is **parked** — the feature has not reached its PR stage — and must never stop dispatch. Only when the `document` chore closes does the gate unblock, and that is the state in which a human must merge. The feature's `stage:housekeeping` chore waits on the gate bead itself, so it becomes ready only after that human closes it.
231
262
 
232
263
  Resolve the question with one command instead of ad-hoc `bd` queries:
233
264