@jaybeeuu/agent-cortex 2.4.1 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,60 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.5.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 8ca9462: New `cortex` and `ctx` commands: both run the same CLI as `agent-cortex`. The help text and the error hints now name the command that was invoked, so `ctx --help` prints `Usage: ctx <command>`; any other script name falls back to `agent-cortex`.
8
+ - 45992c7: `create-chores.mjs` is now idempotent: a re-run adopts the stage chores and PR gate that already exist under the parent, creates only what is missing, and skips deps that are already wired. Re-running it after a failed chore expansion is now the recovery path, so ralph no longer refuses to re-dispatch an expansion.
9
+
10
+ ### Patch Changes
11
+
12
+ - 2cab55e: Chunk the `migrate` v1 label rename: each `bd label add` and `bd label remove` call
13
+ now carries at most 20 bead ids. One call carrying every id in a large workspace
14
+ applied only part of the list and still reported success, so v1 never converged and a
15
+ workspace could report itself migrated with hundreds of legacy labels attached.
16
+ - dd4e69c: Fail `agent-cortex install pi` when a skill sits outside `skills/<group>/<name>`.
17
+
18
+ pi advertises a skill by the absolute path of its `SKILL.md`, and the installer copies exactly
19
+ `skills/<group>/<name>`. A skill authored deeper was skipped without a trace, so no session was
20
+ ever told about it. The install now stops and names the offending path.
21
+
22
+ `docs/dev-loop.md` records the related limit that needs no code: a session lists its skills when
23
+ it starts, so a session already open across a re-stage holds the paths it was given.
24
+
25
+ ## 2.4.2
26
+
27
+ ### Patch Changes
28
+
29
+ - 015ede5: Stop the Claude install from dropping `{{TOOL:read_agent}}` and `{{TOOL:wait_for_agents}}`
30
+ into dangling prose. Both tokens are null on the claude column (Claude reports a subagent's
31
+ result inline, so there is no separate read or wait step), so the installer replaced them with
32
+ an empty string and warned three times — leaving `Read the completed agent's output with and
33
+ parse…` and two similar broken sentences in the installed `ralph` and `design-an-interface`
34
+ skills. Those sentences are now harness-neutral, so both installs read correctly and the
35
+ installer emits no null-mapping warning. A regression guard fails the installer test if a
36
+ shipped skill names a tool the claude column cannot map.
37
+ - 199cefe: Point the shipped pi settings at models that still exist. `tinyModel.model` was
38
+ `opencode/mimo-v2.5-free`, which the provider catalogue has dropped, so the
39
+ setting resolved to nothing and pi fell back to another free model. That fallback
40
+ returns `429 FreeUsageLimitError` once the free quota is spent, and the 429 JSON
41
+ became the entire output of the background call that runs auto-naming and
42
+ notifications — so a subagent run died at session start while the agent itself was
43
+ on a paid model. `tinyModel.model` is now `opencode-go/deepseek-v4.1-flash`.
44
+ `enabledModels` also drops three ids that do not resolve — `opencode-go/deepseek-v4-flash`,
45
+ `opencode/deepseek-v4-flash` and `opencode/mimo-v2.5-free` — which made pi print a
46
+ `No models match pattern` warning on every startup.
47
+ - 6b6080b: Stop the test suite from leaking temp workspaces into `/tmp`. Each test file that
48
+ creates workspaces under the system temp dir now shares one temp root that a
49
+ file-level `after()` hook removes, so the removal happens whether the tests
50
+ passed, failed, or threw before reaching their own cleanup — previously a single
51
+ green run of `test/installer.test.mjs` left 21 workspaces behind (7.4 MB apparent,
52
+ 13 MB on disk: each is a materialised plugin tree of skills, agents and hooks),
53
+ plus one from `test/cli.test.mjs`'s `install claude --output` case. Repeated runs
54
+ could fill a small tmpfs and make unrelated tests fail with ENOSPC. A regression
55
+ guard runs a deliberately failing test file and asserts the run leaves nothing
56
+ behind.
57
+
3
58
  ## 2.4.1
4
59
 
5
60
  ### Patch Changes
package/README.md CHANGED
@@ -151,6 +151,11 @@ long-lived credential in repository secrets to save one click per release.
151
151
 
152
152
  ## Installation
153
153
 
154
+ The CLI installs under three names — `agent-cortex`, `cortex` and `ctx` — that run the
155
+ same command, and the help and error text names the one you invoked. That naming is POSIX
156
+ only: the npm and pnpm shims on Windows call `bin/agent-cortex.mjs` directly, so the text
157
+ falls back to `agent-cortex` there.
158
+
154
159
  ### Pi package staging (`agent-cortex install pi`)
155
160
 
156
161
  `agent-cortex install pi` materialises agent-cortex as a self-contained pi
