@jaybeeuu/agent-cortex 2.1.0 → 2.3.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.
Files changed (181) hide show
  1. package/CHANGELOG.md +411 -0
  2. package/README.md +33 -13
  3. package/agents/README.md +22 -10
  4. package/agents/plan/agent.md +2 -2
  5. package/agents/ralph/agent.md +126 -88
  6. package/agents/ralph/claude/frontmatter.json +3 -3
  7. package/agents/ralph/claude/wake.md +46 -0
  8. package/agents/ralph/pi/{polling.md → wake.md} +35 -20
  9. package/agents/ralph-plan/agent.md +23 -11
  10. package/bin/agent-cortex.mjs +66 -4
  11. package/bin/installers/claude.mjs +182 -68
  12. package/bin/installers/pi.mjs +200 -24
  13. package/extensions/agent-guardrails/README.md +1 -1
  14. package/extensions/agent-guardrails/guardrails.ts +1 -1
  15. package/extensions/agent-guardrails/index.ts +1 -1
  16. package/extensions/agent-modes/discover.test.ts +2 -2
  17. package/extensions/output-style/README.md +1 -1
  18. package/extensions/output-style/index.ts +2 -2
  19. package/extensions/output-style/output-style.ts +2 -2
  20. package/hooks/claude/scripts/notify.mjs +8 -1
  21. package/lib/cli.mjs +91 -2
  22. package/package.json +27 -3
  23. package/scripts/check-changeset.mjs +4 -0
  24. package/scripts/check-no-sync.mjs +5 -1
  25. package/scripts/lib/compose-agent.mjs +69 -9
  26. package/scripts/lib/fs.mjs +10 -2
  27. package/scripts/lib/migrate.mjs +736 -0
  28. package/scripts/lib/release-gates.mjs +21 -1
  29. package/scripts/lib/skill-frontmatter.mjs +23 -6
  30. package/scripts/lib/skill-links.mjs +52 -0
  31. package/scripts/lib/verify-worker-tree.mjs +25 -0
  32. package/scripts/resolve-publish-command.mjs +4 -0
  33. package/skills/engineering/design-an-interface/SKILL.md +3 -3
  34. package/skills/engineering/grill-with-docs/SKILL.md +3 -5
  35. package/skills/engineering/improve-codebase-architecture/SKILL.md +5 -4
  36. package/skills/engineering/tdd/SKILL.md +5 -7
  37. package/skills/engineering/technical-direction/SKILL.md +5 -5
  38. package/skills/planning/classify-bead/REFERENCE.md +3 -3
  39. package/skills/planning/classify-bead/SKILL.md +14 -9
  40. package/skills/planning/classify-bead/scripts/classify-bead.mjs +5 -4
  41. package/skills/planning/create-task/REFERENCE.md +52 -3
  42. package/skills/planning/create-task/SKILL.md +70 -26
  43. package/skills/planning/create-task/scripts/{create-chores.ts → create-chores.mjs} +48 -23
  44. package/skills/planning/flesh-out-idea/REFERENCE.md +116 -0
  45. package/skills/planning/flesh-out-idea/SKILL.md +146 -0
  46. package/skills/planning/prd-to-tasks/REFERENCE.md +54 -5
  47. package/skills/planning/prd-to-tasks/SKILL.md +22 -16
  48. package/skills/planning/record-idea/FORMAT.md +66 -17
  49. package/skills/planning/record-idea/SKILL.md +48 -24
  50. package/skills/planning/request-refactor-plan/SKILL.md +4 -4
  51. package/skills/planning/spec-to-tickets/SKILL.md +5 -5
  52. package/skills/planning/write-a-prd/SKILL.md +8 -9
  53. package/skills/planning/write-a-ticket/SKILL.md +4 -6
  54. package/skills/productivity/bd-tool/SKILL.md +15 -10
  55. package/skills/productivity/grill-me/SKILL.md +5 -0
  56. package/skills/productivity/init-beads/SKILL.md +3 -3
  57. package/skills/productivity/migration/REFERENCE.md +227 -0
  58. package/skills/productivity/migration/SKILL.md +188 -0
  59. package/skills/productivity/validate-agent-dir/scripts/{validate-agent-dir.ts → validate-agent-dir.mjs} +49 -28
  60. package/skills/productivity/write-a-skill/SKILL.md +5 -5
  61. package/skills/review/maintain-agent-docs/SKILL.md +5 -7
  62. package/skills/review/refactor-skill/SKILL.md +4 -3
  63. package/skills/review/review-security/SKILL.md +3 -5
  64. package/skills/style/style-code/SKILL.md +5 -10
  65. package/skills/style/style-comms/SKILL.md +5 -7
  66. package/skills/style/style-documentation/SKILL.md +5 -6
  67. package/skills/style/style-tests/SKILL.md +5 -7
  68. package/skills/workflow/git-workflow/SKILL.md +52 -26
  69. package/skills/workflow/hitl-collab/SKILL.md +4 -6
  70. package/skills/workflow/plan/SKILL.md +41 -30
  71. package/skills/workflow/ralph/REFERENCE.md +182 -51
  72. package/skills/workflow/ralph/SKILL.md +89 -50
  73. package/skills/workflow/run-pipeline-stage/SKILL.md +72 -71
  74. package/skills/workflow/run-pipeline-stage/playbooks/code.md +11 -0
  75. package/skills/workflow/run-pipeline-stage/playbooks/document.md +12 -0
  76. package/skills/workflow/run-pipeline-stage/playbooks/fix.md +12 -0
  77. package/skills/workflow/run-pipeline-stage/playbooks/review.md +11 -0
  78. package/skills/workflow/run-pipeline-stage/playbooks/verify.md +13 -0
  79. package/skills/workflow/run-pipeline-stage/prompts/stage-runner.md +73 -1
  80. package/token-map.README.md +9 -4
  81. package/token-map.json +13 -6
  82. package/.agents/skills/style-versioning/SKILL.md +0 -172
  83. package/.changeset/README.md +0 -8
  84. package/.changeset/config.json +0 -11
  85. package/.github/workflows/ci.yml +0 -302
  86. package/.junie/guidelines.md +0 -87
  87. package/.junie/mcp/mcp.json +0 -10
  88. package/AGENTS.md +0 -153
  89. package/agents/ralph/claude/polling.md +0 -31
  90. package/agents-native/ralph.md +0 -203
  91. package/docs/claude-hooks.md +0 -144
  92. package/docs/commit-signing.md +0 -231
  93. package/docs/ideas/auto-model-selection.md +0 -75
  94. package/docs/ideas/ban-content-assertion-tests.md +0 -75
  95. package/docs/ideas/bead-progress-notes-for-session-resumption.md +0 -75
  96. package/docs/ideas/classify-beads-via-deterministic-script-with-agent-fallback.md +0 -70
  97. package/docs/ideas/ctx-alias-version-and-update-commands.md +0 -65
  98. package/docs/ideas/ctx-feedback-command-for-capturing-ideas-from-any-machine.md +0 -67
  99. package/docs/ideas/dedicated-review-tests-skill.md +0 -71
  100. package/docs/ideas/deployment-beads-for-post-merge-pipelines.md +0 -36
  101. package/docs/ideas/design-and-delivery-as-separate-bead-trees/design-and-delivery-as-separate-bead-trees.md +0 -99
  102. package/docs/ideas/enable-signed-commit-enforcement-changesets-bot-commits-must-be-signed.md +0 -42
  103. package/docs/ideas/extension-manifests.md +0 -121
  104. package/docs/ideas/improve-pi-system-prompt.md +0 -43
  105. package/docs/ideas/map-every-cortex-tool-to-a-real-pi-equivalent.md +0 -95
  106. package/docs/ideas/model-selector.md +0 -40
  107. package/docs/ideas/pi-task-memory.md +0 -34
  108. package/docs/ideas/qa-extension.md +0 -43
  109. package/docs/ideas/record-idea-implementation-questions.md +0 -68
  110. package/docs/ideas/record-ideas-as-beads.md +0 -115
  111. package/docs/ideas/self-flushing-session.md +0 -72
  112. package/docs/ideas/state-in-beads-not-md.md +0 -69
  113. package/docs/ideas/subagent-visibility.md +0 -72
  114. package/docs/ideas/task-session-context.local.md +0 -95
  115. package/docs/ideas/task-session-context.md +0 -89
  116. package/docs/ideas/test-data-creation-helpers.md +0 -92
  117. package/docs/ideas/test-planning-stage-bead-in-feature-planning.md +0 -68
  118. package/docs/ideas/unified-ci-pipeline.md +0 -28
  119. package/docs/ideas/unify-the-ticket-and-task-decomposition-workflows.md +0 -71
  120. package/docs/ideas/worktree-housekeeping-for-closed-beads.md +0 -39
  121. package/docs/inspirations.md +0 -33
  122. package/docs/research/disable-model-invocation.md +0 -82
  123. package/docs/research/gas-city-research.md +0 -256
  124. package/docs/research/skill-design-research.md +0 -286
  125. package/docs/research/skill-improvements-analysis.md +0 -117
  126. package/docs/research/skill-patterns-research.md +0 -24
  127. package/docs/skills/skill-anatomy.md +0 -200
  128. package/docs/technical-direction/harness-config.md +0 -177
  129. package/docs/technical-direction/migration-execution-labels.md +0 -177
  130. package/docs/user-preferences.md +0 -17
  131. package/extensions/agent-guardrails/tsconfig.json +0 -12
  132. package/extensions/agent-modes/tsconfig.json +0 -12
  133. package/extensions/output-style/tsconfig.json +0 -12
  134. package/extensions/session-start/tsconfig.json +0 -12
  135. package/extensions/skill-stats/pnpm-lock.yaml +0 -329
  136. package/extensions/skill-stats/tsconfig.json +0 -12
  137. package/extensions/subagent/agents/planner.md +0 -37
  138. package/extensions/subagent/agents/reviewer.md +0 -35
  139. package/extensions/subagent/agents/scout.md +0 -50
  140. package/extensions/subagent/agents/worker.md +0 -24
  141. package/extensions/subagent/prompts/implement-and-review.md +0 -10
  142. package/extensions/subagent/prompts/implement.md +0 -10
  143. package/extensions/subagent/prompts/scout-and-plan.md +0 -9
  144. package/extensions/subagent/tsconfig.json +0 -12
  145. package/pi/keybindings.json +0 -3
  146. package/pi/settings.json +0 -34
  147. package/pnpm-workspace.yaml +0 -14
  148. package/skills/planning/create-task/scripts/package.json +0 -14
  149. package/skills/planning/create-task/scripts/pnpm-lock.yaml +0 -342
  150. package/skills/planning/create-task/scripts/tsconfig.json +0 -12
  151. package/skills/planning/record-idea/scripts/new-idea.sh +0 -89
  152. package/skills/productivity/validate-agent-dir/scripts/package.json +0 -15
  153. package/skills/productivity/validate-agent-dir/scripts/pnpm-lock.yaml +0 -329
  154. package/skills/productivity/validate-agent-dir/scripts/pnpm-workspace.yaml +0 -2
  155. package/skills/productivity/validate-agent-dir/scripts/tsconfig.json +0 -19
  156. package/skills/productivity/validate-agent-dir/scripts/validate-agent-dir.test.ts +0 -317
  157. package/skills/workflow/run-pipeline-stage/REFERENCE.md +0 -22
  158. package/skills/workflow/run-pipeline-stage/scripts/generate-progress.test.ts +0 -682
  159. package/skills/workflow/run-pipeline-stage/scripts/generate-progress.ts +0 -588
  160. package/skills/workflow/run-pipeline-stage/scripts/package.json +0 -15
  161. package/skills/workflow/run-pipeline-stage/scripts/pnpm-lock.yaml +0 -342
  162. package/skills/workflow/run-pipeline-stage/scripts/pnpm-workspace.yaml +0 -2
  163. package/skills/workflow/run-pipeline-stage/scripts/tsconfig.json +0 -12
  164. package/test/classify-bead.test.mjs +0 -277
  165. package/test/claude-installer.test.mjs +0 -308
  166. package/test/cli.test.mjs +0 -314
  167. package/test/create-chores.test.mjs +0 -85
  168. package/test/fake-bd.test.mjs +0 -72
  169. package/test/fs.test.mjs +0 -76
  170. package/test/helpers/fake-bd.mjs +0 -87
  171. package/test/helpers/fake-claude.mjs +0 -152
  172. package/test/helpers/fake-pi.mjs +0 -132
  173. package/test/installer.test.mjs +0 -806
  174. package/test/notify-hook.test.mjs +0 -63
  175. package/test/pending-gates.test.mjs +0 -147
  176. package/test/pi-installer.test.mjs +0 -785
  177. package/test/pi-register.test.mjs +0 -342
  178. package/test/pi-sweep.test.mjs +0 -619
  179. package/test/release-gates.test.mjs +0 -190
  180. package/test/skill-frontmatter.test.mjs +0 -137
  181. package/test/verify-worker-tree.test.mjs +0 -120
