@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.
@@ -9,7 +9,7 @@ Ralph is a parallel orchestration loop. It advances `execution:afk` beads throug
9
9
 
10
10
  One query decides what ralph works on: **`bd ready -l execution:afk`**. Features, tasks and chores all arrive through it, and each bead's *shape* — not a second `bd` filter — decides which procedure carries it: a childless feature is decomposed, a bead with no stage chores is expanded, a stage chore is dispatched. An `execution:afk` bead is therefore never invisible to ralph for lacking a `stage:*` label, and ralph never invents a narrower "only stage chores" predicate that would hide one.
11
11
 
12
- Each leaf execution bead belongs to the worker that ran it: the worker appends its milestone notes, closes the bead on both outcomes, and builds its own `stage:fix` + fresh-stage remediation chain when it fails. Ralph reads beads and routes — it never closes a leaf bead and never creates a fix bead. Its only retry control is a read-only cap backstop.
12
+ Each leaf execution bead belongs to the worker that ran it: the worker appends its milestone notes, closes the bead on both outcomes, and builds its own `stage:fix` + fresh-stage remediation chain when it fails. The `housekeeping` worker is the exception — it never closes its chore over a worktree the pruner did not remove, and it builds no chain. Ralph reads beads and routes — it never closes a leaf bead and never creates a fix bead. Its only retry control is a read-only cap backstop.
13
13
 
14
14
  State model: task state lives in beads (ready/in_progress via `bd`); the only local file is `.agent-cortex/ralph/state.json`, holding the agent-ID→bead mapping. Pipeline configuration comes from `skills/planning/create-task/pipeline.json`, and stage execution belongs to the `run-pipeline-stage` skill. Stage chores are pre-created from that pipeline when a task is expanded — with `blocks` deps encoding the order — so ralph's dispatch is always "claim what `bd ready -l execution:afk` returns"; it never creates a stage chore.
15
15
 
@@ -45,7 +45,7 @@ State model: task state lives in beads (ready/in_progress via `bd`); the only lo
45
45
 
46
46
  ### 2. Expansion guard (stage chores are pre-created)
47
47
 
48
- A ready `execution:afk` task bead with no `stage:*` chore children has nothing to dispatch, so it is not dispatched as work. Expand it first: dispatch a worker in the feature worktree that runs `create-chores.mjs --parent <id> --priority <parent-priority>` against the existing bead and reports the returned `code`/`verify`/`review`/`document`/`featurePrReview` mapping — the next `bd ready -l execution:afk` pass then picks up `stage:code` (see REFERENCE.md, _Expansion Guard_).
48
+ A ready `execution:afk` task bead with no `stage:*` chore children has nothing to dispatch, so it is not dispatched as work. Expand it first: dispatch a worker in the feature worktree that runs `create-chores.mjs --parent <id> --priority <parent-priority>` against the existing bead and reports the returned `code`/`verify`/`review`/`document`/`housekeeping`/`featurePrReview` mapping — the next `bd ready -l execution:afk` pass then picks up `stage:code` (see REFERENCE.md, _Expansion Guard_).
49
49
 
50
50
  The expansion is idempotent and re-running it is the recovery path: the script adopts the chores that already exist under the parent, creates only the missing ones, and reports the whole mapping. The auto-recovery fires only when a re-check finds no `stage:*` chore at all; a **partial** chain (a count below the stage total) needs a manual re-run of that same command.
51
51
 
@@ -74,7 +74,8 @@ On each completion notification (a background agent finished):
74
74
  | `verify` | `OUTCOME: BLOCKED` | Create nothing — the worker built `stage:fix` + a fresh `stage:verify`; dispatch the ready fix chore |
75
75
  | `review` | `OUTCOME: SUCCESS` | Closing the review chore unblocks the document chore |
76
76
  | `review` | `OUTCOME: BLOCKED` | Create nothing — the worker built `stage:fix` + a fresh `stage:review`; dispatch the ready fix chore |
77
- | `document` | `OUTCOME: SUCCESS` | Open/update the feature PR now (`feature/<parent-id>` → its base branch), report the URL, and update the child HITL gate bead (`lifecycle:feature-pr`). Wait for a human to close that bead after merge, then `bd close <parent-id>` |
77
+ | `document` | `OUTCOME: SUCCESS` | Open/update the feature PR now (`feature/<parent-id>` → its base branch), report the URL, and update the child HITL gate bead (`lifecycle:feature-pr`). Wait for a human to close that bead after merge, then `bd close <parent-id>` — closing the gate unblocks the pre-created `stage:housekeeping` chore, which removes the worktrees of closed beads |
78
+ | `housekeeping` | `OUTCOME: SUCCESS` | Nothing further — it is the last stage; its closed chore ends the pipeline |
78
79
  | any | parent status `blocked` | Nothing — the worker hit `maxFixRounds`; record the parent for the shutdown summary |
79
80
 