@@ -480,7 +485,8 @@ is an empty legacy namespace, not just a clean live one (see
480
485
  A bare `agent-cortex migrate` is a **dry run** — it prints the per-bead plan and writes
481
486
  nothing. `--apply` runs every pending step in order and verifies each before moving on. It
482
487
  shells out to the `bd` CLI on `PATH` (`BD_PATH` overrides) and fails loudly, rather than
483
- half-applying, when `bd` is missing or a `bd` subcommand errors.
488
+ half-applying, when `bd` is missing or a `bd` subcommand errors. Label writes carry at most 20
489
+ ids per `bd` call, because `bd` can exit 0 having applied only part of a longer list.
484
490
 
485
491
  Detection reads `bd export --all` and cross-checks `bd label list-all`, never a
486
492
  label-filtered `bd list`: on bd 1.3.1 that query can omit closed beads, which once let a
@@ -77,7 +77,7 @@ A feature carries `execution:hitl` by default, so this whole procedure only runs
77
77
 
78
78
  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, then let the normal dispatch pick up the resulting `stage:code` chore:
79
79
 
80
- 1. Confirm the bead is chore-less: `bd children <parent-id> --json | grep -c '"stage:'` prints `0` — add `|| true` under `set -e` or inside an `&&` chain, since `grep -c` exits `1` on a zero count and the printed number is the answer. A bead that already has stage chores is never expanded again.
80
+ 1. Confirm the bead is chore-less: `bd children <parent-id> --json | grep -c '"stage:'` prints `0` — add `|| true` under `set -e` or inside an `&&` chain, since `grep -c` exits `1` on a zero count and the printed number is the answer. A bead with a full chain — one `stage:*` chore per stage in `pipeline.json`, plus the `lifecycle:feature-pr` gate — never needs an expansion. This check only sees a count of zero, so a **partial** chain is not detected here and its missing stages never arrive: re-run step 2's command against that parent by hand, which is idempotent.
81
81
  2. Dispatch an **expansion worker** in the feature worktree (`.agent-cortex/worktrees/<parent-id>`, created per the branching model if it is missing) whose whole job is to run the pre-creator against the existing bead and report the JSON it printed:
82
82
 
83
83
  ```bash
@@ -86,6 +86,8 @@ A ready `execution:afk` task bead with no `stage:*` chore children has nothing t
86
86
  ```
87
87
 
88
88
  `<create-task-scripts>` is the absolute path to the `create-task` skill's `scripts/` directory, resolved from wherever the skill was loaded. `<parent-priority>` is the parent bead's own priority from `bd show <parent-id>`.
89
+
90
+ The command is idempotent: it adopts an existing child by its `stage:<id>` or `lifecycle:feature-pr` label, creates only what is missing, skips deps that are already wired, and prints the resolved mapping either way. Re-running it is the recovery path for a failed expansion.
89
91
  3. Record the worker in `inflight` as an **expansion** entry — it has no chore to key on, so `choreId` is `null`:
90
92
  ```json
91
93
  { "kind": "expand", "choreId": null, "parentId": "<parent-id>", "title": "<parent title>", "agentId": "<agent-id>", "logLine": 1 }
@@ -94,7 +96,7 @@ A ready `execution:afk` task bead with no `stage:*` chore children has nothing t
94
96
  4. The worker reports the returned mapping — `code`, `verify`, `review`, `document`, `featurePrReview` — and closes nothing. It emits **no** `---REPORT---` block and owns no bead, so there is nothing to parse and no close to confirm.
95
97
  5. Its completion means the parent now has a `stage:*` chain: the event loop's next `bd ready -l execution:afk` pass picks up the `stage:code` chore (event loop step 7).
96
98
 
97
- **Expansion completion contract.** An expansion worker is dispatched like a stage worker but is not one: no chore claim, no `---REPORT---`, no leaf-bead close. Handle its completion with the expansion branch in event-loop step 3, which confirms the chain exists (`bd children <parent-id> --json | grep -c '"stage:'` prints a non-zero count) and dispatches the ready `stage:code` chore. If the parent still has no `stage:*` children the expansion failed: surface the worker's output in chat, **never re-run `create-chores.mjs`** (it is not idempotent — a second run duplicates the chain), and record the parent for the shutdown summary. Either way, create nothing and close nothing.
99
+ **Expansion completion contract.** An expansion worker is dispatched like a stage worker but is not one: no chore claim, no `---REPORT---`, no leaf-bead close. Handle its completion with the expansion branch in event-loop step 3, which confirms the chain exists (`bd children <parent-id> --json | grep -c '"stage:'` prints one match per stage chore — the gate carries `lifecycle:feature-pr` and is not counted) and dispatches the ready `stage:code` chore. A count below the stage total in `pipeline.json` is a partial chain, whose missing stages never arrive, so re-dispatch the expansion instead of dispatching on. If the parent still has no `stage:*` children the expansion failed: surface the worker's output in chat, record the parent for the shutdown summary, and re-dispatch the expansion — `create-chores.mjs` is idempotent, so a second run adopts the chores that exist, creates only the missing ones, and reports the whole mapping. Either way, create nothing and close nothing yourself.
98
100
 
99
101
  **Never expand an existing bead by running `create-task`** (or any other command that creates a bead). `create-task` creates a bead first and expands second, so running it for an existing bead leaves a **second parent** beside the original, which stays chore-less and never gets dispatched. `create-chores.mjs` expands a bead that already exists; it never creates parents.
100
102
 
@@ -165,8 +167,8 @@ After initialization, Ralph waits for background agents to complete. Completions
165
167
  1. **Flush** that bead's log using the procedure in the wake section to pick up any final lines for the chat summary (a mechanism that delivers the log with the completion needs no flush).
166
168
  2. **Read** the completed agent's full output the way the wake section describes.
167
169
  3. **Re-read** `.agent-cortex/ralph/state.json` to identify the entry for that agent ID, then branch on its `kind`:
168
- - **`kind: "expand"` — expansion completion branch.** The worker claimed no chore, emits no `---REPORT---`, and owns no bead to confirm, so steps 4-6 **do not apply**: remove the entry from `inflight`, confirm the parent now has `stage:*` chore children (`bd children <parent-id> --json | grep -c '"stage:'`) and surface the reported mapping in chat, then go straight to step 7 — the new `stage:code` chore is ready to dispatch. If the parent still has no `stage:*` children the expansion failed: surface the worker's output, do **not** re-run `create-chores.mjs` (not idempotent), record the parent for the shutdown summary, remove the entry, and continue at step 7. Never create or close a bead for an expansion.
169
- - **`kind: "decompose"` — decomposition completion branch.** Same shape as expansion: no chore, no `---REPORT---`, so steps 4-6 **do not apply**: remove the entry from `inflight`, confirm the feature now has children (`bd children <feature-id> --json` returns a non-empty list) and surface the created IDs in chat, then go straight to step 7. Nothing is dispatchable for that feature until a human closes the planning bead, so finding no ready work for it is the correct outcome — do **not** re-dispatch the decomposition (the bead creation it ran is not idempotent). If the feature still has no children, the worker failed: surface its output, record it for the shutdown summary, remove the entry, and continue at step 7. Never create or close a bead here.
170
+ - **`kind: "expand"` — expansion completion branch.** The worker claimed no chore, emits no `---REPORT---`, and owns no bead to confirm, so steps 4-6 **do not apply**: remove the entry from `inflight`, confirm the parent now has `stage:*` chore children (`bd children <parent-id> --json | grep -c '"stage:'`) and surface the reported mapping in chat, then go straight to step 7 — the new `stage:code` chore is ready to dispatch. If the count is `0` — or below the stage total, which is a partial chain whose later stages never arrive — the expansion failed: surface the worker's output, record the parent for the shutdown summary, remove the entry, and re-dispatch the expansion — re-running `create-chores.mjs` is idempotent and is the recovery path, so it repairs the tree instead of duplicating it. Never create or close a bead for an expansion.
171
+ - **`kind: "decompose"` — decomposition completion branch.** Same shape as expansion: no chore, no `---REPORT---`, so steps 4-6 **do not apply**: remove the entry from `inflight`, confirm the feature now has children (`bd children <feature-id> --json` returns a non-empty list) and surface the created IDs in chat, then go straight to step 7. Nothing is dispatchable for that feature until a human closes the planning bead, so finding no ready work for it is the correct outcome — do **not** re-dispatch the decomposition (the bead creation it ran is not idempotent; that hazard is `create-task`'s, not the expansion's). If the feature still has no children, the worker failed: surface its output, record it for the shutdown summary, remove the entry, and continue at step 7. Never create or close a bead here.
170
172
  - **`kind: "stage"`** — a stage worker: continue with steps 4-6.
171
173
  4. **Parse** the agent's `---REPORT---` block (see report format in the `run-pipeline-stage` skill).
172
174
  5. **Confirm** the worker closed its own leaf execution bead: `bd show <chore-id>` should read `closed`. If it is still open the worker died mid-stage — read its notes and re-dispatch. Never close it yourself.
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { parseArgs, buildHelpText, validateHarness } from "../lib/cli.mjs";
3
+ import { parseArgs, buildHelpText, resolveProgramName, validateHarness } from "../lib/cli.mjs";
4
4
  import { join, dirname } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { readFile } from "node:fs/promises";
@@ -10,10 +10,12 @@ import { installPi, registerPi, sweepLegacyPiUserDirs } from "./installers/pi.mj
10
10
  /** @typedef {import("./installers/pi.mjs").RegisterPiResult} RegisterPiResult */
11
11
 
12
12
  const argv = process.argv.slice(2);
13
- const parsed = parseArgs(argv);
13
+ // `agent-cortex`, `cortex` and `ctx` all run this file; the text names the one used.
14
+ const programName = resolveProgramName(process.argv[1]);
15
+ const parsed = parseArgs(argv, programName);
14
16
 
15
17
  if (parsed.command === "help") {
16
- process.stdout.write(buildHelpText() + "\n");
18
+ process.stdout.write(buildHelpText(programName) + "\n");
17
19
  process.exit(0);
18
20
  }
19
21
 
@@ -53,7 +55,7 @@ if (parsed.command === "version") {
53
55
  }
54
56
 
55
57
  if (parsed.command === "unknown") {
56
- process.stderr.write(`Unknown command: ${parsed.name}\nRun "agent-cortex --help" for usage.\n`);
58
+ process.stderr.write(`Unknown command: ${parsed.name}\nRun "${programName} --help" for usage.\n`);
57
59
  process.exit(1);
58
60
  }
59
61
 
@@ -65,7 +67,7 @@ if (parsed.command === "install") {
65
67
 
66
68
  const check = validateHarness(parsed.harness);
67
69
  if (!check.ok) {
68
- process.stderr.write(`${check.error}\nRun "agent-cortex --help" for usage.\n`);
70
+ process.stderr.write(`${check.error}\nRun "${programName} --help" for usage.\n`);
69
71
  process.exit(1);
70
72
  }
71
73
 
@@ -395,7 +395,19 @@ async function installSkills({ root, skillsDir, dryRun, pluginRoot, tokenMap, wa
395
395
  const groupDir = join(skillsSrc, group);
396
396
  if (!(await isDirectory(groupDir))) continue;
397
397
  for (const name of (await readdir(groupDir)).sort()) {
398
- if (!(await isFile(join(groupDir, name, "SKILL.md")))) continue;
398
+ if (!(await isFile(join(groupDir, name, "SKILL.md")))) {
399
+ // pi advertises a skill by the absolute path of its SKILL.md, and this
400
+ // installer carries exactly skills/<group>/<name>. A skill authored
401
+ // below that depth would be dropped without a trace, so fail instead.
402
+ const nested = await findSkillFiles(join(groupDir, name));
403
+ if (nested.length > 0) {
404
+ throw new Error(
405
+ `skill "${nested[0]}" is not at skills/<group>/<name>/SKILL.md — ` +
406
+ "the installer copies exactly that depth, so pi would never advertise this skill",
407
+ );
408
+ }
409
+ continue;
410
+ }
399
411
  skills += 1;
400
412
  const stats = await copyTree(join(groupDir, name), join(skillsDir, group, name), { dryRun, transform });
401
413
  md += stats.md;
@@ -405,6 +417,28 @@ async function installSkills({ root, skillsDir, dryRun, pluginRoot, tokenMap, wa
405
417
  return { skills, md, files, dir: skillsDir };
406
418
  }
407
419
 
420
+ /**
421
+ * Every `SKILL.md` below `dir`, recursively — the skills pi would advertise if
422
+ * its discovery walked the repository tree instead of the staged one.
423
+ * @param {string} dir
424
+ * @returns {Promise<string[]>}
425
+ */
426
+ async function findSkillFiles(dir) {
427
+ /** @type {string[]} */
428
+ const found = [];
429
+ let entries;
430
+ try {
431
+ entries = await readdir(dir, { withFileTypes: true });
432
+ } catch {
433
+ return found;
434
+ }
435
+ for (const entry of entries) {
436
+ if (entry.isDirectory()) found.push(...(await findSkillFiles(join(dir, entry.name))));
437
+ else if (entry.isFile() && entry.name === "SKILL.md") found.push(join(dir, entry.name));
438
+ }
439
+ return found;
440
+ }
441
+
408
442
  // ─── Extensions ──────────────────────────────────────────────────────────────
409
443
 
410
444
  /**
package/lib/cli.mjs CHANGED
@@ -1,3 +1,5 @@
1
+ import { basename } from "node:path";
2
+
1
3
  /**
2
4
  * Result of parsing the CLI argv. Every branch returns `command`; the rest are
3
5
  * populated only for the commands that accept them.
@@ -18,6 +20,24 @@
18
20
 
19
21
  const SUPPORTED_HARNESSES = ["claude", "pi"];
20
22
 
23
+ // The published bin names. `package.json` maps all three onto bin/agent-cortex.mjs,
24
+ // so the help and error text names whichever shim the user typed.
25
+ const PROGRAM_NAMES = ["agent-cortex", "cortex", "ctx"];
26
+ const DEFAULT_PROGRAM_NAME = "agent-cortex";
27
+
28
+ /**
29
+ * Resolve the program name to print from the path the CLI was invoked as. Only
30
+ * the published command names are recognised; anything else (a bare `node
31
+ * bin/agent-cortex.mjs`, a renamed copy) falls back to `agent-cortex`, so the
32
+ * text always names a command that exists.
33
+ * @param {string | undefined} invokedPath `process.argv[1]`, or a bare name in tests
34
+ * @returns {string}
35
+ */
36
+ export function resolveProgramName(invokedPath) {
37
+ const name = basename(invokedPath ?? "");
38
+ return PROGRAM_NAMES.includes(name) ? name : DEFAULT_PROGRAM_NAME;
39
+ }
40
+
21
41
  // Options accepted by `install <harness>`. `--output`/`--dry-run` are shared by
22
42
  // the claude and pi installers; `--plugin-root` is pi-specific. Unknown or
23
43
  // malformed options surface as `optionError` on the parsed result; the CLI
@@ -33,9 +53,10 @@ const INSTALL_OPTIONS = {
33
53
  /**
34
54
  * Parse the CLI argv.
35
55
  * @param {string[]} argv
56
+ * @param {string} [programName] The resolved command name, for usage hints
36
57
  * @returns {ParsedArgs}
37
58
  */
38
- export function parseArgs(argv) {
59
+ export function parseArgs(argv, programName = DEFAULT_PROGRAM_NAME) {
39
60
  if (argv.length === 0 || argv[0] === "--help" || argv[0] === "-h") {
40
61
  return { command: "help" };
41
62
  }
@@ -64,7 +85,7 @@ export function parseArgs(argv) {
64
85
  const inlineValue = arg.startsWith("--output=") ? arg.slice("--output=".length) : null;
65
86
  const spec = inlineValue !== null ? INSTALL_OPTIONS["--output"] : INSTALL_OPTIONS[arg];
66
87
  if (!spec) {
67
- return { command: "install", harness, optionError: `Unknown option "${arg}". Run "agent-cortex --help" for usage.` };
88
+ return { command: "install", harness, optionError: `Unknown option "${arg}". Run "${programName} --help" for usage.` };
68
89
  }
69
90
  if (spec.boolean) {
70
91
  options[spec.key] = true;
@@ -76,7 +97,7 @@ export function parseArgs(argv) {
76
97
  }
77
98
  const value = rest[i + 1];
78
99
  if (value == null || value.startsWith("--")) {
79
- return { command: "install", harness, optionError: `Missing value for "${arg}". Run "agent-cortex --help" for usage.` };
100
+ return { command: "install", harness, optionError: `Missing value for "${arg}". Run "${programName} --help" for usage.` };
80
101
  }
81
102
  options[spec.key] = value;
82
103
  i += 1;
@@ -116,13 +137,13 @@ export function parseArgs(argv) {
116
137
  if (arg === "--dir" || inlineDir !== null) {
117
138
  const value = rest[i + 1];
118
139
  if (inlineDir !== null || value == null || value.startsWith("--")) {
119
- return { command: "migrate", optionError: `Missing value for "--dir". Run "agent-cortex --help" for usage.` };
140
+ return { command: "migrate", optionError: `Missing value for "--dir". Run "${programName} --help" for usage.` };
120
141
  }
121
142
  dir = value;
122
143
  i += 1;
123
144
  continue;
124
145
  }
125
- return { command: "migrate", optionError: `Unknown option "${arg}". Run "agent-cortex --help" for usage.` };
146
+ return { command: "migrate", optionError: `Unknown option "${arg}". Run "${programName} --help" for usage.` };
126
147
  }
127
148
  if (sawApply && sawDryRun) {
128
149
  return { command: "migrate", optionError: '"--apply" and "--dry-run" are mutually exclusive.' };
@@ -134,10 +155,11 @@ export function parseArgs(argv) {
134
155
  }
135
156
 
136
157
  /**
158
+ * @param {string} [programName] The resolved command name, for the usage line
137
159
  * @returns {string}
138
160
  */
139
- export function buildHelpText() {
140
- return `Usage: agent-cortex <command> [options]
161
+ export function buildHelpText(programName = DEFAULT_PROGRAM_NAME) {
162
+ return `Usage: ${programName} <command> [options]
141
163
 
142
164
  Commands:
143
165
  install <harness> Install agent-cortex for a harness
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jaybeeuu/agent-cortex",
3
- "version": "2.4.1",
3
+ "version": "2.5.0",
4
4
  "description": "Personal PI package with custom agents, skills, and extensions",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -11,7 +11,9 @@
11
11
  "url": "https://github.com/jaybeeuu/agent-cortex"
12
12
  },
13
13
  "bin": {
14
- "agent-cortex": "./bin/agent-cortex.mjs"
14
+ "agent-cortex": "./bin/agent-cortex.mjs",
15
+ "cortex": "./bin/agent-cortex.mjs",
16
+ "ctx": "./bin/agent-cortex.mjs"
15
17
  },
16
18
  "files": [
17
19
  "bin/",
@@ -40,6 +40,15 @@ const EXECUTION_LABEL_PREFIX = "execution:";
40
40
  */
41
41
  const V1_LABEL_VALUES = ["afk", "hitl"];
42
42
 
43
+ /**
44
+ * Ids per `bd label add` / `bd label remove` call. bd applies a label change id
45
+ * by id, so one call carrying every id in a large workspace takes minutes and
46
+ * can report success having persisted only part of the list — which is how a
47
+ * 710-bead rename silently left 280 beads unrenamed. A chunk keeps each call
48
+ * small enough to apply in full.
49
+ */
50
+ const LABEL_BATCH_SIZE = 20;
51
+
43
52
  /**
44
53
  * Where loose idea records live, relative to the working directory. This is the
45
54
  * one path the command assumes, and it is deliberately the directory every
@@ -262,9 +271,27 @@ async function findLegacyLabelBeads({ bd }) {
262
271
  return pending.sort((a, b) => a.id.localeCompare(b.id));
263
272
  }
264
273
 
274
+ /**
275
+ * Split `items` into consecutive runs of at most `size` — one `bd` call's worth.
276
+ *
277
+ * `size` must be positive: a size of 0 never advances the loop.
278
+ *
279
+ * @template T
280
+ * @param {T[]} items
281
+ * @param {number} size
282
+ * @returns {T[][]}
283
+ */
284
+ function chunkItems(items, size) {
285
+ /** @type {T[][]} */
286
+ const chunks = [];
287
+ for (let start = 0; start < items.length; start += size) chunks.push(items.slice(start, start + size));
288
+ return chunks;
289
+ }
290
+
265
291
  /**
266
292
  * Apply the label rename in batches: add the target label for a group of beads,
267
293
  * then remove the legacy one, so a bead is never left without its axis label.
294
+ * Both writes carry one chunk of ids, never the whole workspace.
268
295
  *
269
296
  * @param {StepContext} ctx
270
297
  * @param {Pending[]} pending
@@ -283,8 +310,10 @@ async function renameLegacyLabels({ bd }, pending) {
283
310
 
284
311
  for (const from of [...groups.keys()].sort()) {
285
312
  const { to, ids } = /** @type {{ to: string, ids: string[] }} */ (groups.get(from));
286
- await bd(["label", "add", ...ids, to]);
287
- await bd(["label", "remove", ...ids, from]);
313
+ for (const chunk of chunkItems(ids, LABEL_BATCH_SIZE)) {
314
+ await bd(["label", "add", ...chunk, to]);
315
+ await bd(["label", "remove", ...chunk, from]);
316
+ }
288
317
  }
289
318
  }
290
319
 
@@ -55,7 +55,7 @@ description: "Generate multiple radically different interface designs for a modu
55
55
  4. Trade-offs of this approach
56
56
  ```
57
57
 
58
- Collect each agent's output with {{TOOL:read_agent}} when it finishes.
58
+ Collect each agent's output as it finishes.
59
59
 
60
60
  3. **Present designs.** Show each design sequentially — signature, usage examples, what it hides — so the user absorbs each approach before any comparison happens.
61
61
 
@@ -10,7 +10,9 @@ node <skill-scripts>/create-chores.mjs --parent <bead-id> [--priority <0-4>]
10
10
 
11
11
  - Reads `<skill>/pipeline.json`; creates each stage chore via `bd create` and wires dependencies via `bd dep add`.
12
12
  - Prints a JSON object mapping each stage ID to its chore bead ID, plus `featurePrReview` for the PR gate.
13
- - Expands a bead that already exists — it never creates a parent. It is also not idempotent: running it against a bead that already has stage chores creates a second chain, so callers check for existing `stage:*` children first (ralph's expansion guard).
13
+ - Expands a bead that already exists — it never creates a parent.
14
+ - **Idempotent**: it reads that parent's children once (`bd children <parent-id> --json`) and adopts an existing child by its role label — `stage:<id>` for a stage chore, `lifecycle:feature-pr` for the gate — creating only what is missing. Deps that are already wired are skipped. A re-run on an expanded bead creates nothing and prints the same mapping, so re-running after a failed expansion is the recovery path.
15
+ - Adoption ignores bead status and reads only labels: a chore a later stage already closed is still adopted and reprinted in the mapping, which keeps a re-run convergent instead of creating a duplicate. When a role label appears twice under one parent the first-listed child wins. Adoption is scoped to that parent's children and keyed on labels, never titles, so a stray chore from another bead is never adopted.
14
16
 
15
17
  ## Stage chore beads
16
18
 
@@ -81,7 +81,7 @@ Turn a piece of work into a tracked bead, classify it for autonomous (AFK) or hu
81
81
 
82
82
  The planning bead is cold-start ready — what and why, decisions already made, open questions and risks, references — and needs `## Acceptance Criteria`, which `--validate` requires for type `task` (template in `REFERENCE.md` → Hierarchy). A deliverable that genuinely needs a human — visual sign-off, credentials, hardware — is created `-l execution:hitl` and stops there.
83
83
 
84
- 7. **Expand into pipeline stage chores.** Run the `create-chores` script, which reads this skill's `pipeline.json` and deterministically creates all stage chores plus the PR gate bead in one shot:
84
+ 7. **Expand into pipeline stage chores.** Run the `create-chores` script, which reads this skill's `pipeline.json` and creates the stage chores plus the PR gate bead — adopting any the parent already has, so a re-run converges on the existing tree instead of duplicating it:
85
85
 
86
86
  ```bash
87
87
  # Run from the TARGET project's directory — bd resolves its beads DB from cwd;
@@ -92,13 +92,15 @@ Turn a piece of work into a tracked bead, classify it for autonomous (AFK) or hu
92
92
 
93
93
  `<skill-scripts>` is the absolute path to this skill's own `scripts/` directory, resolved from wherever this skill was loaded — it varies by harness and install location, so never hardcode one harness's path.
94
94
 
95
+ The script is idempotent: it adopts an existing child by its `stage:<id>` or `lifecycle:feature-pr` label, creates only what is missing, and skips deps that are already wired. If an expansion fails partway, re-run this command — it completes the partial chain and prints the full mapping.
96
+
95
97
  On the feature path, run it **once per AFK impl child**, with `--parent <impl-task-id>` — never the feature ID: stage chores inherit their parent's labels, the script checks only that it was handed a parent ID (`REFERENCE.md` → PR gate bead), so an `execution:hitl` feature parent yields a chore tree no executor routes. A deliverable created `execution:hitl` in step 6 is not expanded.
96
98
 
97
99
  8. **Report.** Return the bead ID (or, on the feature path, the feature ID plus every planning and impl child ID), the classification of each expanded bead (`AFK`), and the created stage chore IDs from the script output (including `featurePrReview`) — one mapping per expanded impl child on the feature path.
98
100
 
99
101
  ### Expanding an existing bead
100
102
 
101
- Steps 2–6 create beads; step 7 expands one. A bead that already exists but has no `stage:*` chore children — an AFK task filed before the pipeline existed, or one ralph found chore-less — is a supported entry point: run step 7's `create-chores.mjs --parent <existing-id> --priority <its-own-priority>` directly, then report the mapping. No create steps, no new description, no re-classification.
103
+ Steps 2–6 create beads; step 7 expands one. A bead that already exists but has no `stage:*` chore children — an AFK task filed before the pipeline existed, or one ralph found chore-less — is a supported entry point: run step 7's `create-chores.mjs --parent <existing-id> --priority <its-own-priority>` directly, then report the mapping. No create steps, no new description, no re-classification. A partial chain from a failed run is repaired the same way: re-running adopts the chores that exist, creates the missing ones, and reparents nothing.
102
104
 
103
105
  **Never re-run `create-task` for an existing bead.** It creates a bead first and expands second, so the run leaves a **second parent** beside the original, which stays chore-less — the duplicate ralph's expansion guard exists to prevent. `create-chores.mjs` expands a bead that already exists; only step 2 creates a bead.
104
106
 
@@ -110,6 +112,7 @@ Steps 2–6 create beads; step 7 expands one. A bead that already exists but has
110
112
  - **Skipping the classifier and classifying by hand.** The rubric is a subagent's job; a hand-rolled guess drifts from it.
111
113
  - **Expanding an HITL bead.** If classification is HITL, stop — creating a chore tree for human-driven work is wrong.
112
114
  - **Expanding an existing bead by re-running `create-task`.** That step creates a bead, so the original stays chore-less and gains a second parent beside it. Run `create-chores.mjs --parent <existing-id>` instead.
115
+ - **Treating a failed expansion as unrecoverable.** `create-chores.mjs` is idempotent: re-run it and it adopts the chores that exist, creates the rest, and reports the whole mapping.
113
116
  - **Hardcoding `<skill-scripts>`.** The install path differs per harness (plugin cache, installed-plugins, dev checkout); resolve it at runtime.
114
117
  - **Creating a feature with no planning bead.** A feature that goes straight to impl children is a feature nobody designed. The planning bead keeps the children blocked until the design is confirmed.
115
118
  - **Expressing ownership as a dependency.** `bd dep add <child> <parent>` mixes hierarchy into the readiness gate; pass `--parent` at create time instead.
@@ -2,6 +2,11 @@
2
2
  // Pre-create the pipeline stage chores for a task bead, plus the feature PR
3
3
  // review gate, from `pipeline.json`.
4
4
  //
5
+ // Idempotent: a re-run converges on the tree that already exists instead of adding a
6
+ // second chain beside it, so re-running after a failed expansion is the recovery path.
7
+ // Existing children are adopted by their role label (`stage:<id>`, `lifecycle:feature-pr`)
8
+ // under the given parent — never by title, and never across parents.
9
+ //
5
10
  // The parent is an `execution:afk` bead. Stage chores inherit that label from it, so
6
11
  // create-chores does not classify: a parent that is hitl or unlabelled silently yields
7
12
  // a chore tree no executor routes. Check the parent's labels before invoking this.
@@ -69,6 +74,66 @@ function run(args) {
69
74
  return execSync(cmd.join(' '), { encoding: 'utf-8' }).trim();
70
75
  }
71
76
 
77
+ // ─── Existing children ────────────────────────────────────────────────────────
78
+
79
+ /**
80
+ * @typedef {object} Dependency
81
+ * @property {string} depends_on_id
82
+ * @property {string} type
83
+ */
84
+
85
+ /**
86
+ * A child of the parent that already fills one of this script's roles.
87
+ * @typedef {object} Adoptable
88
+ * @property {string} id
89
+ * @property {Set<string>} blocks Beads it already depends on with a `blocks` dep
90
+ */
91
+
92
+ /**
93
+ * Read the parent's children once and index the ones this script owns by their role
94
+ * label. Scoped to that parent's children, so a stray chore belonging to another bead
95
+ * is never adopted. When a role label appears more than once the first-listed child
96
+ * wins, so a converged re-run is deterministic.
97
+ * @param {string} parentId
98
+ * @returns {Map<string, Adoptable>} Keyed by `stage:<id>` or `lifecycle:feature-pr`
99
+ */
100
+ function readAdoptable(parentId) {
101
+ const raw = run(['bd', 'children', parentId, '--json']);
102
+ /** @type {Array<{ id: string, labels?: string[], dependencies?: Dependency[] }>} */
103
+ const children = raw === '' ? [] : JSON.parse(raw);
104
+
105
+ /** @type {Map<string, Adoptable>} */
106
+ const adoptable = new Map();
107
+
108
+ for (const child of children) {
109
+ const blocks = new Set(
110
+ (child.dependencies ?? [])
111
+ .filter((dep) => dep.type === 'blocks')
112
+ .map((dep) => dep.depends_on_id),
113
+ );
114
+
115
+ for (const label of child.labels ?? []) {
116
+ if (label !== 'lifecycle:feature-pr' && !label.startsWith('stage:')) { continue; }
117
+ if (!adoptable.has(label)) { adoptable.set(label, { id: child.id, blocks }); }
118
+ }
119
+ }
120
+
121
+ return adoptable;
122
+ }
123
+
124
+ /**
125
+ * Wire a `blocks` dependency unless the bead already carries it. Re-adding an existing
126
+ * dep is the failure mode a re-run must not hit, so existing deps are skipped.
127
+ * @param {string} beadId
128
+ * @param {string} dependsOnId
129
+ * @param {Set<string>} blocks Dependencies the bead already has; extended in place
130
+ */
131
+ function wireBlocks(beadId, dependsOnId, blocks) {
132
+ if (blocks.has(dependsOnId)) { return; }
133
+ run(['bd', 'dep', 'add', beadId, dependsOnId, '--type', 'blocks']);
134
+ blocks.add(dependsOnId);
135
+ }
136
+
72
137
  // ─── Main ─────────────────────────────────────────────────────────────────────
73
138
 
74
139
  const __filename = fileURLToPath(import.meta.url);
@@ -80,31 +145,38 @@ const pipelinePath = resolve(__dirname, '..', 'pipeline.json');
80
145
  /** @type {Pipeline} */
81
146
  const pipeline = JSON.parse(readFileSync(pipelinePath, 'utf-8'));
82
147
 
148
+ const adoptable = readAdoptable(parentId);
149
+
83
150
  /** @type {Map<string, string>} */
84
151
  const stageToBeadId = new Map();
85
152
 
86
153
  for (const stage of pipeline.stages) {
87
- const title = `[${parentId}] ${stage.title}`;
88
-
89
- const beadId = run([
90
- 'bd', 'create', JSON.stringify(title),
91
- '--type', 'chore',
92
- '--description', JSON.stringify(stage.description),
93
- '--priority', priority,
94
- '--labels', `stage:${stage.id}`,
95
- '--parent', parentId,
96
- '--silent',
97
- ]);
154
+ const adopted = adoptable.get(`stage:${stage.id}`);
155
+
156
+ const beadId = adopted
157
+ ? adopted.id
158
+ : run([
159
+ 'bd', 'create', JSON.stringify(`[${parentId}] ${stage.title}`),
160
+ '--type', 'chore',
161
+ '--description', JSON.stringify(stage.description),
162
+ '--priority', priority,
163
+ '--labels', `stage:${stage.id}`,
164
+ '--parent', parentId,
165
+ '--silent',
166
+ ]);
98
167
 
99
168
  stageToBeadId.set(stage.id, beadId);
100
169
 
170
+ /** @type {Set<string>} */
171
+ const blocks = adopted ? adopted.blocks : new Set();
172
+
101
173
  for (const depStageId of stage.dependsOn) {
102
174
  const depBeadId = stageToBeadId.get(depStageId);
103
175
  if (!depBeadId) {
104
176
  console.error(`Stage '${stage.id}' depends on '${depStageId}', but it has not been created yet. Check dependsOn ordering in pipeline.json.`);
105
177
  process.exit(1);
106
178
  }
107
- run(['bd', 'dep', 'add', beadId, depBeadId, '--type', 'blocks']);
179
+ wireBlocks(beadId, depBeadId, blocks);
108
180
  }
109
181
  }
110
182
 
@@ -120,26 +192,33 @@ if (!documentStageBeadId) {
120
192
  process.exit(1);
121
193
  }
122
194
 
123
- const prGateBeadId = run([
124
- 'bd', 'create', JSON.stringify(`[${parentId}] PR Review and Merge`),
125
- '--type', 'task',
126
- '--description', JSON.stringify(
127
- 'Human review gate: review and merge the agent PR into the feature branch before closing this feature.\n\n## Acceptance Criteria\n\n- [ ] Agent PR reviewed and merged into the feature branch.',
128
- ),
129
- // The gate is type `task`, so `--validate` enforces its `## Acceptance Criteria`
130
- // section. Stage chores require no sections and deliberately omit the flag.
131
- '--validate',
132
- '--priority', priority,
133
- '--labels', 'execution:hitl,lifecycle:feature-pr',
134
- '--parent', parentId,
135
- // The gate declares its own execution class (it is human-owned review work), so opt
136
- // out of bd's create-time parent-label inheritance and its explicit :hitl is never
137
- // joined by an inherited :afk. Stage chores deliberately keep inheriting the parent's
138
- // :afk -- do not add this flag there.
139
- '--no-inherit-labels',
140
- '--silent',
141
- ]);
142
- run(['bd', 'dep', 'add', prGateBeadId, documentStageBeadId, '--type', 'blocks']);
195
+ const adoptedGate = adoptable.get('lifecycle:feature-pr');
196
+
197
+ const prGateBeadId = adoptedGate
198
+ ? adoptedGate.id
199
+ : run([
200
+ 'bd', 'create', JSON.stringify(`[${parentId}] PR Review and Merge`),
201
+ '--type', 'task',
202
+ '--description', JSON.stringify(
203
+ 'Human review gate: review and merge the agent PR into the feature branch before closing this feature.\n\n## Acceptance Criteria\n\n- [ ] Agent PR reviewed and merged into the feature branch.',
204
+ ),
205
+ // The gate is type `task`, so `--validate` enforces its `## Acceptance Criteria`
206
+ // section. Stage chores require no sections and deliberately omit the flag.
207
+ '--validate',
208
+ '--priority', priority,
209
+ '--labels', 'execution:hitl,lifecycle:feature-pr',
210
+ '--parent', parentId,
211
+ // The gate declares its own execution class (it is human-owned review work), so opt
212
+ // out of bd's create-time parent-label inheritance and its explicit :hitl is never
213
+ // joined by an inherited :afk. Stage chores deliberately keep inheriting the parent's
214
+ // :afk -- do not add this flag there.
215
+ '--no-inherit-labels',
216
+ '--silent',
217
+ ]);
218
+
219
+ /** @type {Set<string>} */
220
+ const gateBlocks = adoptedGate ? adoptedGate.blocks : new Set();
221
+ wireBlocks(prGateBeadId, documentStageBeadId, gateBlocks);
143
222
 
144
223
  /** @type {Record<string, string>} */
145
224
  const result = {
@@ -77,10 +77,16 @@ Mechanics the plan reflects:
77
77
  2. Group bead ids by label value. A bead carrying both `afk` and `hitl` appears in both groups;
78
78
  that is a data problem worth inspecting, not a conflict the tool resolves for you.
79
79
  3. Per group, on `--apply`: `bd label add <ids…> execution:<value>` then
80
- `bd label remove <ids…> implementation-type:<value>`. Add-then-remove keeps the routing label
81
- present at every instant, so nothing is unroutable mid-flight.
80
+ `bd label remove <ids…> implementation-type:<value>`, at most 20 ids per call. Add-then-remove
81
+ keeps the routing label present at every instant, so nothing is unroutable mid-flight.
82
82
  4. Re-running after convergence finds zero targets — the no-op that proves idempotence.
83
83
 
84
+ The 20-id ceiling is a `bd` limit, not a preference. `bd label add|remove` applies a list id by
85
+ id and exits 0 even when it persisted only a prefix of it: one call carrying 710 ids left 280
86
+ beads unrenamed while reporting success. Every call in this skill therefore carries one chunk of
87
+ 20 ids, and the per-step verification re-detects from the export instead of trusting the exit
88
+ code. Any hand-run recipe below needs the same ceiling.
89
+
84
90
  Verification commands:
85
91
 
86
92
  ```bash
@@ -153,7 +159,8 @@ inversion.
153
159
 
154
160
  Every `bd label` call in this skill passes issue ids first and the label last, matching the CLI's
155
161
  `bd label add [issue-id...] [label[,label...]]`. Reversing that order fails: the ids are resolved
156
- before the trailing argument is read as a label.
162
+ before the trailing argument is read as a label. Keep each hand-run call to about 20 ids for the
163
+ same reason the command chunks: a longer list exits 0 having applied only part of itself.
157
164
 
158
165
  ```bash
159
166
  # Invert the v1 rename for a specific group
@@ -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. 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 — 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,11 @@ 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.
60
+ status. A bead with a full chain — one `stage:*` chore per stage in `pipeline.json` (four for the
61
+ shipped pipeline) plus the `lifecycle:feature-pr` gate — never needs an expansion. The check
62
+ answers "no chores at all", so a **partial** chain (a lower count from a failed run) passes it
63
+ and its missing stages never arrive. Nothing detects that automatically: re-run step 2's command
64
+ against that parent by hand, which is idempotent.
61
65
  2. **Dispatch an expansion worker** in the feature worktree (`.agent-cortex/worktrees/<parent-id>`) whose whole job is to run the pre-creator against the existing bead and report the JSON it printed:
62
66
  ```bash
63
67
  node <create-task-scripts>/create-chores.mjs \
@@ -93,8 +97,11 @@ On completion, remove the entry from `inflight` and confirm the chore tree exist
93
97
  bd children <parent-id> --json | grep -c '"stage:'
94
98
  ```
95
99
 
96
- Expect a non-zero count — one match per `stage:*` label in the chain. A printed `0` means the
97
- expansion failed. Surface the worker's output in chat and record the parent for the shutdown summary — **never re-dispatch the expansion**, because `create-chores.mjs` is not idempotent and a second run duplicates the chain beside the first. Then continue at the normal dispatch pass: either way nothing is created and nothing is closed by ralph.
100
+ Expect one match per `stage:*` label in the chain — four for the shipped `pipeline.json`; the
101
+ `featurePrReview` gate carries `lifecycle:feature-pr` instead, so it is not counted. A lower count
102
+ means the expansion did not finish: `0` created nothing, and a partial chain leaves stages that
103
+ will never become ready. Either way, surface the worker's output in chat, record the parent for the
104
+ shutdown summary, and re-dispatch the expansion: `create-chores.mjs` is idempotent, so the second run adopts the chores that already exist, creates only the missing ones, and prints the full mapping — re-running is the recovery path. Then continue at the normal dispatch pass: either way nothing is created and nothing is closed by ralph.
98
105
 
99
106
  ---
100
107
 
@@ -130,7 +137,7 @@ Decompose it:
130
137
  bd children <feature-id> --json
131
138
  ```
132
139
  Surface the created IDs in chat. An empty list means the decomposition failed: record the feature for the shutdown summary and continue — do not re-dispatch.
133
- 5. **Never re-dispatch a decomposition.** The bead creation it runs is not idempotent, so a second run duplicates the planning bead and every implementation child beside the first. This is the same hazard as re-running `create-chores.mjs`.
140
+ 5. **Never re-dispatch a decomposition.** The bead creation it runs is not idempotent, so a second run duplicates the planning bead and every implementation child beside the first. This hazard belongs to `create-task`'s bead creation alone — `create-chores.mjs` is idempotent, so re-running an expansion is its recovery path.
134
141
  6. **Go idle.** After decomposition nothing is dispatchable for that feature: `bd ready -l execution:afk` still lists the feature itself (it now has children, so it is skipped — never decomposed again) but none of its children until a human closes the planning bead. Returning to the event loop with *nothing* to dispatch is the correct outcome, not a failure to re-expand — that is Q5b working as designed.
135
142
 
136
143
  ---
@@ -41,26 +41,28 @@ State model: task state lives in beads (ready/in_progress via `bd`); the only lo
41
41
  - **Bead that already has its children** (a decomposed feature, an expanded task parent): skip it — it is not dispatchable itself, and its `stage:*` chore is dispatched on its own.
42
42
  `execution:hitl` beads are not in this query and are not ralph's to touch: record them for the Pending Human Action summary. That includes every feature still at its default `execution:hitl` label.
43
43
  7. Dispatch each unit from its parent's worktree, up to 5 in flight: a stage chore via the event loop, a childless feature via a decomposition worker (_Feature decomposition_), a chore-less task via an expansion worker (_Expansion guard_). Create branches and worktrees first (REFERENCE.md, _Feature Branches and Worktrees_).
44
- 8. Hand over to your harness's completion-detection mechanism: PI keeps a {{TOOL:wait_for_agents}} call outstanding over every in-flight agent ID, and Claude re-invokes you as each worker completes. If no **agent work** was dispatched — `state.json`'s `inflight` holds no entries, so no stage, decomposition or expansion worker is running — and `skills/workflow/ralph/scripts/pending-gates.mjs` reports a non-empty `pendingHumanAction` (an unblocked `lifecycle:feature-pr` gate, or an epic tagged `awaiting-epic-pr-merge`), go straight to HITL Pause (REFERENCE.md). A decomposition- or expansion-only init is in flight, not idle: keep the completion mechanism running and continue at the event loop.
44
+ 8. Hand over to your harness's completion-detection mechanism: PI keeps a blocking wait outstanding over every in-flight agent ID, and Claude re-invokes you as each worker completes. If no **agent work** was dispatched — `state.json`'s `inflight` holds no entries, so no stage, decomposition or expansion worker is running — and `skills/workflow/ralph/scripts/pending-gates.mjs` reports a non-empty `pendingHumanAction` (an unblocked `lifecycle:feature-pr` gate, or an epic tagged `awaiting-epic-pr-merge`), go straight to HITL Pause (REFERENCE.md). A decomposition- or expansion-only init is in flight, not idle: keep the completion mechanism running and continue at the event loop.
45
45
 
46
46
  ### 2. Expansion guard (stage chores are pre-created)
47
47
 
48
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_).
49
49
 
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
+
50
52
  **Never expand an existing bead with `create-task`.** It creates a bead first and expands second, so the original would stay chore-less and gain a **second parent** beside it. `create-chores.mjs` expands; only `create-task` creates parents.
51
53
 
52
54
  ### 3. Feature decomposition
53
55
 
54
56
  A ready `execution:afk` bead of type `feature` with no children is not dispatchable work — there is nothing to stage and nothing to expand. Decompose it: dispatch a worker in `.agent-cortex/worktrees/<feature-id>` that turns the feature into one `execution:hitl` planning bead plus `execution:afk` implementation children that depend on it, then continue (REFERENCE.md, _Feature Decomposition_).
55
57
 
56
- A feature carries `execution:hitl` by default, so this only runs when a human has deliberately relabelled a feature `execution:afk` to make its delivery agent-routable. Decomposition is then the last thing ralph does with it for a while: the children depend on the planning bead, so once the worker reports, `bd ready -l execution:afk` returns only the feature itself — no dispatchable child — and skipping that row is the *correct* behaviour, not re-expanding. **Never re-dispatch a decomposition**: the bead creation it runs is not idempotent, so a second run duplicates the whole hierarchy.
58
+ A feature carries `execution:hitl` by default, so this only runs when a human has deliberately relabelled a feature `execution:afk` to make its delivery agent-routable. Decomposition is then the last thing ralph does with it for a while: the children depend on the planning bead, so once the worker reports, `bd ready -l execution:afk` returns only the feature itself — no dispatchable child — and skipping that row is the *correct* behaviour, not re-expanding. **Never re-dispatch a decomposition**: the bead creation it runs is not idempotent, so a second run duplicates the whole hierarchy. That hazard is `create-task`'s, not `create-chores.mjs`'s — the expansion is idempotent.
57
59
 
58
60
  ### 4. Event loop
59
61
 
60
62
  On each completion notification (a background agent finished):
61
63
 
62
64
  1. Poll that parent's log for new lines — `tail -n +<nextLine> .agent-cortex/ralph/ralph-<parent-id>.log` (logs are keyed by parent bead ID, so every stage of a feature shares one file) — and post only new key events (stage transitions, test/lint results), never empty updates.
63
- 2. Read the completed agent's output with {{TOOL:read_agent}} and parse its `---REPORT---` block for `BEAD_ID`, `STAGE_COMPLETED`, and the outcome. A **decomposition** (`kind: "decompose"`) or **expansion** (`kind: "expand"`) worker has no REPORT and no leaf bead to confirm. An **expansion worker** owns no bead — confirm the parent now has `stage:*` chores (`bd children <parent-id> --json | grep -c '"stage:'`) and skip to step 5 to dispatch its ready `stage:code` chore. A **decomposition worker** — confirm the feature now has children (`bd children <feature-id> --json` returns a non-empty list), record that nothing is dispatchable for it until a human closes the planning bead, and skip to step 5; never re-decompose. If either confirmation comes back empty, the worker failed: surface its output, record the parent for the shutdown summary, and do not re-dispatch.
65
+ 2. Read the completed agent's output and parse its `---REPORT---` block for `BEAD_ID`, `STAGE_COMPLETED`, and the outcome. A **decomposition** (`kind: "decompose"`) or **expansion** (`kind: "expand"`) worker has no REPORT and no leaf bead to confirm. An **expansion worker** owns no bead — confirm the parent now has `stage:*` chores (`bd children <parent-id> --json | grep -c '"stage:'`) and skip to step 5 to dispatch its ready `stage:code` chore. A count of `0` means the expansion produced no chores at all; a count below the stage total means the chain is **partial**. Both are failures: surface the worker's output and re-run `create-chores.mjs --parent <id>`, which adopts the chores already present and creates only the missing ones, so the re-run is the recovery path and it never needs a second chain. A **decomposition worker** — confirm the feature now has children (`bd children <feature-id> --json` returns a non-empty list), record that nothing is dispatchable for it until a human closes the planning bead, and skip to step 5; never re-decompose. If a decomposition's confirmation comes back empty, the worker failed: surface its output, record the parent for the shutdown summary, and do not re-dispatch.
64
66
  3. Confirm the stage worker closed its chore bead: `bd show <bead-id>` should read `closed` with the outcome in its close reason. If it is still open the worker died mid-stage — read its notes and re-dispatch; never close it yourself.
65
67
  4. Route the parent per the dispatch table — nothing is created here: the pre-created chain already encodes the order, and the worker owns any remediation it needs.
66
68
 
@@ -124,6 +126,7 @@ Both paths end in a **gated** `bd dolt push`: on a non-zero exit, retry once, th
124
126
  | "The review passed, so I can merge" | Feature and epic PR merges are human-controlled; the gate exists so a person sees every change. |
125
127
  | "The worker left the chore open — I'll close it" | The worker owns that bead; an open one means it died mid-stage, so read its notes and re-dispatch instead of closing over the missing record. |
126
128
  | "This bead has no chores — I'll create them as I go" | The whole chain is pre-created with `blocks` deps; hand-creating stages duplicates chores and loses the order. Expand with `create-chores.mjs` instead. |
129
+ | "The expansion failed, so I'll build the missing chores by hand" | Re-run `create-chores.mjs --parent <id>`: it is idempotent, so it adopts the chores that exist, creates only the missing ones, and reports the whole mapping. |
127
130
  | "That feature came back ready again, so I'll decompose it a second time" | The implementation children depend on the planning bead, so nothing is ready until a human closes it. Re-decomposing duplicates the hierarchy; idling *is* the correct outcome. |
128
131
 
129
132
  ## Philosophy / rationale
@@ -156,6 +159,6 @@ Do not invoke `classify-bead` from this loop — classification was applied at p
156
159
  - [ ] In-flight count never exceeded 5, and the read-only cap backstop ran before every `stage:*` dispatch.
157
160
  - [ ] Every in-flight, dispatchable-work, HITL-pause, and shutdown predicate was read from `state.json`'s `inflight` (stage, decomposition **and** expansion workers) plus `bd ready -l execution:afk`, with the ready, claimable scope of a chore-less `execution:afk` task bead — never from a chore-only `bd` query, and never counting a bead an open `lifecycle:feature-pr` gate blocks from being claimed.
158
161
  - [ ] Ralph closed no leaf execution bead and created no fix bead; every closed chore carries its worker's close reason.
159
- - [ ] Any chore-less `execution:afk` task bead was expanded with `create-chores.mjs` (one parent bead), never with `create-task`.
162
+ - [ ] Any chore-less `execution:afk` task bead was expanded with `create-chores.mjs` (one parent bead), never with `create-task`; a failed expansion was recovered by re-running that same idempotent command.
160
163
  - [ ] Feature and epic PRs were pushed and reported immediately at their gates, and nothing was auto-merged.
161
164
  - [ ] Every `bd dolt push` before HITL Pause/Shutdown exited 0 — a non-zero exit was retried once, then surfaced as a blocker (command, exit code, stderr) with no completion or Pending Human Action summary printed; status was reported from `bd` commands rather than a generated file, and the chat received no empty poll updates.