@@ -0,0 +1,736 @@
1
+ // @ts-check
2
+ // `agent-cortex migrate` — bring a beads workspace up to the latest
3
+ // agent-cortex layout, one versioned step at a time.
4
+ //
5
+ // The command is repo-agnostic: it talks to the `bd` CLI found on PATH
6
+ // (`BD_PATH` overrides), never to a hardcoded install path, and never to
7
+ // agent-cortex-specific labels. The only filesystem path it knows is the idea
8
+ // directory every workspace that used loose idea records keeps its files in
9
+ // (`docs/ideas`), and reading it is all step v2 does — the migration never
10
+ // writes or deletes a file.
11
+ //
12
+ // The current version is detected from the workspace's own state — the first
13
+ // step with pending work is the version boundary — so there is no side-car
14
+ // marker file to drift out of sync on a fresh clone, and a re-run after an
15
+ // apply is a no-op by construction.
16
+ //
17
+ // Dry run is the default. `--apply` runs every pending step in order up to the
18
+ // latest, verifying each one before moving on.
19
+
20
+ import { execFile } from "node:child_process";
21
+ import { readdir, readFile } from "node:fs/promises";
22
+ import { basename, join } from "node:path";
23
+ import { promisify } from "node:util";
24
+
25
+ const execFileAsync = promisify(execFile);
26
+
27
+ // `bd export --all` on a large workspace is far bigger than execFile's 1 MiB
28
+ // default; the migration reads every bead in the workspace.
29
+ const MAX_BUFFER = 32 * 1024 * 1024;
30
+
31
+ /** Step v1 renames labels in this namespace to the two-axis execution namespace. */
32
+ const LEGACY_LABEL_PREFIX = "implementation-type:";
33
+ const EXECUTION_LABEL_PREFIX = "execution:";
34
+
35
+ /**
36
+ * The values step v1 is allowed to rename. The documented contract is
37
+ * `implementation-type:<afk|hitl>`, so any other value is a human decision the
38
+ * migration must not guess at: renaming `wip` to `execution:wip` would invent a
39
+ * routing label nothing queries.
40
+ */
41
+ const V1_LABEL_VALUES = ["afk", "hitl"];
42
+
43
+ /**
44
+ * Where loose idea records live, relative to the working directory. This is the
45
+ * one path the command assumes, and it is deliberately the directory every
46
+ * workspace with idea files already uses — a workspace without one simply has
47
+ * nothing pending at v2.
48
+ */
49
+ const DEFAULT_IDEA_DIR = "docs/ideas";
50
+
51
+ /**
52
+ * The label that marks a bead as an unscoped idea. It is also what step v2
53
+ * reads to decide which records already exist.
54
+ */
55
+ const IDEA_LABEL = "kind:idea";
56
+
57
+ /**
58
+ * Migrated ideas are `task` beads, and `bd lint` requires acceptance criteria on
59
+ * one. An idea file has no acceptance section of its own — the criterion is the
60
+ * promotion bar `record-idea` captures for a new idea, so a migrated record and
61
+ * a freshly captured one lint the same way.
62
+ */
63
+ const IDEA_ACCEPTANCE =
64
+ "A promotion decision is recorded on this bead: a scoped epic/feature is spawned from it, or it is explicitly rejected.";
65
+
66
+ /**
67
+ * Section aliases for `--description` and `--design`. The idea files predate a
68
+ * single template, so a heading is matched by normalised name — case-folded,
69
+ * trailing parenthetical ignored — rather than by exact text: `## Problem` and
70
+ * the legacy `## Why it might be useful` are the same field, and
71
+ * `## Fleshed-out design (decided 2026-08-25)` is still the design field.
72
+ *
73
+ * Every other `##` section lands in `--notes` in file order, markers preserved,
74
+ * so a one-off section a file added is carried over instead of dropped.
75
+ */
76
+ const DESCRIPTION_SECTIONS = ["problem", "why it might be useful", "who benefits", "proposed outcome"];
77
+ const DESIGN_SECTIONS = ["how we might do it", "fleshed-out design", "design so far / provenance"];
78
+
79
+ /**
80
+ * The idea files carry no `priority:` field: a few use a `## Priority` heading
81
+ * and the rest state it in prose ("priority P2 (moderate)", "P3 by the author").
82
+ * The first `P<n>` token anywhere in the file is therefore the priority, and a
83
+ * file that states none is backlog — P4.
84
+ */
85
+ const PRIORITY_PATTERN = /\bP([0-4])\b/;
86
+ const DEFAULT_PRIORITY = 4;
87
+
88
+ /** @typedef {(args: string[]) => Promise<string>} BdRunner */
89
+ /**
90
+ * What a step works against: the `bd` runner plus the workspace facts a probe
91
+ * may need. Step v2 reads the filesystem, so the context carries the idea
92
+ * directory rather than leaving the step to guess one.
93
+ *
94
+ * @typedef {{ bd: BdRunner, dir: string }} StepContext
95
+ */
96
+ /** @typedef {{ from: string, to: string }} LabelRename */
97
+ /** @typedef {{ id: string, renames: LabelRename[] }} PendingBead */
98
+ /**
99
+ * @typedef {object} PendingIdea
100
+ * @property {string} file Source file path, as reported in the plan.
101
+ * @property {string} title Bead title, `Idea: ` prefixed.
102
+ * @property {number} priority 0–4.
103
+ * @property {string} description `--description` body.
104
+ * @property {string} design `--design` body.
105
+ * @property {string} notes `--notes` body.
106
+ */
107
+ /** @typedef {PendingBead | PendingIdea} Pending */
108
+
109
+ /**
110
+ * One registry entry. `detect` is both the version probe (empty result = the
111
+ * step is already applied) and the input to `apply`, so a step can never plan
112
+ * one thing and write another. `plan` renders that pending work as the dry
113
+ * run's lines, so the printout never has to know a step's pending shape.
114
+ *
115
+ * @typedef {object} MigrationStep
116
+ * @property {number} version Version this step migrates the workspace to.
117
+ * @property {string} name Stable identifier, used in output.
118
+ * @property {string} describe One-line description of the change.
119
+ * @property {(ctx: StepContext) => Promise<Pending[]>} detect Pending work, empty when applied.
120
+ * @property {(pending: Pending[]) => string[]} plan One line per pending item.
121
+ * @property {(ctx: StepContext, pending: Pending[]) => Promise<void>} apply Write the change.
122
+ */
123
+
124
+ /**
125
+ * The `bd` runner. Resolved per call so a test or a machine-specific install
126
+ * can override it without a hardcoded path (see the BD_PATH convention shared
127
+ * with the other bd-driving tools in this repo).
128
+ *
129
+ * @returns {BdRunner}
130
+ */
131
+ function bdRunner() {
132
+ const bin = process.env.BD_PATH || "bd";
133
+ return async (args) => {
134
+ try {
135
+ const { stdout } = await execFileAsync(bin, args, { encoding: "utf-8", maxBuffer: MAX_BUFFER });
136
+ return stdout;
137
+ } catch (err) {
138
+ if (/** @type {NodeJS.ErrnoException} */ (err).code === "ENOENT") {
139
+ throw new Error(`bd not found (${bin}). Install the beads CLI or set BD_PATH.`);
140
+ }
141
+ const stderr = /** @type {{ stderr?: string }} */ (err).stderr;
142
+ const detail = stderr && stderr.trim() ? stderr.trim() : /** @type {Error} */ (err).message;
143
+ throw new Error(`bd ${args.slice(0, 2).join(" ")} failed: ${detail}`);
144
+ }
145
+ };
146
+ }
147
+
148
+ /**
149
+ * Every issue in the workspace, read from `bd export --all` (JSONL). The export
150
+ * reports each record's labels whatever its status, so closed beads are in
151
+ * scope. A label-filtered `bd list` hides closed beads on some bd builds, and
152
+ * relying on it is how a partially migrated workspace once reported itself as
153
+ * up to date.
154
+ *
155
+ * Non-issue records (memories and other infrastructure beads) are skipped. A
156
+ * line that is not JSON, or an issue without an id, fails loudly instead of
157
+ * shrinking the result set silently.
158
+ *
159
+ * @param {BdRunner} bd
160
+ * @returns {Promise<Array<{ id: string, labels: string[] }>>}
161
+ */
162
+ async function exportIssues(bd) {
163
+ const stdout = await bd(["export", "--all"]);
164
+ /** @type {Array<{ id: string, labels: string[] }>} */
165
+ const issues = [];
166
+ const lines = stdout.split("\n");
167
+
168
+ for (let index = 0; index < lines.length; index++) {
169
+ const line = lines[index].trim();
170
+ if (line === "") continue;
171
+
172
+ let record;
173
+ try {
174
+ record = JSON.parse(line);
175
+ } catch {
176
+ throw new Error(`bd export --all returned a non-JSON line at line ${index + 1}`);
177
+ }
178
+ if (typeof record?._type === "string" && record._type !== "issue") continue;
179
+ if (typeof record?.id !== "string") {
180
+ throw new Error(`bd export --all returned an issue without an id at line ${index + 1}`);
181
+ }
182
+ issues.push({ id: record.id, labels: Array.isArray(record.labels) ? record.labels : [] });
183
+ }
184
+
185
+ return issues;
186
+ }
187
+
188
+ /**
189
+ * Labels present anywhere in the workspace, closed beads included, from the
190
+ * label index. The migration cross-checks the export against it: a legacy label
191
+ * the index reports but the export cannot attribute to a bead means the export
192
+ * lost data, and the workspace must not be called migrated.
193
+ *
194
+ * @param {BdRunner} bd
195
+ * @returns {Promise<string[]>}
196
+ */
197
+ async function listIndexedLabels(bd) {
198
+ const entries = JSON.parse(await bd(["label", "list-all", "--json"]));
199
+ if (!Array.isArray(entries)) throw new Error("bd label list-all --json did not return an array");
200
+
201
+ return entries.map((entry) => {
202
+ if (typeof entry?.label !== "string") {
203
+ throw new Error("bd label list-all --json returned an entry without a label");
204
+ }
205
+ return entry.label;
206
+ });
207
+ }
208
+
209
+ /**
210
+ * Find every bead still carrying an `implementation-type:` label, grouped per
211
+ * bead. Closed beads are included: the migration's goal is an empty legacy
212
+ * namespace, so history labels are renamed too.
213
+ *
214
+ * A value outside the step's allowlist is a blocking error, not a rename to
215
+ * guess at — the message names each offending bead and label, and detection
216
+ * runs before any write, so both the dry run and `--apply` change nothing.
217
+ *
218
+ * @param {StepContext} ctx
219
+ * @returns {Promise<PendingBead[]>}
220
+ */
221
+ async function findLegacyLabelBeads({ bd }) {
222
+ /** @type {PendingBead[]} */
223
+ const pending = [];
224
+ /** @type {string[]} */
225
+ const unmapped = [];
226
+
227
+ for (const bead of await exportIssues(bd)) {
228
+ /** @type {LabelRename[]} */
229
+ const renames = [];
230
+ for (const label of bead.labels) {
231
+ if (!label.startsWith(LEGACY_LABEL_PREFIX)) continue;
232
+ const value = label.slice(LEGACY_LABEL_PREFIX.length);
233
+ if (value === "") throw new Error(`cannot migrate malformed label "${label}" on ${bead.id}`);
234
+ // Refuse the whole run rather than renaming the rest and leaving an
235
+ // invented label behind: a half-migrated workspace starves silently.
236
+ if (!V1_LABEL_VALUES.includes(value)) {
237
+ unmapped.push(`${bead.id}: ${label}`);
238
+ continue;
239
+ }
240
+ renames.push({ from: label, to: `${EXECUTION_LABEL_PREFIX}${value}` });
241
+ }
242
+ if (renames.length > 0) pending.push({ id: bead.id, renames });
243
+ }
244
+
245
+ if (unmapped.length > 0) {
246
+ throw new Error(
247
+ `v1 label-rename only maps ${LEGACY_LABEL_PREFIX}${V1_LABEL_VALUES.join("|")} to ` +
248
+ `${EXECUTION_LABEL_PREFIX}${V1_LABEL_VALUES.join("|")}, but ${unmapped.length} label(s) carry another ` +
249
+ `value — refusing to migrate:\n${unmapped.map((entry) => ` ${entry}`).join("\n")}\n` +
250
+ "Correct or remove those labels, then re-run.",
251
+ );
252
+ }
253
+
254
+ const residual = (await listIndexedLabels(bd)).filter((label) => label.startsWith(LEGACY_LABEL_PREFIX));
255
+ if (pending.length === 0 && residual.length > 0) {
256
+ throw new Error(
257
+ `no bead in bd export --all carries ${residual.join(", ")}, but the label index still does — ` +
258
+ "refusing to report the workspace as migrated",
259
+ );
260
+ }
261
+
262
+ return pending.sort((a, b) => a.id.localeCompare(b.id));
263
+ }
264
+
265
+ /**
266
+ * Apply the label rename in batches: add the target label for a group of beads,
267
+ * then remove the legacy one, so a bead is never left without its axis label.
268
+ *
269
+ * @param {StepContext} ctx
270
+ * @param {Pending[]} pending
271
+ * @returns {Promise<void>}
272
+ */
273
+ async function renameLegacyLabels({ bd }, pending) {
274
+ /** @type {Map<string, { to: string, ids: string[] }>} */
275
+ const groups = new Map();
276
+ for (const bead of pending.filter(isPendingBead)) {
277
+ for (const { from, to } of bead.renames) {
278
+ const group = groups.get(from) ?? { to, ids: [] };
279
+ group.ids.push(bead.id);
280
+ groups.set(from, group);
281
+ }
282
+ }
283
+
284
+ for (const from of [...groups.keys()].sort()) {
285
+ 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]);
288
+ }
289
+ }
290
+
291
+ /**
292
+ * Split a markdown body into its `##` sections and the lines that precede the
293
+ * first one. Level-3 headings stay inside their parent section: a `###` block is
294
+ * part of the design or note it belongs to, not a field of its own.
295
+ *
296
+ * @param {string[]} lines
297
+ * @returns {{ sections: Array<{ heading: string, lines: string[] }>, preamble: string[] }}
298
+ */
299
+ function splitSections(lines) {
300
+ /** @type {Array<{ heading: string, lines: string[] }>} */
301
+ const sections = [];
302
+ /** @type {string[]} */
303
+ const preamble = [];
304
+ /** @type {{ heading: string, lines: string[] } | null} */
305
+ let current = null;
306
+
307
+ for (const line of lines) {
308
+ const heading = /^##(?!#)\s+(.+?)\s*$/.exec(line);
309
+ if (heading) {
310
+ current = { heading: heading[1], lines: [] };
311
+ sections.push(current);
312
+ continue;
313
+ }
314
+ if (current === null) preamble.push(line);
315
+ else current.lines.push(line);
316
+ }
317
+
318
+ return { sections, preamble };
319
+ }
320
+
321
+ /**
322
+ * Render a section back to markdown with its `##` marker: the marker is the
323
+ * contract `flesh-out-idea` reads a bead's fields by.
324
+ *
325
+ * @param {{ heading: string, lines: string[] }} section
326
+ * @returns {string}
327
+ */
328
+ function renderSection({ heading, lines }) {
329
+ const body = lines.join("\n").replace(/^\n+/, "").replace(/\s+$/, "");
330
+ return body === "" ? `## ${heading}` : `## ${heading}\n${body}`;
331
+ }
332
+
333
+ /**
334
+ * A heading's alias key: case-folded, with a trailing parenthetical dropped so
335
+ * `## Fleshed-out design (decided 2026-08-25)` still matches `fleshed-out design`.
336
+ *
337
+ * @param {string} heading
338
+ * @returns {string}
339
+ */
340
+ function sectionKey(heading) {
341
+ return heading.trim().toLowerCase().replace(/\s*\([^()]*\)\s*$/, "");
342
+ }
343
+
344
+ /**
345
+ * A title's comparison key: the `Idea: ` / `Design: ` prefix dropped, case
346
+ * folded, and only letters and digits kept. A record already migrated by hand
347
+ * was titled `Design: …`, and a file name writes the same words with hyphens,
348
+ * so both sides reduce to the same key and collide.
349
+ *
350
+ * @param {string} title
351
+ * @returns {string}
352
+ */
353
+ function titleKey(title) {
354
+ return title
355
+ .replace(/^\s*(?:idea|design)\s*:\s*/i, "")
356
+ .toLowerCase()
357
+ .replace(/[^a-z0-9]+/g, "");
358
+ }
359
+
360
+ /**
361
+ * The lines under a `## Provenance` heading — how a record migrated by hand
362
+ * cites the file it came from. Only that section is consulted: neighbouring
363
+ * ideas are cross-referenced all over the notes, and a cross-reference is not
364
+ * evidence that a file has been recorded.
365
+ *
366
+ * @param {string} text
367
+ * @returns {string}
368
+ */
369
+ function provenanceText(text) {
370
+ return splitSections(text.split("\n"))
371
+ .sections.filter((section) => /^provenance/i.test(section.heading))
372
+ .map((section) => section.lines.join("\n"))
373
+ .join("\n");
374
+ }
375
+
376
+ /**
377
+ * One markdown idea file → the bead fields it becomes.
378
+ *
379
+ * No content loss is the contract: the `#` title line becomes the bead title and
380
+ * every other line lands in exactly one of description, design, or notes.
381
+ *
382
+ * @param {string} sourcePath
383
+ * @param {string} markdown
384
+ * @returns {PendingIdea}
385
+ */
386
+ function parseIdeaFile(sourcePath, markdown) {
387
+ /** @type {string[]} */
388
+ const body = [];
389
+ let heading = "";
390
+ for (const line of markdown.split("\n")) {
391
+ const title = heading === "" ? /^#\s+(.+?)\s*$/.exec(line) : null;
392
+ if (title) {
393
+ heading = title[1];
394
+ continue;
395
+ }
396
+ body.push(line);
397
+ }
398
+
399
+ const { sections, preamble } = splitSections(body);
400
+ /** @type {string[]} */
401
+ const description = [];
402
+ /** @type {string[]} */
403
+ const design = [];
404
+ /** @type {string[]} */
405
+ const notes = [];
406
+
407
+ const lead = preamble.join("\n").trim();
408
+ if (lead !== "") description.push(lead);
409
+
410
+ for (const section of sections) {
411
+ const block = renderSection(section);
412
+ if (DESCRIPTION_SECTIONS.includes(sectionKey(section.heading))) description.push(block);
413
+ else if (DESIGN_SECTIONS.includes(sectionKey(section.heading))) design.push(block);
414
+ else notes.push(block);
415
+ }
416
+
417
+ // A file whose heading is missing or is bare `Idea:` still needs a title; its
418
+ // file name is the only name it has.
419
+ const title = heading.replace(/^\s*(?:idea|design)\s*:\s*/i, "").trim();
420
+ const priority = PRIORITY_PATTERN.exec(markdown);
421
+
422
+ return {
423
+ file: sourcePath,
424
+ title: `Idea: ${title === "" ? basename(sourcePath, ".md").replace(/-/g, " ") : title}`,
425
+ priority: priority === null ? DEFAULT_PRIORITY : Number(priority[1]),
426
+ description: description.join("\n\n"),
427
+ design: design.join("\n\n"),
428
+ notes: notes.join("\n\n"),
429
+ };
430
+ }
431
+
432
+ /**
433
+ * `*.md` files under the idea directory, deepest one level (a multi-part idea
434
+ * can live in its own folder). A missing directory is not an error: a workspace
435
+ * that never used idea files has nothing pending at this step.
436
+ *
437
+ * @param {string} dir
438
+ * @returns {Promise<string[]>}
439
+ */
440
+ async function listIdeaPaths(dir) {
441
+ /** @type {string[]} */
442
+ const paths = [];
443
+
444
+ let entries;
445
+ try {
446
+ entries = await readdir(dir, { withFileTypes: true });
447
+ } catch (err) {
448
+ if (/** @type {NodeJS.ErrnoException} */ (err).code === "ENOENT") return paths;
449
+ throw err;
450
+ }
451
+
452
+ const byName = (/** @type {{ name: string }} */ a, /** @type {{ name: string }} */ b) =>
453
+ a.name.localeCompare(b.name);
454
+
455
+ for (const entry of [...entries].sort(byName)) {
456
+ const path = join(dir, entry.name);
457
+ if (entry.isFile()) {
458
+ if (entry.name.endsWith(".md")) paths.push(path);
459
+ continue;
460
+ }
461
+ // Dot directories are workspace state (`.beads`, `.agent-cortex`), never ideas.
462
+ if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
463
+ for (const nested of [...(await readdir(path, { withFileTypes: true }))].sort(byName)) {
464
+ if (nested.isFile() && nested.name.endsWith(".md")) paths.push(join(path, nested.name));
465
+ }
466
+ }
467
+
468
+ return paths;
469
+ }
470
+
471
+ /**
472
+ * The idea beads that already exist, as the two facts the collision check needs:
473
+ * a title key, and the text of any provenance section.
474
+ *
475
+ * `--all` keeps closed ideas in the answer — a closed idea bead means that file
476
+ * is recorded, and a collision must skip it rather than mint a twin.
477
+ *
478
+ * @param {BdRunner} bd
479
+ * @returns {Promise<Array<{ key: string, provenance: string }>>}
480
+ */
481
+ async function listIdeaBeads(bd) {
482
+ const entries = JSON.parse(await bd(["list", "-l", IDEA_LABEL, "--all", "--json", "--limit", "0"]));
483
+ if (!Array.isArray(entries)) throw new Error(`bd list -l ${IDEA_LABEL} --json did not return an array`);
484
+
485
+ return entries.map((entry) => ({
486
+ key: titleKey(typeof entry?.title === "string" ? entry.title : ""),
487
+ provenance: provenanceText(
488
+ [entry?.description, entry?.design, entry?.notes, entry?.acceptance_criteria]
489
+ .filter((field) => typeof field === "string")
490
+ .join("\n\n"),
491
+ ),
492
+ }));
493
+ }
494
+
495
+ /**
496
+ * Pending work for v2: every idea file whose record does not exist yet.
497
+ *
498
+ * A file is skipped when a bead cites it under a `## Provenance` heading — both
499
+ * this step's own output, so a re-run or an `--apply` that died part-way never
500
+ * duplicates a record, and the records migrated by hand before this step
501
+ * existed — or, for a bead that cites no source, when the bead's title matches.
502
+ *
503
+ * A bead that cites its source identifies exactly one file, so its title stands
504
+ * for nothing but itself: two files can share a title (this workspace's
505
+ * `task-session-context.md` and `task-session-context.local.md` do), and letting
506
+ * a source-citing bead's title mask its twin would make an `--apply` that died
507
+ * between the two creates resume silently short. Only a bead that cites no
508
+ * source is matched by title — that is the older hand-migrated record, which the
509
+ * step cannot attribute any other way.
510
+ *
511
+ * @param {StepContext} ctx
512
+ * @returns {Promise<PendingIdea[]>}
513
+ */
514
+ async function findIdeaFiles(ctx) {
515
+ const beads = await listIdeaBeads(ctx.bd);
516
+ const titles = new Set(
517
+ beads.filter((bead) => bead.provenance === "").map((bead) => bead.key).filter((key) => key !== ""),
518
+ );
519
+ const provenance = beads.map((bead) => bead.provenance).join("\n");
520
+ /** @type {PendingIdea[]} */
521
+ const pending = [];
522
+
523
+ for (const path of await listIdeaPaths(ctx.dir)) {
524
+ const idea = parseIdeaFile(path, await readFile(path, "utf-8"));
525
+ if (titles.has(titleKey(idea.title))) continue;
526
+ if (provenance.includes(basename(path))) continue;
527
+ pending.push(idea);
528
+ }
529
+
530
+ return pending;
531
+ }
532
+
533
+ /**
534
+ * Every bead this step creates names the file it came from, so a re-run can
535
+ * attribute a record to one file exactly instead of inferring it from a title
536
+ * two files may share — and so the cutover that retires the files leaves the
537
+ * provenance behind in the record.
538
+ *
539
+ * @param {PendingIdea} idea
540
+ * @returns {string}
541
+ */
542
+ function withProvenance({ notes, file }) {
543
+ const block = `## Provenance\nMigrated from \`${file}\` by \`agent-cortex migrate\`.`;
544
+ return notes === "" ? block : `${notes}\n\n${block}`;
545
+ }
546
+
547
+ /**
548
+ * Create the beads one file at a time, so a run that dies part-way leaves only
549
+ * the unmigrated files behind and the next `--apply` resumes exactly there.
550
+ *
551
+ * `kind:idea` marks the record as an unscoped idea and `execution:hitl` keeps it
552
+ * out of agent-workable queries: an idea is human-owned until it is promoted,
553
+ * and it stays out of the queue by *not* carrying `execution:afk`.
554
+ *
555
+ * @param {StepContext} ctx
556
+ * @param {Pending[]} pending
557
+ * @returns {Promise<void>}
558
+ */
559
+ async function createIdeaBeads({ bd }, pending) {
560
+ for (const idea of pending.filter(isIdeaFile)) {
561
+ /** @type {string[]} */
562
+ const args = [
563
+ "create",
564
+ idea.title,
565
+ "--silent",
566
+ "-l",
567
+ `${IDEA_LABEL},${EXECUTION_LABEL_PREFIX}hitl`,
568
+ "-p",
569
+ `P${idea.priority}`,
570
+ ];
571
+ if (idea.description !== "") args.push("-d", idea.description);
572
+ if (idea.design !== "") args.push("--design", idea.design);
573
+ args.push("--notes", withProvenance(idea));
574
+ args.push("--acceptance", IDEA_ACCEPTANCE);
575
+ await bd(args);
576
+ }
577
+ }
578
+
579
+ /**
580
+ * @param {Pending} item
581
+ * @returns {item is PendingBead}
582
+ */
583
+ function isPendingBead(item) {
584
+ return "renames" in item;
585
+ }
586
+
587
+ /**
588
+ * @param {Pending} item
589
+ * @returns {item is PendingIdea}
590
+ */
591
+ function isIdeaFile(item) {
592
+ return "file" in item;
593
+ }
594
+
595
+ /**
596
+ * @param {Pending[]} pending
597
+ * @returns {string[]}
598
+ */
599
+ function planLabelRenames(pending) {
600
+ return pending
601
+ .filter(isPendingBead)
602
+ .map((bead) => `${bead.id}: ${bead.renames.map(({ from, to }) => `${from} → ${to}`).join(", ")}`);
603
+ }
604
+
605
+ /**
606
+ * @param {Pending[]} pending
607
+ * @returns {string[]}
608
+ */
609
+ function planIdeaBeads(pending) {
610
+ return pending
611
+ .filter(isIdeaFile)
612
+ .map((idea) => `${idea.file} → ${idea.title} (P${idea.priority})`);
613
+ }
614
+
615
+ /** @type {MigrationStep[]} */
616
+ const STEPS = [
617
+ {
618
+ version: 1,
619
+ name: "label-rename",
620
+ describe: `${LEGACY_LABEL_PREFIX}<afk|hitl> → ${EXECUTION_LABEL_PREFIX}<afk|hitl>`,
621
+ detect: findLegacyLabelBeads,
622
+ plan: planLabelRenames,
623
+ apply: renameLegacyLabels,
624
+ },
625
+ {
626
+ version: 2,
627
+ name: "ideas-to-beads",
628
+ describe: `${DEFAULT_IDEA_DIR}/*.md (or --dir) → ${IDEA_LABEL} beads`,
629
+ detect: findIdeaFiles,
630
+ plan: planIdeaBeads,
631
+ apply: createIdeaBeads,
632
+ },
633
+ ];
634
+
635
+ const LATEST_VERSION = STEPS[STEPS.length - 1].version;
636
+
637
+ /**
638
+ * @typedef {object} MigrationState
639
+ * @property {number} version Highest contiguous applied version.
640
+ * @property {MigrationStep | null} step First step with pending work, null when up to date.
641
+ * @property {Pending[]} pending That step's pending work.
642
+ */
643
+
644
+ /**
645
+ * Detect the workspace's current migration version. Stops at the first step
646
+ * with pending work: later steps assume the earlier ones have run.
647
+ *
648
+ * @param {StepContext} ctx
649
+ * @returns {Promise<MigrationState>}
650
+ */
651
+ async function detectVersion(ctx) {
652
+ let version = 0;
653
+ for (const step of STEPS) {
654
+ const pending = await step.detect(ctx);
655
+ if (pending.length > 0) return { version, step, pending };
656
+ version = step.version;
657
+ }
658
+ return { version, step: null, pending: [] };
659
+ }
660
+
661
+ /** @param {string} chunk */
662
+ function write(chunk) {
663
+ process.stdout.write(chunk);
664
+ }
665
+
666
+ /**
667
+ * @param {MigrationStep} step
668
+ * @param {Pending[]} pending
669
+ */
670
+ function summarize(step, pending) {
671
+ return ` ${pending.length} bead(s) pending for v${step.version} ${step.name}\n`;
672
+ }
673
+
674
+ /**
675
+ * The dry run's deliverable: one line per pending item, then the totals.
676
+ *
677
+ * @param {MigrationStep} step
678
+ * @param {Pending[]} pending
679
+ */
680
+ function writePlan(step, pending) {
681
+ for (const line of step.plan(pending)) write(` ${line}\n`);
682
+ write(summarize(step, pending));
683
+ }
684
+
685
+ /**
686
+ * Run `agent-cortex migrate`.
687
+ *
688
+ * @param {{ apply: boolean, dir?: string }} options
689
+ * `dir` overrides the idea source directory (default `docs/ideas`), which
690
+ * is what makes the step usable from a checkout that keeps its idea files
691
+ * somewhere else.
692
+ * @returns {Promise<void>}
693
+ */
694
+ export async function runMigrate({ apply, dir = DEFAULT_IDEA_DIR }) {
695
+ const ctx = { bd: bdRunner(), dir };
696
+ const current = await detectVersion(ctx);
697
+
698
+ if (current.step === null) {
699
+ write(`agent-cortex migrate: already at v${LATEST_VERSION} — nothing to do.\n`);
700
+ return;
701
+ }
702
+
703
+ if (!apply) {
704
+ write(`agent-cortex migrate: dry run (workspace at v${current.version}, latest v${LATEST_VERSION})\n`);
705
+ write(` v${current.step.version} ${current.step.name}: ${current.step.describe}\n`);
706
+ writePlan(current.step, current.pending);
707
+ write("No changes written. Re-run with --apply to write.\n");
708
+ return;
709
+ }
710
+
711
+ write(`agent-cortex migrate: v${current.version} → v${LATEST_VERSION}\n`);
712
+ let state = current;
713
+ while (state.step !== null) {
714
+ const step = state.step;
715
+ write(` v${step.version} ${step.name}: ${step.describe}\n`);
716
+ write(summarize(step, state.pending));
717
+
718
+ await step.apply(ctx, state.pending);
719
+
720
+ const remaining = await step.detect(ctx);
721
+ if (remaining.length > 0) {
722
+ const files = step.plan(remaining).join(", ");
723
+ throw new Error(
724
+ `v${step.version} ${step.name} verification failed — ${remaining.length} item(s) still pending: ${files}`,
725
+ );
726
+ }
727
+ write(` ✓ v${step.version} ${step.name} verified\n`);
728
+
729
+ const next = await detectVersion(ctx);
730
+ if (next.step !== null && next.step.version <= step.version) {
731
+ throw new Error(`migration did not advance past v${step.version}`);
732
+ }
733
+ state = next;
734
+ }
735
+ write(`agent-cortex migrate: now at v${LATEST_VERSION}.\n`);
736
+ }