80
81
  5. Run `bd ready -l execution:afk` and dispatch new ready work while the in-flight count is under 5 — max 5 parent features, counted from `state.json`'s `inflight` (a parent counts while any of its workers — stage, `kind: "decompose"` or `kind: "expand"` — sits in `inflight`; REFERENCE.md, _State Files_). A ready childless feature is decomposed, not staged, and a ready bead with no chore tree is expanded (see _Feature decomposition_ and _Expansion guard_ above). Before each dispatch run the read-only cap backstop (REFERENCE.md, _Cap Backstop_): at `maxFixRounds` block the parent and create nothing. Do not schedule new parent features while `pending-gates.mjs` reports a non-empty `pendingHumanAction` — that is, while a `lifecycle:feature-pr` gate is **unblocked**. Gates still blocked behind their own pipeline (pre-created for a feature that has not reached its PR stage) are parked and never stop dispatch.
@@ -96,7 +97,7 @@ bd show <id> # full detail, including the worker's notes
96
97
  - **Multi-feature epics** (two or more feature task children): the epic runs on `epic/<epic-id>`, based on `origin/main` — never local `main`. Each `execution:afk` parent task runs on `feature/<parent-id>`, based from its epic branch, in the dedicated worktree `.agent-cortex/worktrees/<parent-id>`.
97
98
  - Feature PR gate: `feature/<parent-id>` → `epic/<epic-id>` (or → `main` for single-feature epics). Epic PR gate: `epic/<epic-id>` → `main`.
98
99
  - **Never stack branches.** `feature/<parent-id>` is based on `epic/<epic-id>` (or `origin/main`), never on another `feature/*`. A dependency between two tasks lives in the bead graph (`bd dep add <waiting> <blocked-on>`) so the dependent task stays out of `bd ready` until its dependency has merged — stacking the branch defeats that gate by hand and costs a rebase-on-squash later (see `git-workflow` rule 3).
99
- - Branch/worktree setup and the PR commands live in REFERENCE.md (_Feature branches and worktrees_, _Feature PR gate_).
100
+ - Branch/worktree setup and the PR commands live in REFERENCE.md (_Feature branches and worktrees_, _Feature PR gate_). Closed-bead worktrees are reclaimed by `skills/workflow/ralph/scripts/prune-worktrees.mjs`, which removes worktrees whose bead is closed, reports the rest, and removes an orphan only with `--remove-orphans` (REFERENCE.md, _Pruning Worktrees_). The `stage:housekeeping` chore runs it: dispatch that chore with the **main checkout** as cwd, not the feature worktree, because the worktree it removes may be the parent's own. A non-zero exit gets one retry; a closed-bead worktree still refused after it fails the chore, which stays open.
100
101
 
101
102
  ### 7. HITL pause and shutdown
102
103
 
