@jaybeeuu/agent-cortex 2.5.1 → 2.6.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,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.6.0
4
+
5
+ ### Minor Changes
6
+
7
+ - c14bf85: `ctx version` now reports the installed harness versions beside the running CLI version: one line per installed harness read from the fingerprint its installer wrote (`~/.agent-cortex/pi/package.json`, `~/.agent-cortex/claude/.claude-plugin/plugin.json`), marked `current`, `stale`, `ahead` when the installed copy is newer, or `unknown` when the fingerprint is missing or unreadable. `ctx version --json` prints the same data as records. The command is read-only — no install, no network, no writes — and `--version` still prints the bare CLI version.
8
+
9
+ ### Patch Changes
10
+
11
+ - ccb1788: Add `skills/workflow/ralph/scripts/prune-worktrees.mjs`, the deterministic pass that converges the
12
+ ralph worktree set with bead state. It scans `.agent-cortex/worktrees/*`, classifies each directory
13
+ by its bead as a closed-bead worktree, an open-bead worktree, or an orphan, and removes a
14
+ closed-bead worktree with its local and remote branch. A dirty tree, a locked worktree, or an
15
+ unpushed branch refuses that worktree with the reason. Orphans are reported only, and
16
+ `--remove-orphans` is the single path that removes one.
17
+
3
18
  ## 2.5.1
4
19
 
5
20
  ### Patch Changes
package/README.md CHANGED
@@ -478,6 +478,30 @@ re-registers it; `--output <dir>` generates a preview only). The only committed
478
478
  so never edit anything under it. CI validates the Claude materialiser (structural checks on
479
479
  a temp-dir install) so sources and installer can never silently diverge.
480
480
 
481
+ ## Checking installed versions (`agent-cortex version`)
482
+
483
+ `agent-cortex version` prints the running CLI version, then one line for each installed
484
+ harness: its name, the version its installer recorded, and a marker. The fingerprint is the
485
+ manifest the installer already writes — `~/.agent-cortex/pi/package.json` for pi and
486
+ `~/.agent-cortex/claude/.claude-plugin/plugin.json` for Claude Code — so the report needs no
487
+ separate state file.
488
+
489
+ ```sh
490
+ agent-cortex version # CLI version, then one line per installed harness
491
+ agent-cortex version --json # the same data as JSON records
492
+ agent-cortex --version # the bare CLI version, for scripts
493
+ ```
494
+
495
+ The marker is `current` when the installed copy matches the running CLI, `stale` when it is
496
+ older, and `ahead` when it is newer — a downgraded CLI leaves its installs ahead of it. A
497
+ harness root that exists with a missing or broken fingerprint reports `unknown` for both the
498
+ version and the marker, because an install that cannot be read is never reported as `current`.
499
+ Only plain release versions are compared, so a prerelease on either side also reports
500
+ `unknown`. A harness with no installer gets no line.
501
+
502
+ The command is read-only: it stats and reads those manifests, and never installs, writes, or
503
+ reaches the network.
504
+
481
505
  ## Migrating a beads workspace
482
506
 
483
507
  `agent-cortex migrate` brings the `.beads` workspace it runs in — any repo, not just this
@@ -1,6 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { parseArgs, buildHelpText, resolveProgramName, validateHarness } from "../lib/cli.mjs";
4
+ import {
5
+ collectVersionReport,
6
+ formatVersionReport,
7
+ serialiseVersionReport,
8
+ } from "../lib/harness-versions.mjs";
4
9
  import { join, dirname } from "node:path";
5
10
  import { fileURLToPath } from "node:url";
6
11
  import { readFile } from "node:fs/promises";
@@ -45,8 +50,22 @@ async function readPackageVersion() {
45
50
  }
46
51
 
