@jaybeeuu/agent-cortex 2.0.0 → 2.2.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 +437 -0
- package/README.md +46 -15
- package/agents/README.md +22 -10
- package/agents/plan/agent.md +2 -2
- package/agents/ralph/agent.md +128 -91
- package/agents/ralph/claude/frontmatter.json +3 -3
- package/agents/ralph/claude/wake.md +46 -0
- package/agents/ralph/pi/{polling.md → wake.md} +35 -20
- package/agents/ralph-plan/agent.md +23 -11
- package/bin/agent-cortex.mjs +66 -4
- package/bin/installers/claude.mjs +262 -77
- package/bin/installers/pi.mjs +200 -24
- package/extensions/agent-guardrails/README.md +1 -1
- package/extensions/agent-guardrails/guardrails.ts +1 -1
- package/extensions/agent-guardrails/index.ts +1 -1
- package/extensions/agent-modes/discover.test.ts +2 -2
- package/extensions/output-style/README.md +58 -0
- package/extensions/output-style/index.ts +23 -0
- package/extensions/output-style/output-style.md +43 -0
- package/extensions/output-style/output-style.test.ts +50 -0
- package/extensions/output-style/output-style.ts +32 -0
- package/extensions/output-style/package.json +19 -0
- package/hooks/claude/scripts/notify.mjs +8 -1
- package/lib/cli.mjs +91 -2
- package/package.json +29 -4
- package/scripts/check-changeset.mjs +4 -0
- package/scripts/check-no-sync.mjs +5 -1
- package/scripts/check-skill-frontmatter.mjs +21 -0
- package/scripts/lib/compose-agent.mjs +69 -9
- package/scripts/lib/fs.mjs +10 -2
- package/scripts/lib/migrate.mjs +736 -0
- package/scripts/lib/release-gates.mjs +21 -1
- package/scripts/lib/skill-frontmatter.mjs +152 -0
- package/scripts/lib/skill-links.mjs +52 -0
- package/scripts/lib/verify-worker-tree.mjs +25 -0
- package/scripts/resolve-publish-command.mjs +4 -0
- package/skills/engineering/design-an-interface/SKILL.md +3 -3
- package/skills/engineering/grill-with-docs/SKILL.md +3 -5
- package/skills/engineering/improve-codebase-architecture/SKILL.md +5 -4
- package/skills/engineering/tdd/SKILL.md +5 -7
- package/skills/engineering/technical-direction/SKILL.md +5 -5
- package/skills/planning/classify-bead/REFERENCE.md +3 -3
- package/skills/planning/classify-bead/SKILL.md +14 -9
- package/skills/planning/classify-bead/scripts/classify-bead.mjs +5 -4
- package/skills/planning/create-task/REFERENCE.md +52 -3
- package/skills/planning/create-task/SKILL.md +70 -26
- package/skills/planning/create-task/scripts/{create-chores.ts → create-chores.mjs} +48 -23
- package/skills/planning/flesh-out-idea/REFERENCE.md +116 -0
- package/skills/planning/flesh-out-idea/SKILL.md +146 -0
- package/skills/planning/prd-to-tasks/REFERENCE.md +54 -5
- package/skills/planning/prd-to-tasks/SKILL.md +22 -16
- package/skills/planning/record-idea/FORMAT.md +66 -17
- package/skills/planning/record-idea/SKILL.md +48 -24
- package/skills/planning/request-refactor-plan/SKILL.md +4 -4
- package/skills/planning/spec-to-tickets/SKILL.md +5 -5
- package/skills/planning/write-a-prd/SKILL.md +8 -9
- package/skills/planning/write-a-ticket/SKILL.md +4 -6
- package/skills/productivity/bd-tool/SKILL.md +15 -10
- package/skills/productivity/grill-me/SKILL.md +5 -0
- package/skills/productivity/init-beads/SKILL.md +3 -3
- package/skills/productivity/migration/REFERENCE.md +227 -0
- package/skills/productivity/migration/SKILL.md +188 -0
- package/skills/productivity/validate-agent-dir/scripts/{validate-agent-dir.ts → validate-agent-dir.mjs} +49 -28
- package/skills/productivity/write-a-skill/SKILL.md +5 -5
- package/skills/review/maintain-agent-docs/SKILL.md +5 -7
- package/skills/review/refactor-skill/SKILL.md +4 -3
- package/skills/review/review-security/SKILL.md +3 -5
- package/skills/style/style-code/SKILL.md +5 -10
- package/skills/style/style-comms/SKILL.md +5 -7
- package/skills/style/style-documentation/SKILL.md +5 -6
- package/skills/style/style-tests/SKILL.md +5 -7
- package/skills/workflow/git-workflow/SKILL.md +28 -4
- package/skills/workflow/hitl-collab/SKILL.md +4 -6
- package/skills/workflow/plan/SKILL.md +41 -30
- package/skills/workflow/ralph/REFERENCE.md +209 -55
- package/skills/workflow/ralph/SKILL.md +89 -50
- package/skills/workflow/ralph/scripts/pending-gates.mjs +134 -0
- package/skills/workflow/run-pipeline-stage/SKILL.md +72 -71
- package/skills/workflow/run-pipeline-stage/playbooks/code.md +11 -0
- package/skills/workflow/run-pipeline-stage/playbooks/document.md +12 -0
- package/skills/workflow/run-pipeline-stage/playbooks/fix.md +12 -0
- package/skills/workflow/run-pipeline-stage/playbooks/review.md +11 -0
- package/skills/workflow/run-pipeline-stage/playbooks/verify.md +13 -0
- package/skills/workflow/run-pipeline-stage/prompts/stage-runner.md +73 -1
- package/token-map.README.md +9 -4
- package/token-map.json +13 -6
- package/.agents/skills/style-versioning/SKILL.md +0 -172
- package/.changeset/README.md +0 -8
- package/.changeset/config.json +0 -11
- package/.github/workflows/ci.yml +0 -302
- package/.junie/guidelines.md +0 -87
- package/.junie/mcp/mcp.json +0 -10
- package/AGENTS.md +0 -151
- package/agents/ralph/claude/polling.md +0 -31
- package/agents-native/ralph.md +0 -193
- package/docs/claude-hooks.md +0 -128
- package/docs/commit-signing.md +0 -231
- package/docs/ideas/auto-model-selection.md +0 -75
- package/docs/ideas/ban-content-assertion-tests.md +0 -75
- package/docs/ideas/bead-progress-notes-for-session-resumption.md +0 -75
- package/docs/ideas/classify-beads-via-deterministic-script-with-agent-fallback.md +0 -70
- package/docs/ideas/ctx-alias-version-and-update-commands.md +0 -65
- package/docs/ideas/ctx-feedback-command-for-capturing-ideas-from-any-machine.md +0 -67
- package/docs/ideas/dedicated-review-tests-skill.md +0 -71
- package/docs/ideas/deployment-beads-for-post-merge-pipelines.md +0 -36
- package/docs/ideas/design-and-delivery-as-separate-bead-trees/design-and-delivery-as-separate-bead-trees.md +0 -99
- package/docs/ideas/enable-signed-commit-enforcement-changesets-bot-commits-must-be-signed.md +0 -42
- package/docs/ideas/extension-manifests.md +0 -121
- package/docs/ideas/improve-pi-system-prompt.md +0 -37
- package/docs/ideas/map-every-cortex-tool-to-a-real-pi-equivalent.md +0 -95
- package/docs/ideas/model-selector.md +0 -40
- package/docs/ideas/pi-task-memory.md +0 -34
- package/docs/ideas/qa-extension.md +0 -43
- package/docs/ideas/record-idea-implementation-questions.md +0 -68
- package/docs/ideas/record-ideas-as-beads.md +0 -115
- package/docs/ideas/self-flushing-session.md +0 -72
- package/docs/ideas/state-in-beads-not-md.md +0 -69
- package/docs/ideas/subagent-visibility.md +0 -72
- package/docs/ideas/task-session-context.local.md +0 -95
- package/docs/ideas/task-session-context.md +0 -89
- package/docs/ideas/test-data-creation-helpers.md +0 -92
- package/docs/ideas/test-planning-stage-bead-in-feature-planning.md +0 -68
- package/docs/ideas/unified-ci-pipeline.md +0 -28
- package/docs/ideas/unify-the-ticket-and-task-decomposition-workflows.md +0 -71
- package/docs/ideas/worktree-housekeeping-for-closed-beads.md +0 -39
- package/docs/inspirations.md +0 -33
- package/docs/research/disable-model-invocation.md +0 -82
- package/docs/research/gas-city-research.md +0 -256
- package/docs/research/skill-design-research.md +0 -286
- package/docs/research/skill-improvements-analysis.md +0 -117
- package/docs/research/skill-patterns-research.md +0 -24
- package/docs/skills/skill-anatomy.md +0 -197
- package/docs/technical-direction/harness-config.md +0 -177
- package/docs/technical-direction/migration-execution-labels.md +0 -177
- package/docs/user-preferences.md +0 -17
- package/extensions/agent-guardrails/tsconfig.json +0 -12
- package/extensions/agent-modes/tsconfig.json +0 -12
- package/extensions/session-start/tsconfig.json +0 -12
- package/extensions/skill-stats/pnpm-lock.yaml +0 -329
- package/extensions/skill-stats/tsconfig.json +0 -12
- package/extensions/subagent/agents/planner.md +0 -37
- package/extensions/subagent/agents/reviewer.md +0 -35
- package/extensions/subagent/agents/scout.md +0 -50
- package/extensions/subagent/agents/worker.md +0 -24
- package/extensions/subagent/prompts/implement-and-review.md +0 -10
- package/extensions/subagent/prompts/implement.md +0 -10
- package/extensions/subagent/prompts/scout-and-plan.md +0 -9
- package/extensions/subagent/tsconfig.json +0 -12
- package/pi/keybindings.json +0 -3
- package/pi/settings.json +0 -34
- package/pnpm-workspace.yaml +0 -14
- package/skills/planning/create-task/scripts/package.json +0 -14
- package/skills/planning/create-task/scripts/pnpm-lock.yaml +0 -342
- package/skills/planning/create-task/scripts/tsconfig.json +0 -12
- package/skills/planning/record-idea/scripts/new-idea.sh +0 -89
- package/skills/productivity/validate-agent-dir/scripts/package.json +0 -15
- package/skills/productivity/validate-agent-dir/scripts/pnpm-lock.yaml +0 -329
- package/skills/productivity/validate-agent-dir/scripts/pnpm-workspace.yaml +0 -2
- package/skills/productivity/validate-agent-dir/scripts/tsconfig.json +0 -19
- package/skills/productivity/validate-agent-dir/scripts/validate-agent-dir.test.ts +0 -317
- package/skills/workflow/run-pipeline-stage/REFERENCE.md +0 -22
- package/skills/workflow/run-pipeline-stage/scripts/generate-progress.test.ts +0 -682
- package/skills/workflow/run-pipeline-stage/scripts/generate-progress.ts +0 -588
- package/skills/workflow/run-pipeline-stage/scripts/package.json +0 -15
- package/skills/workflow/run-pipeline-stage/scripts/pnpm-lock.yaml +0 -342
- package/skills/workflow/run-pipeline-stage/scripts/pnpm-workspace.yaml +0 -2
- package/skills/workflow/run-pipeline-stage/scripts/tsconfig.json +0 -12
- package/test/classify-bead.test.mjs +0 -277
- package/test/claude-installer.test.mjs +0 -308
- package/test/cli.test.mjs +0 -314
- package/test/create-chores.test.mjs +0 -85
- package/test/fake-bd.test.mjs +0 -72
- package/test/fs.test.mjs +0 -76
- package/test/helpers/fake-bd.mjs +0 -87
- package/test/helpers/fake-claude.mjs +0 -152
- package/test/helpers/fake-pi.mjs +0 -132
- package/test/installer.test.mjs +0 -664
- package/test/notify-hook.test.mjs +0 -63
- package/test/pi-installer.test.mjs +0 -785
- package/test/pi-register.test.mjs +0 -342
- package/test/pi-sweep.test.mjs +0 -619
- package/test/release-gates.test.mjs +0 -190
- 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
|
+
}
|