@@ -0,0 +1,346 @@
1
+ // Converges the ralph worktree set with bead state.
2
+ //
3
+ // Ralph runs each bead in `.agent-cortex/worktrees/<bead-id>` and leaves the
4
+ // worktree behind after the bead closes; crashed runs leave orphans. This pass
5
+ // reads bead state, not git ancestry: this repo squash-merges, so every ancestry
6
+ // test reports a landed branch as unmerged.
7
+ //
8
+ // Zero dependencies: run with `node prune-worktrees.mjs`.
9
+
10
+ import { execFile } from 'node:child_process';
11
+ import { readdir } from 'node:fs/promises';
12
+ import { dirname, join } from 'node:path';
13
+ import { promisify } from 'node:util';
14
+ import { pathToFileURL } from 'node:url';
15
+
16
+ const execFileAsync = promisify(execFile);
17
+
18
+ // BD_PATH env override lets machines where bd is not on PATH pin a location.
19
+ const BD_PATH = process.env.BD_PATH || 'bd';
20
+
21
+ /** @typedef {'closed-bead' | 'open-bead' | 'orphan' | 'unclassified'} Classification */
22
+ /** @typedef {'removed' | 'kept' | 'refused'} Outcome */
23
+
24
+ /**
25
+ * @typedef {object} WorktreeEntry
26
+ * @property {string} id Bead id, taken from the directory name.
27
+ * @property {string} path Absolute path of the worktree.
28
+ * @property {string | null} branch Checked-out branch, or null when unknown.
29
+ * @property {Classification} classification
30
+ * @property {string} beadStatus Raw `bd` status, or `'missing'`.
31
+ * @property {Outcome} outcome
32
+ * @property {string[]} reasons Why the worktree was not removed.
33
+ */
34
+
35
+ /**
36
+ * @typedef {object} PruneResult
37
+ * @property {WorktreeEntry[]} entries
38
+ * @property {number} exitCode
39
+ */
40
+
41
+ /**
42
+ * The text of a failed child-process call: git's stderr when it has one, the
43
+ * error message otherwise.
44
+ * @param {unknown} error
45
+ * @returns {string}
46
+ */
47
+ function errorText(error) {
48
+ if (typeof error === 'object' && error !== null) {
49
+ const { stderr, message } = /** @type {{ stderr?: unknown, message?: unknown }} */ (error);
50
+ if (typeof stderr === 'string' && stderr.trim() !== '') return stderr;
51
+ if (typeof message === 'string') return message;
52
+ }
53
+ return String(error);
54
+ }
55
+
56
+ /**
57
+ * @param {unknown} error
58
+ * @returns {boolean} true when the error is a missing path, not a failure.
59
+ */
60
+ function isMissingPath(error) {
61
+ return typeof error === 'object' && error !== null && /** @type {{ code?: unknown }} */ (error).code === 'ENOENT';
62
+ }
63
+
64
+ /** @type {(args: string[]) => Promise<string>} */
65
+ async function runBd(args) {
66
+ const { stdout } = await execFileAsync(BD_PATH, args, { encoding: 'utf-8' });
67
+ return stdout;
68
+ }
69
+
70
+ /**
71
+ * Run git in `cwd` and report the outcome rather than throwing: a refusal is an
72
+ * expected domain result here, not an emergency.
73
+ * @param {string} cwd
74
+ * @param {string[]} args
75
+ * @returns {Promise<{ ok: true, stdout: string } | { ok: false, stderr: string }>}
76
+ */
77
+ async function runGit(cwd, args) {
78
+ try {
79
+ const { stdout } = await execFileAsync('git', args, { cwd, encoding: 'utf-8' });
80
+ return { ok: true, stdout };
81
+ } catch (error) {
82
+ return { ok: false, stderr: errorText(error) };
83
+ }
84
+ }
85
+
86
+ /**
87
+ * List the worktree directories under `.agent-cortex/worktrees`, ignoring
88
+ * everything else on disk. A missing directory means nothing to prune.
89
+ * @param {string} repoRoot
90
+ * @returns {Promise<string[]>} absolute paths, sorted for stable output.
91
+ */
92
+ async function listWorktreePaths(repoRoot) {
93
+ const worktreesDir = join(repoRoot, '.agent-cortex', 'worktrees');
94
+ /** @type {import('node:fs').Dirent[]} */
95
+ let dirents;
96
+ try {
97
+ dirents = await readdir(worktreesDir, { withFileTypes: true });
98
+ } catch (error) {
99
+ if (isMissingPath(error)) return [];
100
+ throw error;
101
+ }
102
+ return dirents
103
+ .filter((dirent) => dirent.isDirectory())
104
+ .map((dirent) => join(worktreesDir, dirent.name))
105
+ .sort();
106
+ }
107
+
108
+ /**
109
+ * Map each registered worktree path to its checked-out branch and lock state.
110
+ * @param {string} repoRoot
111
+ * @returns {Promise<Map<string, { branch: string | null, locked: string | null }>>}
112
+ */
113
+ async function readRegisteredWorktrees(repoRoot) {
114
+ const result = await runGit(repoRoot, ['worktree', 'list', '--porcelain']);
115
+ /** @type {Map<string, { branch: string | null, locked: string | null }>} */
116
+ const registered = new Map();
117
+ if (!result.ok) return registered;
118
+
119
+ for (const block of result.stdout.split('\n\n')) {
120
+ const lines = block.split('\n');
121
+ const pathLine = lines.find((line) => line.startsWith('worktree '));
122
+ if (pathLine === undefined) continue;
123
+ const branchLine = lines.find((line) => line.startsWith('branch '));
124
+ const lockedLine = lines.find((line) => line.startsWith('locked'));
125
+ registered.set(pathLine.slice('worktree '.length), {
126
+ branch: branchLine === undefined ? null : branchLine.slice('branch refs/heads/'.length),
127
+ locked: lockedLine === undefined ? null : lockedLine.slice('locked'.length).trim(),
128
+ });
129
+ }
130
+ return registered;
131
+ }
132
+
133
+ /**
134
+ * Resolve one worktree's bead and classify it.
135
+ * @param {string} id
136
+ * @param {(args: string[]) => Promise<string>} exec
137
+ * @returns {Promise<{ classification: Classification, beadStatus: string }>}
138
+ */
139
+ async function classifyBead(id, exec) {
140
+ let stdout;
141
+ try {
142
+ stdout = await exec(['show', id, '--json']);
143
+ } catch (error) {
144
+ const message = errorText(error);
145
+ if (/not found|no issues found/i.test(message)) {
146
+ return { classification: 'orphan', beadStatus: 'missing' };
147
+ }
148
+ return { classification: 'unclassified', beadStatus: `error: ${message.trim()}` };
149
+ }
150
+
151
+ const beads = JSON.parse(stdout);
152
+ const bead = (Array.isArray(beads) ? beads[0] : beads) ?? {};
153
+ const status = String(bead.status ?? 'unknown');
154
+ return { classification: status === 'closed' ? 'closed-bead' : 'open-bead', beadStatus: status };
155
+ }
156
+
157
+ /**
158
+ * Reasons git refuses to tear a worktree down. A worktree removed anyway would
159
+ * throw away uncommitted work or an unpushed branch, so every reason keeps the
160
+ * worktree in place.
161
+ * @param {{ path: string, registered: { branch: string | null, locked: string | null } | undefined }} target
162
+ * @param {(cwd: string, args: string[]) => Promise<{ ok: true, stdout: string } | { ok: false, stderr: string }>} git
163
+ * @returns {Promise<{ branch: string | null, reasons: string[] }>}
164
+ */
165
+ async function findRefusals({ path, registered }, git) {
166
+ /** @type {string[]} */
167
+ const reasons = [];
168
+
169
+ if (registered === undefined) {
170
+ return { branch: null, reasons: ['not a registered git worktree'] };
171
+ }
172
+ if (registered.locked !== null) {
173
+ reasons.push(registered.locked === '' ? 'worktree is locked' : `worktree is locked: ${registered.locked}`);
174
+ }
175
+ if (registered.branch === null) {
176
+ return { branch: null, reasons: [...reasons, 'worktree is not on a branch'] };
177
+ }
178
+
179
+ const status = await git(path, ['status', '--porcelain']);
180
+ if (!status.ok) return { branch: registered.branch, reasons: [...reasons, `worktree cannot be read: ${status.stderr.trim()}`] };
181
+ const dirtyPaths = status.stdout.split('\n').filter((line) => line !== '').length;
182
+ if (dirtyPaths > 0) reasons.push(`worktree is dirty: ${dirtyPaths} uncommitted path(s)`);
183
+
184
+ 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`);
187
+ return { branch: registered.branch, reasons };
188
+ }
189
+ const trackingRef = upstream.stdout.trim();
190
+ const unpushed = await git(path, ['rev-list', '--count', `${trackingRef}..HEAD`]);
191
+ if (!unpushed.ok) return { branch: registered.branch, reasons: [...reasons, `branch ${registered.branch} cannot be compared with ${trackingRef}: ${unpushed.stderr.trim()}`] };
192
+ const unpushedCommits = Number.parseInt(unpushed.stdout.trim(), 10);
193
+ if (unpushedCommits > 0) {
194
+ reasons.push(`branch ${registered.branch} has ${unpushedCommits} commit(s) not pushed to ${trackingRef}`);
195
+ }
196
+
197
+ return { branch: registered.branch, reasons };
198
+ }
199
+
200
+ /**
201
+ * Remove a worktree and its branch, locally and on the remote.
202
+ * @param {string} repoRoot
203
+ * @param {{ path: string, branch: string }} target
204
+ * @param {(cwd: string, args: string[]) => Promise<{ ok: true, stdout: string } | { ok: false, stderr: string }>} git
205
+ * @returns {Promise<{ ok: true } | { ok: false, reasons: string[] }>}
206
+ */
207
+ async function removeWorktree(repoRoot, { path, branch }, git) {
208
+ /** @type {string[]} */
209
+ const reasons = [];
210
+
211
+ const removed = await git(repoRoot, ['worktree', 'remove', path]);
212
+ if (!removed.ok) {
213
+ return { ok: false, reasons: [`git worktree remove failed: ${removed.stderr.trim()}`] };
214
+ }
215
+
216
+ const local = await git(repoRoot, ['branch', '-D', branch]);
217
+ if (!local.ok) reasons.push(`local branch ${branch} could not be deleted: ${local.stderr.trim()}`);
218
+
219
+ const remote = await git(repoRoot, ['push', 'origin', '--delete', branch]);
220
+ const remoteGone = /remote ref does not exist/i.test(remote.ok ? '' : remote.stderr);
221
+ if (!remote.ok && !remoteGone) {
222
+ reasons.push(`remote branch ${branch} could not be deleted: ${remote.stderr.trim()}`);
223
+ }
224
+
225
+ return reasons.length === 0 ? { ok: true } : { ok: false, reasons };
226
+ }
227
+
228
+ /**
229
+ * Converge `.agent-cortex/worktrees` with bead state: remove closed-bead
230
+ * worktrees, report everything else.
231
+ * @param {object} options
232
+ * @param {string} options.repoRoot Repository to prune.
233
+ * @param {boolean} [options.removeOrphans] Also remove worktrees whose bead is open or missing.
234
+ * @param {(args: string[]) => Promise<string>} [options.exec] bd runner; injectable for tests.
235
+ * @param {(cwd: string, args: string[]) => Promise<{ ok: true, stdout: string } | { ok: false, stderr: string }>} [options.git] git runner; injectable for tests.
236
+ * @returns {Promise<PruneResult>}
237
+ */
238
+ export async function pruneWorktrees({ repoRoot, exec = runBd, removeOrphans = false, git = runGit }) {
239
+ const registered = await readRegisteredWorktrees(repoRoot);
240
+ /** @type {WorktreeEntry[]} */
241
+ const entries = [];
242
+
243
+ for (const path of await listWorktreePaths(repoRoot)) {
244
+ const id = path.slice(dirname(path).length + 1);
245
+ const { classification, beadStatus } = await classifyBead(id, exec);
246
+
247
+ /** @type {WorktreeEntry} */
248
+ const entry = { id, path, branch: registered.get(path)?.branch ?? null, classification, beadStatus, outcome: 'kept', reasons: [] };
249
+
250
+ // A closed bead is the proof. Anything else — an open bead, a missing bead,
251
+ // a bead bd could not answer for — is only removed on explicit request, and
252
+ // an unreadable bead is never removed at all.
253
+ const wanted = classification === 'closed-bead' || (classification !== 'unclassified' && removeOrphans);
254
+ if (wanted) {
255
+ const { branch, reasons } = await findRefusals({ path, registered: registered.get(path) }, git);
256
+ entry.branch = branch;
257
+ if (reasons.length > 0) {
258
+ entry.outcome = 'refused';
259
+ entry.reasons = reasons;
260
+ } else if (branch === null) {
261
+ entry.outcome = 'refused';
262
+ entry.reasons = ['worktree is not on a branch'];
263
+ } else {
264
+ const removal = await removeWorktree(repoRoot, { path, branch }, git);
265
+ entry.outcome = removal.ok ? 'removed' : 'refused';
266
+ if (!removal.ok) entry.reasons = removal.reasons;
267
+ }
268
+ }
269
+
270
+ entries.push(entry);
271
+ }
272
+
273
+ return { entries, exitCode: entries.some((entry) => entry.outcome !== 'removed') ? 1 : 0 };
274
+ }
275
+
276
+ // ─── CLI ──────────────────────────────────────────────────────────────────────
277
+
278
+ const USAGE = `Usage: node prune-worktrees.mjs [--remove-orphans]
279
+
280
+ Removes worktrees under .agent-cortex/worktrees whose bead is closed, then
281
+ reports the rest. A worktree whose bead is open or missing is an orphan: it is
282
+ only removed when --remove-orphans is passed. Run from anywhere inside the
283
+ repository.
284
+ `;
285
+
286
+ /** Every flag the CLI accepts. */
287
+ const REMOVE_ORPHANS_FLAG = '--remove-orphans';
288
+
289
+ class UsageError extends Error {}
290
+
291
+ /**
292
+ * Resolve the repository root from any directory inside it — the main checkout
293
+ * or a linked worktree. The shared `.git` directory is one level below the root
294
+ * in both cases.
295
+ * @param {string} cwd
296
+ * @returns {Promise<string | null>}
297
+ */
298
+ async function resolveRepoRoot(cwd) {
299
+ const result = await runGit(cwd, ['rev-parse', '--path-format=absolute', '--git-common-dir']);
300
+ return result.ok ? dirname(result.stdout.trim()) : null;
301
+ }
302
+
303
+ /**
304
+ * @param {object} options
305
+ * @param {string[]} options.argv CLI arguments (without node/script).
306
+ * @param {string} [options.cwd] Directory the CLI was invoked from. * @param {(args: string[]) => Promise<string>} [options.exec] bd runner; injectable for tests.
307
+ * @param {(cwd: string, args: string[]) => Promise<{ ok: true, stdout: string } | { ok: false, stderr: string }>} [options.git]
308
+ * @param {(chunk: string) => void} [options.write] stdout sink; injectable for tests.
309
+ * @returns {Promise<number>} process exit code.
310
+ */
311
+ export async function runCli({ argv, cwd = process.cwd(), exec = runBd, git = runGit, write = (chunk) => process.stdout.write(chunk) }) {
312
+ if (argv.includes('--help') || argv.includes('-h')) {
313
+ write(USAGE);
314
+ return 0;
315
+ }
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);
319
+
320
+ const repoRoot = await resolveRepoRoot(cwd);
321
+ if (repoRoot === null) throw new Error('not inside a git repository');
322
+
323
+ const { entries, exitCode } = await pruneWorktrees({ repoRoot, exec, removeOrphans, git });
324
+
325
+ write(`Scanning ${join(repoRoot, '.agent-cortex', 'worktrees')}\n`);
326
+ for (const entry of entries) {
327
+ const detail = entry.reasons.length > 0 ? ` — ${entry.reasons.join('; ')}` : '';
328
+ write(` ${entry.id.padEnd(24)} ${entry.classification.padEnd(13)} ${entry.outcome}${detail}\n`);
329
+ }
330
+ const removed = entries.filter((entry) => entry.outcome === 'removed').length;
331
+ write(`Removed ${removed} of ${entries.length} worktree(s).\n`);
332
+
333
+ return exitCode;
334
+ }
335
+
336
+ // Only run the CLI when invoked directly, so tests can import the module.
337
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
338
+ runCli({ argv: process.argv.slice(2) })
339
+ .then((code) => {
340
+ process.exitCode = code;
341
+ })
342
+ .catch((error) => {
343
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
344
+ process.exitCode = error instanceof UsageError ? 2 : 1;
345
+ });
346
+ }
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: run-pipeline-stage
3
- description: Execute a single pipeline stage for a bead (code, verify, review, document) via its playbook and read-only routing, reporting the outcome in a fixed REPORT shape. Use when you want to "work through beads inline", "pick up a specific stage bead", or run one stage "without the full ralph orchestrator".
3
+ description: Execute a single pipeline stage for a bead (code, verify, review, document, housekeeping) via its playbook and read-only routing, reporting the outcome in a fixed REPORT shape. Use when you want to "work through beads inline", "pick up a specific stage bead", or run one stage "without the full ralph orchestrator".
4
4
  ---
5
5
 
6
6
  # Run Pipeline Stage
7
7
 
8
- Stage beads (`stage:code`, `stage:verify`, `stage:review`, `stage:document`, `stage:fix`) come from the pipeline definition in `skills/planning/create-task/pipeline.json`. This skill executes one stage and owns that bead's lifecycle: the runner that does the work appends its own notes, closes its own leaf execution bead on **both** outcomes, and builds its own remediation children when the stage fails. The orchestrator dispatches and routes — it never edits or closes a leaf bead.
8
+ Stage beads (`stage:code`, `stage:verify`, `stage:review`, `stage:document`, `stage:housekeeping`, `stage:fix`) come from the pipeline definition in `skills/planning/create-task/pipeline.json`. This skill executes one stage and owns that bead's lifecycle: the runner that does the work appends its own notes, closes its own leaf execution bead on **both** outcomes, and builds its own remediation children when the stage fails. `housekeeping` is the single exception: it never closes its bead over a worktree the pruner did not remove, and it builds no remediation children. The orchestrator dispatches and routes — it never edits or closes a leaf bead.
9
9
 
10
10
  ## When to use
11
11
 
@@ -24,7 +24,7 @@ Stage beads (`stage:code`, `stage:verify`, `stage:review`, `stage:document`, `st
24
24
 
25
25
  | Actor | Owns |
26
26
  |---|---|
27
- | Stage runner (worker) | Milestone notes, progress-log lines, closing its leaf bead on both outcomes, remediation children, the REPORT |
27
+ | Stage runner (worker) | Milestone notes, progress-log lines, closing its leaf bead on both outcomes (`housekeeping` excepted: it leaves its bead open), remediation children, the REPORT |
28
28
  | Orchestrator (ralph or you) | Claim, dispatch, forward routing, the read-only cap backstop, the PR gate |
29
29
 
30
30
  A leaf bead is the worker's record of what happened. The orchestrator reads it; it does not write it.
@@ -58,7 +58,9 @@ The worker built anything this stage needed; routing picks up whatever `bd ready
58
58
  | `review` | `OUTCOME: SUCCESS` | Dispatch the ready `stage:document` chore |
59
59
  | `fix` | `OUTCOME: SUCCESS` | Dispatch the ready re-run chore that was blocked on the fix |
60
60
  | `document` | `OUTCOME: SUCCESS` | Open/update the feature PR and the HITL gate bead |
61
+ | `housekeeping` | `OUTCOME: SUCCESS` | Create nothing — this is the last stage; its closed chore ends the pipeline |
61
62
  | `code` / `verify` / `review` / `fix` / `document` | `OUTCOME: BLOCKED` | Create nothing — the worker built `stage:fix` + a fresh instance of its own stage; dispatch the ready fix chore |
63
+ | `housekeeping` | `OUTCOME: BLOCKED` | Create nothing — the worker left its chore **open**; hand the surviving worktrees to a human. Do not re-dispatch it automatically |
62
64
  | any | parent status `blocked` | Nothing — the worker or the cap backstop blocked it; record it for shutdown |
63
65
 
64
66
  Cap backstop (read-only): before dispatching a `stage:*` chore, count the parent's fix rounds and, if already at `maxFixRounds` (`skills/planning/create-task/pipeline.json`, currently `4`), block the parent and create nothing:
@@ -69,7 +71,7 @@ fix_rounds=$(bd children <parent-id> --json | grep -c '"stage:fix"')
69
71
 
70
72
  ## Remediation Children (worker-built)
71
73
 
72
- On `OUTCOME: BLOCKED` the worker creates its own chain — a `stage:fix` chore carrying the feedback, plus a **fresh instance of the stage that failed**, blocked on the fix. A fresh instance is not optional: the failed chore is already closed, so the pre-created chain cannot re-verify a fix.
74
+ On `OUTCOME: BLOCKED` the worker creates its own chain — a `stage:fix` chore carrying the feedback, plus a **fresh instance of the stage that failed**, blocked on the fix. A fresh instance is not optional: the failed chore is already closed, so the pre-created chain cannot re-verify a fix. `housekeeping` is the exception — it changes no files, its bead stays open, and nothing downstream waits on it, so it builds no chain.
73
75
 
74
76
  | Failed stage | Chain |
75
77
  |---|---|
@@ -78,6 +80,7 @@ On `OUTCOME: BLOCKED` the worker creates its own chain — a `stage:fix` chore c
78
80
  | `review` | `stage:fix` → fresh `stage:review` |
79
81
  | `document` | `stage:fix` → fresh `stage:document` |
80
82
  | `fix` | `stage:fix` → fresh `stage:fix` |
83
+ | `housekeeping` | none — the worker leaves its chore open, reports `BLOCKED`, and hands the surviving worktrees to a human. No `stage:fix` and no fresh `stage:housekeeping`, because the stage repairs no files and sits last in the chain |
81
84
 
82
85
  Webbing, at cap, and the exact commands live in `prompts/stage-runner.md` and each playbook's _Remediation and close_ section. In short: both beads are children of the parent task (`--parent <parent-id>`), the fresh stage is `bd dep add <fresh> <fix> --type blocks`, and any not-yet-run later stage is re-blocked behind the fresh bead. Chores carry no required sections, so the feedback description points at the parent/requirements bead (`<parent-id>`) rather than restating its acceptance criteria.
83
86
 
@@ -112,7 +115,7 @@ Every stage-runner prompt ends with the literal REPORT instruction (see `prompts
112
115
  ```
113
116
  ---REPORT---
114
117
  BEAD_ID: <id>
115
- STAGE_COMPLETED: <code|verify|review|document|fix>
118
+ STAGE_COMPLETED: <code|verify|review|document|housekeeping|fix>
116
119
  SUMMARY: <2-3 sentence summary of what was done>
117
120
  FILES_CHANGED: <comma-separated list, or "none">
118
121
  COMMIT: <sha of the stage commit, or "none">
@@ -0,0 +1,58 @@
1
+ # Stage Playbook: housekeeping
2
+
3
+ This stage runs after the human merge gate and is last in the chain. It removes the worktrees of
4
+ closed beads and changes no files.
5
+
6
+ ## Stage Instructions
7
+
8
+ 1. **Work from the main checkout.** Run every command from the repository's main checkout — the
9
+ directory that holds `.agent-cortex/worktrees/` — never from a bead worktree. The worktree this
10
+ run removes can be the one you are standing in.
11
+
12
+ 2. **Run the pruner once:**
13
+ ```bash
14
+ node <ralph-scripts>/prune-worktrees.mjs
15
+ ```
16
+ `<ralph-scripts>` is the absolute path to `skills/workflow/ralph/scripts/`, resolved from wherever
17
+ that skill was loaded. Read the per-worktree report it prints: every scanned worktree, its
18
+ classification (`closed-bead`, `open-bead`, `orphan`, `unclassified`) and its outcome (`removed`,
19
+ `kept`, `refused`).
20
+
21
+ 3. **Retry once on a non-zero exit.** Exit status `1` means at least one scanned worktree survived,
22
+ and an open bead counts as survival, so a normal run against a live backlog exits `1`. Run the
23
+ same command a second time when the first run exits non-zero. One retry, never more.
24
+
25
+ 4. **Decide from the per-worktree report, not from the exit status.** After the retry:
26
+ - **SUCCESS** — no `closed-bead` worktree is left in `refused` state. Report every worktree the
27
+ run removed and every one it kept, with the printed reason.
28
+ - **BLOCKED** — a `closed-bead` worktree is still `refused` after the retry, or the pruner could
29
+ not run at all (missing node, unreadable directory). A refused `closed-bead` worktree is a
30
+ worktree this run did not remove, whatever reason it printed.
31
+
32
+ 5. **Never close over a worktree the script did not remove.** Do not delete a worktree by hand, do
33
+ not pass `--remove-orphans`, and do not edit the pruner. This stage reports what the script did.
34
+
35
+ ## Stage Outcome
36
+
37
+ - **No commit and no push.** This stage changes no files, so report `COMMIT: none` and
38
+ `PUSHED: no` on both outcomes. Do not create an empty commit.
39
+ - Set `OUTCOME: SUCCESS` when every `closed-bead` worktree is gone after the retry. `FILES_CHANGED`
40
+ is `none`.
41
+ - Set `OUTCOME: BLOCKED` when a `closed-bead` worktree survived the retry, or the pruner could not
42
+ run. List each surviving worktree with the printed reason under `BLOCKING_ISSUES`.
43
+ - A `kept` or `refused` entry for an open bead is a normal report, not a failure.
44
+
45
+ ## Remediation and close
46
+
47
+ - **This stage is the exception to close-on-both-outcomes.** Nothing downstream waits on its close,
48
+ so the bead's own state is the report.
49
+ - `OUTCOME: SUCCESS` — append the notes and the progress log, then
50
+ `bd close <id> --reason="SUCCESS: <removed/kept summary>"`. Create nothing: this is the last stage
51
+ and its closed chore ends the pipeline.
52
+ - `OUTCOME: BLOCKED` — **do not close the bead.** Leave it open: return it to `open` status
53
+ (`bd update <id> --status open`) if your claim moved it to `in_progress`. Report the surviving
54
+ worktrees and hand the chore back to a human. Create no `stage:fix` chore and no fresh
55
+ `stage:housekeeping` chore — a fix round repairs files, and this stage repairs none.
56
+ - **No downstream re-block.** This stage sits behind the PR gate rather than behind another stage, so
57
+ no later stage needs wiring behind a fresh instance.
58
+ - Report `COMMIT: none`, `PUSHED: no`, and `PR: none`.
@@ -38,10 +38,19 @@ not a transcript.
38
38
  ```
39
39
  The outcome lives in the close reason, the milestone notes, and the REPORT. A closed bead
40
40
  with a `BLOCKED` reason is a completed stage, not abandoned work.
41
+
42
+ **Exception — `housekeeping`.** It closes its bead only when the pruner removed every
43
+ `closed-bead` worktree. On `BLOCKED` it leaves the bead **open** and builds nothing (see
44
+ _Remediation_ below).
41
45
  3. **Do not create the next stage on success.** Routing forward is the orchestrator's job.
42
46
 
43
47
  ## Remediation (only when your outcome is BLOCKED)
44
48
 
49
+ **Exception — `housekeeping` builds nothing.** It changes no files and sits last in the chain, so a
50
+ `stage:fix` chore and a fresh instance have nothing to repair and nothing to re-block. It leaves its
51
+ bead open, reports `OUTCOME: BLOCKED` with the surviving worktrees, and hands the chore to a human.
52
+ Every step below applies to every other stage.
53
+
45
54
  A fresh instance of this stage is required: the bead you are closing cannot re-run itself.
46
55
 
47
56
  1. **Cap check.** Read `maxFixRounds` from `skills/planning/create-task/pipeline.json` and count
@@ -89,6 +98,9 @@ dirty worktree counts as a failed stage whatever the outcome line claims.
89
98
  3. Report `COMMIT`, `PUSHED`, and `PR`. The `document` stage also opens or updates the feature PR
90
99
  and reports its URL; every earlier stage reports `PR: none`.
91
100
 
101
+ **Exception — `housekeeping`.** It changes no files, so it commits and pushes nothing. Report
102
+ `COMMIT: none` and `PUSHED: no` with `OUTCOME: SUCCESS`; never create an empty commit.
103
+
92
104
  ## Instructions
93
105
  1. Read and follow the playbook file listed above.
94
106
  2. Treat the bead as the source-of-truth specification (not a prompt script).
@@ -101,7 +113,7 @@ End your response with a `---REPORT---` block in exactly this format:
101
113
  ```
102
114
  ---REPORT---
103
115
  BEAD_ID: <id>
104
- STAGE_COMPLETED: <code|verify|review|document|fix>
116
+ STAGE_COMPLETED: <code|verify|review|document|housekeeping|fix>
105
117
  SUMMARY: <2-3 sentence summary of what was done>
106
118
  FILES_CHANGED: <comma-separated list, or "none">
107
119
  COMMIT: <sha of the stage commit, or "none">
@@ -51,10 +51,20 @@ rather than inventing a dotted form:
51
51
  `ask_user`) — and `task` / `read_agent` / `wait_for_agents` subagent tools, the last three
52
52
  supplied by this repo's own `extensions/subagent` rather than by pi; `skill` maps
53
53
  to `read` (skills load by reading the listed SKILL.md path).
54
- - **`paths`** — `plugin_root`, `agents_dir`, and `skills_dir`. These are the install-time
55
- variables that differ per harness. `plugin_root` is the anchor; the other two resolve
56
- relative to it (`{base, relative}` specs) so a harness only overrides the single root it
57
- actually installs into.
54
+ - **`paths`** — `plugin_root`, `agents_dir`, `skills_dir`, and `skill_anatomy`. These are the
55
+ install-time variables that differ per harness. `plugin_root` is the anchor; the others
56
+ resolve relative to it (`{base, relative}` specs) so a harness only overrides the single
57
+ root it actually installs into.
58
+ A path entry whose relative path sits under `docs/` names a document the install ships:
59
+ both installers copy it from the package root into the staged tree at that relative path
60
+ (`scripts/lib/docs.mjs`), so a skill can read it on a machine with no checkout. One copy
61
+ per install serves every skill that references it, and the token map decides both the
62
+ location and the shipment — `skill_anatomy` is read by both `write-a-skill` and
63
+ `refactor-skill`. The repo's `docs/` stays the single source of truth; the staged file is
64
+ a verbatim copy, never a second authored version.
65
+ A second shipped doc is declared twice: the `paths` entry sets its install location, and
66
+ `package.json` `files` must list the same path. The test stages from the checkout, so only
67
+ the `files` whitelist catches a doc that the published package omits.
58
68
  Claude's `plugin_root` stays the `${CLAUDE_PLUGIN_ROOT}` env-var token, never a literal
59
69
  `~/.agent-cortex/claude`: Claude Code **copies** marketplace-installed plugins — including
60
70
  local directory sources like `~/.agent-cortex/claude` — into its own cache, so only the
package/token-map.json CHANGED
@@ -39,6 +39,10 @@
39
39
  "skills_dir": {
40
40
  "claude": { "base": "plugin_root", "relative": "skills" },
41
41
  "pi": { "base": "plugin_root", "relative": "skills" }
42
+ },
43
+ "skill_anatomy": {
44
+ "claude": { "base": "plugin_root", "relative": "docs/skills/skill-anatomy.md" },
45
+ "pi": { "base": "plugin_root", "relative": "docs/skills/skill-anatomy.md" }
42
46
  }
43
47
  },
44
48
  "agents": {
@@ -75,7 +79,8 @@
75
79
  },
76
80
  "paths": {
77
81
  "pi": "plugin_root is the staged package `agent-cortex install pi` materialises at ~/.agent-cortex/pi; pi loads that directory by path and the shipped extensions resolve their own resources relative to it (agent-modes computes plugin_root from its actual install location). A checkout or symlinked install differs — installers compute plugin_root from the actual resolution and can override it (--plugin-root).",
78
- "claude": "plugin_root stays ${CLAUDE_PLUGIN_ROOT} (env var) for the materialised plugin — never a literal ~/.agent-cortex/claude. Claude Code copies marketplace-installed plugins (including local directory sources like ~/.agent-cortex/claude) into its own cache and expands the env var per context, so a literal home path in substituted files would break under cache-copy; the installer passes the env form through verbatim."
82
+ "claude": "plugin_root stays ${CLAUDE_PLUGIN_ROOT} (env var) for the materialised plugin — never a literal ~/.agent-cortex/claude. Claude Code copies marketplace-installed plugins (including local directory sources like ~/.agent-cortex/claude) into its own cache and expands the env var per context, so a literal home path in substituted files would break under cache-copy; the installer passes the env form through verbatim.",
83
+ "skill_anatomy": "A path entry whose relative path sits under docs/ names a document the install ships: both installers copy it from the package root into the staged tree at that relative path (scripts/lib/docs.mjs), so a skill can read it on a machine with no checkout. One copy per install serves every skill that references it — write-a-skill and refactor-skill both do. The repo's docs/ stays the single source of truth; the staged copy is a verbatim copy, never a second authored version."
79
84
  },
80
85
  "agents": {
81
86
  "prefix": "Claude strips the agent-cortex: name prefix during conversion (NAME_PREFIX in bin/installers/claude.mjs): pi agents are agent-cortex:<slug>, claude agents are <slug>."