47
52
  if (parsed.command === "version") {
53
+ if (parsed.optionError) {
54
+ process.stderr.write(`${parsed.optionError}\n`);
55
+ process.exit(1);
56
+ }
48
57
  try {
49
- process.stdout.write(`${await readPackageVersion()}\n`);
58
+ const cliVersion = await readPackageVersion();
59
+ // `--version` prints the bare version for scripts; the `version` subcommand
60
+ // adds one line per installed harness, read from the installers'
61
+ // fingerprints (read-only — no install, no network, no writes).
62
+ if (parsed.report === true) {
63
+ const report = await collectVersionReport({ cli: cliVersion });
64
+ const output = parsed.json === true ? serialiseVersionReport(report) : formatVersionReport(report);
65
+ process.stdout.write(`${output}\n`);
66
+ } else {
67
+ process.stdout.write(`${cliVersion}\n`);
68
+ }
50
69
  } catch (err) {
51
70
  process.stderr.write(`${/** @type {Error} */ (err).message}\n`);
52
71
  process.exit(1);
package/lib/cli.mjs CHANGED
@@ -5,6 +5,8 @@ import { basename } from "node:path";
5
5
  * populated only for the commands that accept them.
6
6
  * @typedef {object} ParsedArgs
7
7
  * @property {"help" | "version" | "install" | "migrate" | "unknown"} command
8
+ * @property {boolean} [report] `version` subcommand: report installed harnesses too
9
+ * @property {boolean} [json] `version --json`: print the report as JSON
8
10
  * @property {string | null} [harness]
9
11
  * @property {string} [name]
10
12
  * @property {string} [optionError]
@@ -69,6 +71,20 @@ export function parseArgs(argv, programName = DEFAULT_PROGRAM_NAME) {
69
71
 
70
72
  const [command, ...rest] = argv;
71
73
 
74
+ if (command === "version") {
75
+ // The `version` subcommand reports the installed harness versions; the
76
+ // `--version` flag above stays a bare CLI version for scripts.
77
+ let json = false;
78
+ for (const arg of rest) {
79
+ if (arg === "--json") {
80
+ json = true;
81
+ continue;
82
+ }
83
+ return { command: "version", report: true, optionError: `Unknown option "${arg}". Run "${programName} --help" for usage.` };
84
+ }
85
+ return json ? { command: "version", report: true, json } : { command: "version", report: true };
86
+ }
87
+
72
88
  if (command === "install") {
73
89
  let harness = null;
74
90
  let i = 0;
@@ -231,6 +247,13 @@ Commands:
231
247
  --apply Apply every pending step, verifying each
232
248
  --dir <path> Read idea files from <path> instead of
233
249
  docs/ideas (step v2's source)
250
+ version Print the running CLI version, then one line per installed
251
+ harness: its name, the version its installer recorded, and
252
+ a marker comparing the two — current, stale, ahead (the
253
+ installed copy is newer), or unknown (no readable
254
+ fingerprint). Read-only: no install, no network, no writes.
255
+ Options:
256
+ --json Print the same data as JSON records
234
257
  --help, -h Show this help text
235
258
  --version, -V Print the package version`;
236
259
  }
@@ -0,0 +1,156 @@
1
+ // The read side of the installers' fingerprints: `ctx version` reports which
2
+ // harnesses are installed and how their version compares with the running CLI.
3
+ //
4
+ // Read-only by construction — the module only stats and reads, so the command
5
+ // path never writes, installs, or reaches the network. A harness with no
6
+ // installer gets no entry in the table, so it can never appear in the report.
7
+
8
+ import { readFile } from "node:fs/promises";
9
+ import { homedir } from "node:os";
10
+ import { join } from "node:path";
11
+ import { isDirectory } from "../scripts/lib/fs.mjs";
12
+
13
+ /**
14
+ * Where one harness records the version it was installed at: an artifact root
15
+ * under the home install directory, and the version file inside it.
16
+ * @typedef {object} HarnessFingerprint
17
+ * @property {"claude" | "pi"} harness
18
+ * @property {string[]} root Path segments of the artifact root, relative to home
19
+ * @property {string[]} file Path segments of the version file, relative to the root
20
+ */
21
+
22
+ // Ordered as the installers are listed; the report keeps this order.
23
+ /** @type {HarnessFingerprint[]} */
24
+ const HARNESS_FINGERPRINTS = [
25
+ { harness: "claude", root: [".agent-cortex", "claude"], file: [".claude-plugin", "plugin.json"] },
26
+ { harness: "pi", root: [".agent-cortex", "pi"], file: ["package.json"] },
27
+ ];
28
+
29
+ /**
30
+ * How an installed copy relates to the running CLI. `unknown` covers both a
31
+ * fingerprint that cannot be read and a version that is not a plain release —
32
+ * an unreadable install is never reported as `current`.
33
+ * @typedef {"current" | "stale" | "ahead" | "unknown"} Staleness
34
+ */
35
+
36
+ /**
37
+ * One installed harness in the report.
38
+ * @typedef {object} HarnessVersion
39
+ * @property {string} harness
40
+ * @property {string} installed The installed version, or "unknown"
41
+ * @property {string} cli The running CLI version
42
+ * @property {Staleness} staleness
43
+ */
44
+
45
+ /**
46
+ * Everything the `version` command reports.
47
+ * @typedef {object} VersionReport
48
+ * @property {string} cli The running CLI version
49
+ * @property {HarnessVersion[]} harnesses One entry per installed harness
50
+ */
51
+
52
+ const UNKNOWN = "unknown";
53
+
54
+ // Only plain releases are compared. A prerelease or a range is not "current"
55
+ // just because its numeric core matches, so it reports unknown instead.
56
+ const PLAIN_RELEASE = /^(\d+)\.(\d+)\.(\d+)$/;
57
+
58
+ /**
59
+ * Compare two plain semver releases.
60
+ * @param {string} a
61
+ * @param {string} b
62
+ * @returns {-1 | 0 | 1 | null} The order, or null when either side is not a plain release
63
+ */
64
+ function compareReleases(a, b) {
65
+ const left = PLAIN_RELEASE.exec(a);
66
+ const right = PLAIN_RELEASE.exec(b);
67
+ if (left === null || right === null) return null;
68
+ for (let segment = 1; segment <= 3; segment++) {
69
+ const l = Number(left[segment]);
70
+ const r = Number(right[segment]);
71
+ if (l !== r) return l < r ? -1 : 1;
72
+ }
73
+ return 0;
74
+ }
75
+
76
+ /**
77
+ * @param {string | null} installed null when the fingerprint is unreadable
78
+ * @param {string} cli
79
+ * @returns {Staleness}
80
+ */
81
+ function stalenessOf(installed, cli) {
82
+ if (installed === null) return "unknown";
83
+ const order = compareReleases(installed, cli);
84
+ if (order === null) return "unknown";
85
+ if (order < 0) return "stale";
86
+ return order > 0 ? "ahead" : "current";
87
+ }
88
+
89
+ /**
90
+ * Read the version one harness recorded at install time.
91
+ * @param {string} home
92
+ * @param {HarnessFingerprint} fingerprint
93
+ * @returns {Promise<{ installed: boolean, version: string | null }>} `installed`
94
+ * is false when the artifact root is absent; `version` is null when the
95
+ * fingerprint is missing, malformed, or carries no version string
96
+ */
97
+ async function readInstalledVersion(home, fingerprint) {
98
+ const root = join(home, ...fingerprint.root);
99
+ if (!(await isDirectory(root))) return { installed: false, version: null };
100
+ try {
101
+ /** @type {{ version?: unknown }} */
102
+ const manifest = JSON.parse(await readFile(join(root, ...fingerprint.file), "utf-8"));
103
+ const version = manifest?.version;
104
+ return { installed: true, version: typeof version === "string" && version !== "" ? version : null };
105
+ } catch {
106
+ return { installed: true, version: null };
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Report the running CLI version and the installed version of every harness
112
+ * whose artifact root exists.
113
+ * @param {{ cli: string, home?: string }} options `home` defaults to the real
114
+ * home directory; tests pass a fixture directory
115
+ * @returns {Promise<VersionReport>}
116
+ */
117
+ export async function collectVersionReport({ cli, home = homedir() }) {
118
+ /** @type {HarnessVersion[]} */
119
+ const harnesses = [];
120
+ for (const fingerprint of HARNESS_FINGERPRINTS) {
121
+ const { installed, version } = await readInstalledVersion(home, fingerprint);
122
+ if (!installed) continue;
123
+ harnesses.push({
124
+ harness: fingerprint.harness,
125
+ installed: version ?? UNKNOWN,
126
+ cli,
127
+ staleness: stalenessOf(version, cli),
128
+ });
129
+ }
130
+ return { cli, harnesses };
131
+ }
132
+
133
+ /**
134
+ * Render the report as the default, human-readable output: the CLI version on
135
+ * the first line, then one line per installed harness. A harness whose version
136
+ * could not be read prints `unknown` twice — once for the version and once for
137
+ * the marker, because neither is known.
138
+ * @param {VersionReport} report
139
+ * @returns {string} Text without a trailing newline
140
+ */
141
+ export function formatVersionReport(report) {
142
+ const lines = [report.cli];
143
+ for (const entry of report.harnesses) {
144
+ lines.push(`${entry.harness} ${entry.installed} ${entry.staleness}`);
145
+ }
146
+ return lines.join("\n");
147
+ }
148
+
149
+ /**
150
+ * Render the report as JSON, one record per installed harness.
151
+ * @param {VersionReport} report
152
+ * @returns {string} JSON without a trailing newline
153
+ */
154
+ export function serialiseVersionReport(report) {
155
+ return JSON.stringify(report, null, 2);
156
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jaybeeuu/agent-cortex",
3
- "version": "2.5.1",
3
+ "version": "2.6.0",
4
4
  "description": "Personal PI package with custom agents, skills, and extensions",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -204,6 +204,27 @@ For each parent task bead (`<parent-id>`):
204
204
 
205
205
  ---
206
206
 
207
+ ## Pruning Worktrees
208
+
209
+ A worktree outlives its bead: ralph leaves the directory behind when a feature closes, and a crashed run leaves one with no bead at all. `skills/workflow/ralph/scripts/prune-worktrees.mjs` converges `.agent-cortex/worktrees/*` with bead state.
210
+
211
+ ```bash
212
+ node skills/workflow/ralph/scripts/prune-worktrees.mjs # remove closed-bead worktrees, report the rest
213
+ node skills/workflow/ralph/scripts/prune-worktrees.mjs --remove-orphans # also remove open-bead and missing-bead worktrees
214
+ ```
215
+
216
+ **Candidacy comes from bead state, never from git ancestry.** The repo squash-merges, so every landed branch still reports as unmerged. A directory named after a bead that `bd show` reports `closed` is the only proof that admits removal; an open bead, a missing bead, or a bead `bd` could not answer for is kept unless `--remove-orphans` asks otherwise. A bead `bd` could not read is never removed, whatever the flags say.
217
+
218
+ **A candidate that fails a guard is refused, and the reason is printed.** The guards are: dirty working tree, `locked` worktree, detached HEAD, directory not registered with git, branch with no upstream, and branch with unpushed commits.
219
+
220
+ **Removal is three commands.** `git worktree remove <path>`, then `git branch -D <branch>`, then `git push origin --delete <branch>`. The remote branch name is the worktree's own checked-out branch, so ralph's `feature/<parent-id>` deletes `origin/feature/<parent-id>`. A remote ref that git already reports as gone is not a failure.
221
+
222
+ **Exit status is 1 whenever any scanned worktree survived the run.** An open bead counts as survival, so a normal run against a live backlog exits 1. Read the per-worktree report; do not treat the status as a verdict on the worktree you asked about.
223
+
224
+ **An unregistered stale directory is refused forever.** Earlier git runs may have dropped its registration, so every scan reports it and never removes it. Clean it up by hand: `git worktree prune`, then `rm -rf <path>`.
225
+
226
+ ---
227
+
207
228
  ## Feature PR Gate
208
229
 
209
230
  When the `document` chore for a parent task completes:
@@ -96,7 +96,7 @@ bd show <id> # full detail, including the worker's notes
96
96
  - **Multi-feature epics** (two or more feature task children): the epic runs on `epic/<epic-id>`, based on `origin/main` — never local `main`. Each `execution:afk` parent task runs on `feature/<parent-id>`, based from its epic branch, in the dedicated worktree `.agent-cortex/worktrees/<parent-id>`.
97
97
  - Feature PR gate: `feature/<parent-id>` → `epic/<epic-id>` (or → `main` for single-feature epics). Epic PR gate: `epic/<epic-id>` → `main`.
98
98
  - **Never stack branches.** `feature/<parent-id>` is based on `epic/<epic-id>` (or `origin/main`), never on another `feature/*`. A dependency between two tasks lives in the bead graph (`bd dep add <waiting> <blocked-on>`) so the dependent task stays out of `bd ready` until its dependency has merged — stacking the branch defeats that gate by hand and costs a rebase-on-squash later (see `git-workflow` rule 3).
99
- - Branch/worktree setup and the PR commands live in REFERENCE.md (_Feature branches and worktrees_, _Feature PR gate_).
99
+ - Branch/worktree setup and the PR commands live in REFERENCE.md (_Feature branches and worktrees_, _Feature PR gate_). Closed-bead worktrees are reclaimed by `skills/workflow/ralph/scripts/prune-worktrees.mjs`, which removes worktrees whose bead is closed, reports the rest, and removes an orphan only with `--remove-orphans` (REFERENCE.md, _Pruning Worktrees_).
100
100
 
101
101
  ### 7. HITL pause and shutdown
102
102
 
@@ -0,0 +1,346 @@
1
+ // Converges the ralph worktree set with bead state.
2
+ //
3
+ // Ralph runs each bead in `.agent-cortex/worktrees/<bead-id>` and leaves the
4
+ // worktree behind after the bead closes; crashed runs leave orphans. This pass
5
+ // reads bead state, not git ancestry: this repo squash-merges, so every ancestry
6
+ // test reports a landed branch as unmerged.
7
+ //
8
+ // Zero dependencies: run with `node prune-worktrees.mjs`.
9
+
10
+ import { execFile } from 'node:child_process';
11
+ import { readdir } from 'node:fs/promises';
12
+ import { dirname, join } from 'node:path';
13
+ import { promisify } from 'node:util';
14
+ import { pathToFileURL } from 'node:url';
15
+
16
+ const execFileAsync = promisify(execFile);
17
+
18
+ // BD_PATH env override lets machines where bd is not on PATH pin a location.
19
+ const BD_PATH = process.env.BD_PATH || 'bd';
20
+
21
+ /** @typedef {'closed-bead' | 'open-bead' | 'orphan' | 'unclassified'} Classification */
22
+ /** @typedef {'removed' | 'kept' | 'refused'} Outcome */
23
+
24
+ /**
25
+ * @typedef {object} WorktreeEntry
26
+ * @property {string} id Bead id, taken from the directory name.
27
+ * @property {string} path Absolute path of the worktree.
28
+ * @property {string | null} branch Checked-out branch, or null when unknown.
29
+ * @property {Classification} classification
30
+ * @property {string} beadStatus Raw `bd` status, or `'missing'`.
31
+ * @property {Outcome} outcome
32
+ * @property {string[]} reasons Why the worktree was not removed.
33
+ */
34
+
35
+ /**
36
+ * @typedef {object} PruneResult
37
+ * @property {WorktreeEntry[]} entries
38
+ * @property {number} exitCode
39
+ */
40
+
41
+ /**
42
+ * The text of a failed child-process call: git's stderr when it has one, the
43
+ * error message otherwise.
44
+ * @param {unknown} error
45
+ * @returns {string}
46
+ */
47
+ function errorText(error) {
48
+ if (typeof error === 'object' && error !== null) {
49
+ const { stderr, message } = /** @type {{ stderr?: unknown, message?: unknown }} */ (error);
50
+ if (typeof stderr === 'string' && stderr.trim() !== '') return stderr;
51
+ if (typeof message === 'string') return message;
52
+ }
53
+ return String(error);
54
+ }
55
+
56
+ /**
57
+ * @param {unknown} error
58
+ * @returns {boolean} true when the error is a missing path, not a failure.
59
+ */
60
+ function isMissingPath(error) {
61
+ return typeof error === 'object' && error !== null && /** @type {{ code?: unknown }} */ (error).code === 'ENOENT';
62
+ }
63
+
64
+ /** @type {(args: string[]) => Promise<string>} */
65
+ async function runBd(args) {
66
+ const { stdout } = await execFileAsync(BD_PATH, args, { encoding: 'utf-8' });
67
+ return stdout;
68
+ }
69
+
70
+ /**
71
+ * Run git in `cwd` and report the outcome rather than throwing: a refusal is an
72
+ * expected domain result here, not an emergency.
73
+ * @param {string} cwd
74
+ * @param {string[]} args
75
+ * @returns {Promise<{ ok: true, stdout: string } | { ok: false, stderr: string }>}
76
+ */
77
+ async function runGit(cwd, args) {
78
+ try {
79
+ const { stdout } = await execFileAsync('git', args, { cwd, encoding: 'utf-8' });
80
+ return { ok: true, stdout };
81
+ } catch (error) {
82
+ return { ok: false, stderr: errorText(error) };
83
+ }
84
+ }
85
+
86
+ /**
87
+ * List the worktree directories under `.agent-cortex/worktrees`, ignoring
88
+ * everything else on disk. A missing directory means nothing to prune.
89
+ * @param {string} repoRoot
90
+ * @returns {Promise<string[]>} absolute paths, sorted for stable output.
91
+ */
92
+ async function listWorktreePaths(repoRoot) {
93
+ const worktreesDir = join(repoRoot, '.agent-cortex', 'worktrees');
94
+ /** @type {import('node:fs').Dirent[]} */
95
+ let dirents;
96
+ try {
97
+ dirents = await readdir(worktreesDir, { withFileTypes: true });
98
+ } catch (error) {
99
+ if (isMissingPath(error)) return [];
100
+ throw error;
101
+ }
102
+ return dirents
103
+ .filter((dirent) => dirent.isDirectory())
104
+ .map((dirent) => join(worktreesDir, dirent.name))
105
+ .sort();
106
+ }
107
+
108
+ /**
109
+ * Map each registered worktree path to its checked-out branch and lock state.
110
+ * @param {string} repoRoot
111
+ * @returns {Promise<Map<string, { branch: string | null, locked: string | null }>>}
112
+ */
113
+ async function readRegisteredWorktrees(repoRoot) {
114
+ const result = await runGit(repoRoot, ['worktree', 'list', '--porcelain']);
115
+ /** @type {Map<string, { branch: string | null, locked: string | null }>} */
116
+ const registered = new Map();
117
+ if (!result.ok) return registered;
118
+
119
+ for (const block of result.stdout.split('\n\n')) {
120
+ const lines = block.split('\n');
121
+ const pathLine = lines.find((line) => line.startsWith('worktree '));
122
+ if (pathLine === undefined) continue;
123
+ const branchLine = lines.find((line) => line.startsWith('branch '));
124
+ const lockedLine = lines.find((line) => line.startsWith('locked'));
125
+ registered.set(pathLine.slice('worktree '.length), {
126
+ branch: branchLine === undefined ? null : branchLine.slice('branch refs/heads/'.length),
127
+ locked: lockedLine === undefined ? null : lockedLine.slice('locked'.length).trim(),
128
+ });
129
+ }
130
+ return registered;
131
+ }
132
+
133
+ /**
134
+ * Resolve one worktree's bead and classify it.
135
+ * @param {string} id
136
+ * @param {(args: string[]) => Promise<string>} exec
137
+ * @returns {Promise<{ classification: Classification, beadStatus: string }>}
138
+ */
139
+ async function classifyBead(id, exec) {
140
+ let stdout;
141
+ try {
142
+ stdout = await exec(['show', id, '--json']);
143
+ } catch (error) {
144
+ const message = errorText(error);
145
+ if (/not found|no issues found/i.test(message)) {
146
+ return { classification: 'orphan', beadStatus: 'missing' };
147
+ }
148
+ return { classification: 'unclassified', beadStatus: `error: ${message.trim()}` };
149
+ }
150
+
151
+ const beads = JSON.parse(stdout);
152
+ const bead = (Array.isArray(beads) ? beads[0] : beads) ?? {};
153
+ const status = String(bead.status ?? 'unknown');
154
+ return { classification: status === 'closed' ? 'closed-bead' : 'open-bead', beadStatus: status };
155
+ }
156
+
157
+ /**
158
+ * Reasons git refuses to tear a worktree down. A worktree removed anyway would
159
+ * throw away uncommitted work or an unpushed branch, so every reason keeps the
160
+ * worktree in place.
161
+ * @param {{ path: string, registered: { branch: string | null, locked: string | null } | undefined }} target
162
+ * @param {(cwd: string, args: string[]) => Promise<{ ok: true, stdout: string } | { ok: false, stderr: string }>} git
163
+ * @returns {Promise<{ branch: string | null, reasons: string[] }>}
164
+ */
165
+ async function findRefusals({ path, registered }, git) {
166
+ /** @type {string[]} */
167
+ const reasons = [];
168
+
169
+ if (registered === undefined) {
170
+ return { branch: null, reasons: ['not a registered git worktree'] };
171
+ }
172
+ if (registered.locked !== null) {
173
+ reasons.push(registered.locked === '' ? 'worktree is locked' : `worktree is locked: ${registered.locked}`);
174
+ }
175
+ if (registered.branch === null) {
176
+ return { branch: null, reasons: [...reasons, 'worktree is not on a branch'] };
177
+ }
178
+
179
+ const status = await git(path, ['status', '--porcelain']);
180
+ if (!status.ok) return { branch: registered.branch, reasons: [...reasons, `worktree cannot be read: ${status.stderr.trim()}`] };
181
+ const dirtyPaths = status.stdout.split('\n').filter((line) => line !== '').length;
182
+ if (dirtyPaths > 0) reasons.push(`worktree is dirty: ${dirtyPaths} uncommitted path(s)`);
183
+
184
+ const upstream = await git(path, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}']);
185
+ if (!upstream.ok) {
186
+ reasons.push(`branch ${registered.branch} has no remote-tracking branch`);
187
+ return { branch: registered.branch, reasons };
188
+ }
189
+ const trackingRef = upstream.stdout.trim();
190
+ const unpushed = await git(path, ['rev-list', '--count', `${trackingRef}..HEAD`]);
191
+ if (!unpushed.ok) return { branch: registered.branch, reasons: [...reasons, `branch ${registered.branch} cannot be compared with ${trackingRef}: ${unpushed.stderr.trim()}`] };
192
+ const unpushedCommits = Number.parseInt(unpushed.stdout.trim(), 10);
193
+ if (unpushedCommits > 0) {
194
+ reasons.push(`branch ${registered.branch} has ${unpushedCommits} commit(s) not pushed to ${trackingRef}`);
195
+ }
196
+
197
+ return { branch: registered.branch, reasons };
198
+ }
199
+
200
+ /**
201
+ * Remove a worktree and its branch, locally and on the remote.
202
+ * @param {string} repoRoot
203
+ * @param {{ path: string, branch: string }} target
204
+ * @param {(cwd: string, args: string[]) => Promise<{ ok: true, stdout: string } | { ok: false, stderr: string }>} git
205
+ * @returns {Promise<{ ok: true } | { ok: false, reasons: string[] }>}
206
+ */
207
+ async function removeWorktree(repoRoot, { path, branch }, git) {
208
+ /** @type {string[]} */
209
+ const reasons = [];
210
+
211
+ const removed = await git(repoRoot, ['worktree', 'remove', path]);
212
+ if (!removed.ok) {
213
+ return { ok: false, reasons: [`git worktree remove failed: ${removed.stderr.trim()}`] };
214
+ }
215
+
216
+ const local = await git(repoRoot, ['branch', '-D', branch]);
217
+ if (!local.ok) reasons.push(`local branch ${branch} could not be deleted: ${local.stderr.trim()}`);
218
+
219
+ const remote = await git(repoRoot, ['push', 'origin', '--delete', branch]);
220
+ const remoteGone = /remote ref does not exist/i.test(remote.ok ? '' : remote.stderr);
221
+ if (!remote.ok && !remoteGone) {
222
+ reasons.push(`remote branch ${branch} could not be deleted: ${remote.stderr.trim()}`);
223
+ }
224
+
225
+ return reasons.length === 0 ? { ok: true } : { ok: false, reasons };
226
+ }
227
+
228
+ /**
229
+ * Converge `.agent-cortex/worktrees` with bead state: remove closed-bead
230
+ * worktrees, report everything else.
231
+ * @param {object} options
232
+ * @param {string} options.repoRoot Repository to prune.
233
+ * @param {boolean} [options.removeOrphans] Also remove worktrees whose bead is open or missing.
234
+ * @param {(args: string[]) => Promise<string>} [options.exec] bd runner; injectable for tests.
235
+ * @param {(cwd: string, args: string[]) => Promise<{ ok: true, stdout: string } | { ok: false, stderr: string }>} [options.git] git runner; injectable for tests.
236
+ * @returns {Promise<PruneResult>}
237
+ */
238
+ export async function pruneWorktrees({ repoRoot, exec = runBd, removeOrphans = false, git = runGit }) {
239
+ const registered = await readRegisteredWorktrees(repoRoot);
240
+ /** @type {WorktreeEntry[]} */
241
+ const entries = [];
242
+
243
+ for (const path of await listWorktreePaths(repoRoot)) {
244
+ const id = path.slice(dirname(path).length + 1);
245
+ const { classification, beadStatus } = await classifyBead(id, exec);
246
+
247
+ /** @type {WorktreeEntry} */
248
+ const entry = { id, path, branch: registered.get(path)?.branch ?? null, classification, beadStatus, outcome: 'kept', reasons: [] };
249
+
250
+ // A closed bead is the proof. Anything else — an open bead, a missing bead,
251
+ // a bead bd could not answer for — is only removed on explicit request, and
252
+ // an unreadable bead is never removed at all.
253
+ const wanted = classification === 'closed-bead' || (classification !== 'unclassified' && removeOrphans);
254
+ if (wanted) {
255
+ const { branch, reasons } = await findRefusals({ path, registered: registered.get(path) }, git);
256
+ entry.branch = branch;
257
+ if (reasons.length > 0) {
258
+ entry.outcome = 'refused';
259
+ entry.reasons = reasons;
260
+ } else if (branch === null) {
261
+ entry.outcome = 'refused';
262
+ entry.reasons = ['worktree is not on a branch'];
263
+ } else {
264
+ const removal = await removeWorktree(repoRoot, { path, branch }, git);
265
+ entry.outcome = removal.ok ? 'removed' : 'refused';
266
+ if (!removal.ok) entry.reasons = removal.reasons;
267
+ }
268
+ }
269
+
270
+ entries.push(entry);
271
+ }
272
+
273
+ return { entries, exitCode: entries.some((entry) => entry.outcome !== 'removed') ? 1 : 0 };
274
+ }
275
+
276
+ // ─── CLI ──────────────────────────────────────────────────────────────────────
277
+
278
+ const USAGE = `Usage: node prune-worktrees.mjs [--remove-orphans]
279
+
280
+ Removes worktrees under .agent-cortex/worktrees whose bead is closed, then
281
+ reports the rest. A worktree whose bead is open or missing is an orphan: it is
282
+ only removed when --remove-orphans is passed. Run from anywhere inside the
283
+ repository.
284
+ `;
285
+
286
+ /** Every flag the CLI accepts. */
287
+ const REMOVE_ORPHANS_FLAG = '--remove-orphans';
288
+
289
+ class UsageError extends Error {}
290
+
291
+ /**
292
+ * Resolve the repository root from any directory inside it — the main checkout
293
+ * or a linked worktree. The shared `.git` directory is one level below the root
294
+ * in both cases.
295
+ * @param {string} cwd
296
+ * @returns {Promise<string | null>}
297
+ */
298
+ async function resolveRepoRoot(cwd) {
299
+ const result = await runGit(cwd, ['rev-parse', '--path-format=absolute', '--git-common-dir']);
300
+ return result.ok ? dirname(result.stdout.trim()) : null;
301
+ }
302
+
303
+ /**
304
+ * @param {object} options
305
+ * @param {string[]} options.argv CLI arguments (without node/script).
306
+ * @param {string} [options.cwd] Directory the CLI was invoked from. * @param {(args: string[]) => Promise<string>} [options.exec] bd runner; injectable for tests.
307
+ * @param {(cwd: string, args: string[]) => Promise<{ ok: true, stdout: string } | { ok: false, stderr: string }>} [options.git]
308
+ * @param {(chunk: string) => void} [options.write] stdout sink; injectable for tests.
309
+ * @returns {Promise<number>} process exit code.
310
+ */
311
+ export async function runCli({ argv, cwd = process.cwd(), exec = runBd, git = runGit, write = (chunk) => process.stdout.write(chunk) }) {
312
+ if (argv.includes('--help') || argv.includes('-h')) {
313
+ write(USAGE);
314
+ return 0;
315
+ }
316
+ const unknown = argv.filter((arg) => arg !== REMOVE_ORPHANS_FLAG);
317
+ if (unknown.length > 0) throw new UsageError(`unexpected argument: ${unknown[0]}`);
318
+ const removeOrphans = argv.includes(REMOVE_ORPHANS_FLAG);
319
+
320
+ const repoRoot = await resolveRepoRoot(cwd);
321
+ if (repoRoot === null) throw new Error('not inside a git repository');
322
+
323
+ const { entries, exitCode } = await pruneWorktrees({ repoRoot, exec, removeOrphans, git });
324
+
325
+ write(`Scanning ${join(repoRoot, '.agent-cortex', 'worktrees')}\n`);
326
+ for (const entry of entries) {
327
+ const detail = entry.reasons.length > 0 ? ` — ${entry.reasons.join('; ')}` : '';
328
+ write(` ${entry.id.padEnd(24)} ${entry.classification.padEnd(13)} ${entry.outcome}${detail}\n`);
329
+ }
330
+ const removed = entries.filter((entry) => entry.outcome === 'removed').length;
331
+ write(`Removed ${removed} of ${entries.length} worktree(s).\n`);
332
+
333
+ return exitCode;
334
+ }
335
+
336
+ // Only run the CLI when invoked directly, so tests can import the module.
337
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
338
+ runCli({ argv: process.argv.slice(2) })
339
+ .then((code) => {
340
+ process.exitCode = code;
341
+ })
342
+ .catch((error) => {
343
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
344
+ process.exitCode = error instanceof UsageError ? 2 : 1;
345
+ });
346
+ }