@jaybeeuu/agent-cortex 2.5.0 → 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 +52 -0
- package/README.md +35 -5
- package/agents/README.md +3 -1
- package/bin/agent-cortex.mjs +25 -3
- package/bin/installers/claude.mjs +55 -11
- package/extensions/agent-guardrails/package.json +1 -1
- package/extensions/agent-modes/index.test.ts +13 -0
- package/extensions/agent-modes/package.json +1 -1
- package/extensions/lib/tiny-model.ts +7 -6
- package/extensions/output-style/package.json +1 -1
- package/extensions/session-start/package.json +1 -1
- package/extensions/skill-stats/package.json +1 -1
- package/extensions/subagent/package.json +3 -3
- package/lib/cli.mjs +23 -0
- package/lib/harness-versions.mjs +156 -0
- package/package.json +1 -1
- package/{skills/productivity/validate-agent-dir/scripts → scripts}/validate-agent-dir.mjs +20 -14
- package/skills/planning/create-task/REFERENCE.md +12 -1
- package/skills/planning/create-task/templates/code.md +13 -2
- package/skills/planning/create-task/templates/document.md +13 -2
- package/skills/planning/create-task/templates/fix.md +13 -2
- package/skills/planning/create-task/templates/verify.md +14 -8
- package/skills/workflow/ralph/REFERENCE.md +21 -0
- package/skills/workflow/ralph/SKILL.md +1 -1
- package/skills/workflow/ralph/scripts/prune-worktrees.mjs +346 -0
- package/skills/workflow/run-pipeline-stage/SKILL.md +3 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,57 @@
|
|
|
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
|
+
|
|
18
|
+
## 2.5.1
|
|
19
|
+
|
|
20
|
+
### Patch Changes
|
|
21
|
+
|
|
22
|
+
- e87b2d0: Align the code, verify, document, and fix REPORT templates on the authoritative
|
|
23
|
+
`run-pipeline-stage` contract. Each template now emits `STAGE_COMPLETED` with its stage name
|
|
24
|
+
(`code`, `verify`, `document`, `fix`) plus `COMMIT`, `PUSHED`, `PR`, and
|
|
25
|
+
`OUTCOME: SUCCESS|BLOCKED`, and lists blocked work under `BLOCKING_ISSUES`. The unparsed
|
|
26
|
+
`VERIFY_OUTCOME`/`VERIFY_FAILURES` keys are gone, so an orchestrator can route every stage's
|
|
27
|
+
report.
|
|
28
|
+
- 83ed526: Stop `agent-cortex install claude` erasing Claude Code's `enabledPlugins` entry.
|
|
29
|
+
|
|
30
|
+
The settings merge now asserts only the `enabledPlugins` and `extraKnownMarketplaces` entries the
|
|
31
|
+
committed `claude/settings.json` template declares; entries it does not declare survive, so a
|
|
32
|
+
same-version re-run keeps the entry Claude Code writes when the plugin is enabled. Registration
|
|
33
|
+
also reports an installed-but-not-enabled plugin, naming `claude plugin enable <plugin>@<marketplace>`.
|
|
34
|
+
|
|
35
|
+
- 34bcea7: Type-check the extensions against the pi version that is actually installed.
|
|
36
|
+
|
|
37
|
+
The extension workspaces pinned `@earendil-works/pi-coding-agent`, `pi-agent-core`, and `pi-ai` at
|
|
38
|
+
`^0.78.1` for `import type` only, while `pi --version` reported 1.5.2. `pnpm typecheck` therefore
|
|
39
|
+
validated against an API that no installed pi provides. The pins now follow the running CLI at
|
|
40
|
+
`^1.5.2`, and the `agent-modes` `ExtensionAPI` test double gains the members 1.5.x declares:
|
|
41
|
+
`registerMarkdownTransformer`, `registerEntryRenderer`, `registerToolRenderer`, `registerVirtualModel`,
|
|
42
|
+
`registerMcpServer`, `unregisterMcpServer`, `getMcpServers`, `unregisterVirtualModel`, and
|
|
43
|
+
`getSettings`, plus the unsubscribe return value of `on`.
|
|
44
|
+
|
|
45
|
+
- fa84942: Move the composable agent-directory validator from
|
|
46
|
+
`skills/productivity/validate-agent-dir/scripts/` to `scripts/validate-agent-dir.mjs`. The
|
|
47
|
+
directory had no `SKILL.md`, so neither installer shipped it while it still looked like a shipped
|
|
48
|
+
skill, and the module validates this repository's own `agents/` layout — repo tooling, not a
|
|
49
|
+
portable skill. Its filesystem probes are now async (`validateAgentDir` returns a promise),
|
|
50
|
+
matching the no-`*Sync` rule for `scripts/`.
|
|
51
|
+
|
|
52
|
+
`agents/README.md` (Validation) and `test/validate-agent-dir.test.mjs` point at the new path. No
|
|
53
|
+
skill, agent, hook, or installer behaviour changes.
|
|
54
|
+
|
|
3
55
|
## 2.5.0
|
|
4
56
|
|
|
5
57
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -292,7 +292,8 @@ symlinks), `SessionStart` + `Notification` hooks, and 2 MCP servers — writes a
|
|
|
292
292
|
manifest at `~/.agent-cortex/.claude-plugin/marketplace.json` exposing `./claude`, and
|
|
293
293
|
registers it with Claude Code, and merges the committed `claude/settings.json` template into
|
|
294
294
|
`~/.claude/settings.json`: the template supplies defaults, personal values win, and the
|
|
295
|
-
installer owns exactly `enabledPlugins` + `extraKnownMarketplaces`
|
|
295
|
+
installer owns exactly the `enabledPlugins` + `extraKnownMarketplaces` entries the template
|
|
296
|
+
declares — entries it does not declare are left alone — while every other key
|
|
296
297
|
(`permissions`, `hooks`, `env`, `statusLine`, unknown keys) is preserved. It also writes the
|
|
297
298
|
managed section of `~/.claude/CLAUDE.md` — the reply-shape block the pi `output-style`
|
|
298
299
|
extension appends, taken from the same source file — and leaves every other line of that
|
|
@@ -360,10 +361,15 @@ agent-cortex install claude --require-register # register or fail the inst
|
|
|
360
361
|
|
|
361
362
|
The install also merges the committed `claude/settings.json` template into
|
|
362
363
|
`~/.claude/settings.json` (the settings file Claude Code reads at user scope). The template
|
|
363
|
-
supplies defaults and personal values win; the installer owns exactly `enabledPlugins` +
|
|
364
|
-
`extraKnownMarketplaces
|
|
365
|
-
|
|
366
|
-
|
|
364
|
+
supplies defaults and personal values win; the installer owns exactly the `enabledPlugins` +
|
|
365
|
+
`extraKnownMarketplaces` entries the template declares, so entries it does not declare (including
|
|
366
|
+
the one Claude Code writes when you enable the plugin) survive, and `permissions`, `hooks`, `env`,
|
|
367
|
+
`statusLine` and unknown keys are never touched. A legacy symlinked `settings.json` is replaced
|
|
368
|
+
with a real file rather than written through, and `--output` leaves the user's Claude config
|
|
369
|
+
alone. When registration finds the plugin installed but the settings do not enable it, the install
|
|
370
|
+
warns and names `claude plugin enable <plugin>@<marketplace>` — a disabled plugin loads nothing.
|
|
371
|
+
That check reads the user-scope file only, so a plugin enabled at project or local scope still
|
|
372
|
+
warns; `claude plugin list` reports the effective state.
|
|
367
373
|
|
|
368
374
|
It also writes the managed section of `~/.claude/CLAUDE.md` (Claude's user memory), delimited
|
|
369
375
|
by `<!-- BEGIN agent-cortex:output-style -->` / `<!-- END agent-cortex:output-style -->`. The
|
|
@@ -472,6 +478,30 @@ re-registers it; `--output <dir>` generates a preview only). The only committed
|
|
|
472
478
|
so never edit anything under it. CI validates the Claude materialiser (structural checks on
|
|
473
479
|
a temp-dir install) so sources and installer can never silently diverge.
|
|
474
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
|
+
|
|
475
505
|
## Migrating a beads workspace
|
|
476
506
|
|
|
477
507
|
`agent-cortex migrate` brings the `.beads` workspace it runs in — any repo, not just this
|
package/agents/README.md
CHANGED
|
@@ -245,7 +245,9 @@ agents, token-substituted skills, extensions) at `~/.agent-cortex/pi`.
|
|
|
245
245
|
|
|
246
246
|
## Validation
|
|
247
247
|
|
|
248
|
-
|
|
248
|
+
The composable layout is validated by the repo tooling at `scripts/validate-agent-dir.mjs` — a
|
|
249
|
+
module, not a shipped skill, with no CLI. Import `validateAgentDir` to check a directory, or run
|
|
250
|
+
its test suite:
|
|
249
251
|
|
|
250
252
|
```bash
|
|
251
253
|
node --test test/validate-agent-dir.test.mjs
|
package/bin/agent-cortex.mjs
CHANGED
|
@@ -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
|
-
|
|
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);
|
|
@@ -120,8 +139,8 @@ if (parsed.command === "install") {
|
|
|
120
139
|
// materialises the plugin into the home install root (~/.agent-cortex/claude)
|
|
121
140
|
// with copied, token-substituted skills, writes the marketplace manifest at
|
|
122
141
|
// ~/.agent-cortex/.claude-plugin/marketplace.json, merges the committed
|
|
123
|
-
// claude/settings.json template into ~/.claude/settings.json (
|
|
124
|
-
// enabledPlugins + extraKnownMarketplaces), and registers it with Claude Code
|
|
142
|
+
// claude/settings.json template into ~/.claude/settings.json (asserting only
|
|
143
|
+
// the enabledPlugins + extraKnownMarketplaces entries the template declares), and registers it with Claude Code
|
|
125
144
|
// (marketplace add → install → update); `--output <dir>` is the generate-only
|
|
126
145
|
// form (tests/CI validation), and the generated claude/ plugin subtree is
|
|
127
146
|
// never committed — only the settings template is.
|
|
@@ -147,6 +166,9 @@ if (parsed.command === "install") {
|
|
|
147
166
|
root: /** @type {string} */ (result.marketplaceRoot),
|
|
148
167
|
manifest: /** @type {object} */ (result.marketplaceManifest),
|
|
149
168
|
pluginVersion: result.pluginVersion,
|
|
169
|
+
// The enablement check reads the same file this install just merged:
|
|
170
|
+
// a plugin Claude Code does not enable loads nothing.
|
|
171
|
+
...(result.settings ? { settingsPath: result.settings.path } : {}),
|
|
150
172
|
dryRun: parsed.dryRun ?? false,
|
|
151
173
|
requireRegister: parsed.requireRegister ?? false,
|
|
152
174
|
});
|
|
@@ -85,13 +85,16 @@ const PACKAGE_ROOT = join(MODULE_DIR, "..", "..");
|
|
|
85
85
|
*/
|
|
86
86
|
|
|
87
87
|
/**
|
|
88
|
-
*
|
|
88
|
+
* Register plan/outcome returned by registerClaude.
|
|
89
89
|
* @typedef {object} RegisterClaudeResult
|
|
90
90
|
* @property {string} marketplace
|
|
91
91
|
* @property {string} plugin
|
|
92
92
|
* @property {string[][]} commands
|
|
93
93
|
* @property {boolean} dryRun
|
|
94
94
|
* @property {boolean} registered
|
|
95
|
+
* @property {boolean | null} enabled Whether the user's settings enable the plugin;
|
|
96
|
+
* null when the settings could not be read or
|
|
97
|
+
* its state is unconfirmed.
|
|
95
98
|
* @property {string[]} skipped
|
|
96
99
|
*/
|
|
97
100
|
|
|
@@ -400,9 +403,13 @@ async function buildHandAuthored(root) {
|
|
|
400
403
|
* - template keys fill in defaults the live file lacks; every value already
|
|
401
404
|
* present in the live file wins (deeply), and unknown keys are preserved,
|
|
402
405
|
* so personal config survives re-install;
|
|
403
|
-
* - `enabledPlugins` and `extraKnownMarketplaces` are CLI-owned
|
|
404
|
-
*
|
|
405
|
-
*
|
|
406
|
+
* - `enabledPlugins` and `extraKnownMarketplaces` are CLI-owned in the other
|
|
407
|
+
* direction: the entries the template declares are asserted (the template
|
|
408
|
+
* value wins for those), and entries it does not declare are kept. A
|
|
409
|
+
* template key that is empty or absent declares nothing and therefore owns
|
|
410
|
+
* nothing — silence never deletes, so a re-install cannot erase the entry
|
|
411
|
+
* Claude Code writes when the user enables the plugin. `permissions`,
|
|
412
|
+
* `hooks`, `env`, `statusLine` and every other key are never touched.
|
|
406
413
|
* @param {{ root: string, target: string, dryRun: boolean, warn: Warn }} options
|
|
407
414
|
* @returns {Promise<ManagedFileResult | null>}
|
|
408
415
|
*/
|
|
@@ -410,16 +417,32 @@ async function installSettings({ root, target, dryRun, warn }) {
|
|
|
410
417
|
const template = await readTemplateJson(join(root, SETTINGS_TEMPLATE), warn);
|
|
411
418
|
if (template === null) return null;
|
|
412
419
|
|
|
413
|
-
const
|
|
420
|
+
const existing = await readJsonObject(target, warn);
|
|
421
|
+
const merged = mergeSettings(template, existing);
|
|
414
422
|
for (const key of SETTINGS_OWNED_KEYS) {
|
|
415
|
-
|
|
416
|
-
|
|
423
|
+
const value = mergeOwnedSetting(template[key], existing[key]);
|
|
424
|
+
if (value === undefined) delete merged[key];
|
|
425
|
+
else merged[key] = value;
|
|
417
426
|
}
|
|
418
427
|
|
|
419
428
|
const content = `${JSON.stringify(merged, null, 2)}\n`;
|
|
420
429
|
return writeManagedFile({ target, content, dryRun });
|
|
421
430
|
}
|
|
422
431
|
|
|
432
|
+
/**
|
|
433
|
+
* Merge one CLI-owned settings key. The template asserts the entries it
|
|
434
|
+
* declares — its value wins for exactly those — and every entry it does not
|
|
435
|
+
* declare survives from the live file. An empty or absent template key declares
|
|
436
|
+
* nothing, so the live value passes through untouched: silence never deletes.
|
|
437
|
+
* @param {unknown} template
|
|
438
|
+
* @param {unknown} existing
|
|
439
|
+
* @returns {unknown}
|
|
440
|
+
*/
|
|
441
|
+
function mergeOwnedSetting(template, existing) {
|
|
442
|
+
if (!isPlainObject(template) || !isPlainObject(existing)) return existing === undefined ? template : existing;
|
|
443
|
+
return { ...existing, ...template };
|
|
444
|
+
}
|
|
445
|
+
|
|
423
446
|
/**
|
|
424
447
|
* Merge the committed template over the live file. Template keys fill in
|
|
425
448
|
* defaults, live values win (deeply), and keys the template does not know about
|
|
@@ -1045,6 +1068,11 @@ async function pluginLoadVerdict(claudeBin, pluginId) {
|
|
|
1045
1068
|
* When that list cannot be read, or does not list the plugin, the load state is
|
|
1046
1069
|
* unconfirmed: the install warns and skips the success line.
|
|
1047
1070
|
*
|
|
1071
|
+
* A loaded plugin that the user's settings do not enable loads nothing either:
|
|
1072
|
+
* Claude Code reads `enabledPlugins` from that file, and registration skips the
|
|
1073
|
+
* install as "already installed", so the run warns with the command that fixes
|
|
1074
|
+
* it rather than reporting a working install.
|
|
1075
|
+
*
|
|
1048
1076
|
* @param {object} options
|
|
1049
1077
|
* @param {string} [options.root] Marketplace root carrying .claude-plugin/…
|
|
1050
1078
|
* (defaults to the repo containing this module)
|
|
@@ -1052,6 +1080,8 @@ async function pluginLoadVerdict(claudeBin, pluginId) {
|
|
|
1052
1080
|
* @param {boolean} [options.dryRun] Plan and report without running or writing
|
|
1053
1081
|
* @param {object} [options.manifest] Pre-parsed marketplace manifest (skips the file read)
|
|
1054
1082
|
* @param {string} [options.pluginVersion] Materialised plugin version (default: read from plugin.json)
|
|
1083
|
+
* @param {string} [options.settingsPath] Claude user settings the enablement check reads
|
|
1084
|
+
* (default ~/.claude/settings.json)
|
|
1055
1085
|
* @param {boolean} [options.requireRegister] Exit non-zero when registration cannot run
|
|
1056
1086
|
* @param {(msg: string) => void} [options.warn] Warning sink (default: console.warn)
|
|
1057
1087
|
* @returns {Promise<RegisterClaudeResult>}
|
|
@@ -1062,6 +1092,7 @@ export async function registerClaude({
|
|
|
1062
1092
|
dryRun = false,
|
|
1063
1093
|
manifest,
|
|
1064
1094
|
pluginVersion,
|
|
1095
|
+
settingsPath = DEFAULT_SETTINGS_PATH,
|
|
1065
1096
|
requireRegister = false,
|
|
1066
1097
|
warn = DEFAULT_WARN,
|
|
1067
1098
|
} = {}) {
|
|
@@ -1072,7 +1103,7 @@ export async function registerClaude({
|
|
|
1072
1103
|
const version = pluginVersion ?? (await readMaterialisedPluginVersion(root, manifestObj));
|
|
1073
1104
|
const home = resolve(root);
|
|
1074
1105
|
|
|
1075
|
-
const summary = /** @type {RegisterClaudeResult} */ ({ marketplace, plugin, commands: [], dryRun, registered: true, skipped: [] });
|
|
1106
|
+
const summary = /** @type {RegisterClaudeResult} */ ({ marketplace, plugin, commands: [], dryRun, registered: true, enabled: null, skipped: [] });
|
|
1076
1107
|
console.log(`Registering plugin "${pluginId}" with Claude Code (via ${claudeBin})…`);
|
|
1077
1108
|
|
|
1078
1109
|
const manualCommands = [
|
|
@@ -1156,9 +1187,22 @@ export async function registerClaude({
|
|
|
1156
1187
|
`verify with "${claudeBin} plugin list" (restart Claude Code to apply)`,
|
|
1157
1188
|
);
|
|
1158
1189
|
} else {
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1190
|
+
// Installed but disabled is a plugin that loads nothing: Claude Code gates
|
|
1191
|
+
// every skill and agent on the user's enabledPlugins entry, and the install
|
|
1192
|
+
// path above skips a same-version re-install as "already installed", so
|
|
1193
|
+
// this is the only place that state is reported.
|
|
1194
|
+
const enabled = (await readJsonObject(settingsPath, warn)).enabledPlugins?.[pluginId] === true;
|
|
1195
|
+
summary.enabled = enabled;
|
|
1196
|
+
if (enabled) {
|
|
1197
|
+
console.log(
|
|
1198
|
+
`Installed and enabled — verify with "claude plugin list" / "claude plugin details ${plugin}" (restart Claude Code to apply).`,
|
|
1199
|
+
);
|
|
1200
|
+
} else {
|
|
1201
|
+
warn(
|
|
1202
|
+
`"${pluginId}" is installed but not enabled in ${settingsPath} — Claude Code loads nothing from a disabled plugin; ` +
|
|
1203
|
+
`enable it with "${claudeBin} plugin enable ${pluginId}" and restart Claude Code`,
|
|
1204
|
+
);
|
|
1205
|
+
}
|
|
1162
1206
|
}
|
|
1163
1207
|
return summary;
|
|
1164
1208
|
}
|
|
@@ -64,6 +64,8 @@ function createFakePi(flagValues: Record<string, boolean | string> = {}): FakePi
|
|
|
64
64
|
const list = handlers.get(event) ?? [];
|
|
65
65
|
list.push(handler);
|
|
66
66
|
handlers.set(event, list);
|
|
67
|
+
// `ExtensionAPI.on` returns an unsubscribe function.
|
|
68
|
+
return () => {};
|
|
67
69
|
},
|
|
68
70
|
registerFlag: (name, options) => {
|
|
69
71
|
flags.push([name, options]);
|
|
@@ -77,8 +79,16 @@ function createFakePi(flagValues: Record<string, boolean | string> = {}): FakePi
|
|
|
77
79
|
registerShortcut: () => {},
|
|
78
80
|
registerTool: () => {},
|
|
79
81
|
registerMessageRenderer: () => {},
|
|
82
|
+
registerMarkdownTransformer: () => {},
|
|
83
|
+
registerEntryRenderer: () => {},
|
|
84
|
+
registerToolRenderer: () => {},
|
|
80
85
|
registerProvider: () => {},
|
|
81
86
|
unregisterProvider: () => {},
|
|
87
|
+
registerMcpServer: () => {},
|
|
88
|
+
unregisterMcpServer: () => {},
|
|
89
|
+
unregisterVirtualModel: () => {},
|
|
90
|
+
registerVirtualModel: () => {},
|
|
91
|
+
getMcpServers: () => [],
|
|
82
92
|
sendMessage: () => {},
|
|
83
93
|
sendUserMessage: () => {},
|
|
84
94
|
appendEntry: () => {},
|
|
@@ -89,6 +99,9 @@ function createFakePi(flagValues: Record<string, boolean | string> = {}): FakePi
|
|
|
89
99
|
throw new Error("not implemented");
|
|
90
100
|
},
|
|
91
101
|
getAllTools: () => [],
|
|
102
|
+
getSettings: () => {
|
|
103
|
+
throw new Error("not implemented");
|
|
104
|
+
},
|
|
92
105
|
getCommands: () => [],
|
|
93
106
|
setModel: async () => false,
|
|
94
107
|
getThinkingLevel: () => "off",
|
|
@@ -16,12 +16,13 @@
|
|
|
16
16
|
* The model call itself is gone. It went through pi-ai's `complete()`, a value
|
|
17
17
|
* import of pi's runtime, and that import stopped the extension loading outside
|
|
18
18
|
* the agent-cortex checkout (see test/pi-extensions-load.test.mjs). The
|
|
19
|
-
* ExtensionAPI promises no completion call.
|
|
20
|
-
* `ModelRegistry.complete
|
|
21
|
-
* would
|
|
22
|
-
* Extensions also make no internal LLM calls (AGENTS.md), so this
|
|
23
|
-
* off. Both callers fall back to the truncation they already used
|
|
24
|
-
* failed. The `model` key is no longer read and is ignored if
|
|
19
|
+
* ExtensionAPI promises no completion call. The installed host (pi 1.5.2) does
|
|
20
|
+
* expose `ModelRegistry.complete` through `ExtensionContext.modelRegistry`, but
|
|
21
|
+
* calling it would make the extensions depend on a method that exists on later
|
|
22
|
+
* hosts only. Extensions also make no internal LLM calls (AGENTS.md), so this
|
|
23
|
+
* feature stays off. Both callers fall back to the truncation they already used
|
|
24
|
+
* when the model failed. The `model` key is no longer read and is ignored if
|
|
25
|
+
* present.
|
|
25
26
|
*/
|
|
26
27
|
|
|
27
28
|
import { homedir } from "node:os";
|
|
@@ -8,9 +8,9 @@
|
|
|
8
8
|
"typecheck": "tsc --noEmit"
|
|
9
9
|
},
|
|
10
10
|
"devDependencies": {
|
|
11
|
-
"@earendil-works/pi-agent-core": "^
|
|
12
|
-
"@earendil-works/pi-ai": "^
|
|
13
|
-
"@earendil-works/pi-coding-agent": "^
|
|
11
|
+
"@earendil-works/pi-agent-core": "^1.5.2",
|
|
12
|
+
"@earendil-works/pi-ai": "^1.5.2",
|
|
13
|
+
"@earendil-works/pi-coding-agent": "^1.5.2",
|
|
14
14
|
"@types/node": "^22.0.0",
|
|
15
15
|
"tsx": "^4.0.0",
|
|
16
16
|
"typebox": "^1.1.38",
|
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
|
@@ -2,12 +2,15 @@
|
|
|
2
2
|
// Validator for the composable agent directory layout: `agent.md` plus at
|
|
3
3
|
// least one harness subdirectory with a valid `frontmatter.json`.
|
|
4
4
|
//
|
|
5
|
-
//
|
|
6
|
-
// `
|
|
5
|
+
// Repo tooling, not a shipped skill: the composable layout exists only in this
|
|
6
|
+
// repository's `agents/` tree, and installs carry flat rendered agents. Run the
|
|
7
|
+
// test suite at `test/validate-agent-dir.test.mjs`.
|
|
7
8
|
|
|
8
|
-
import {
|
|
9
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
9
10
|
import { join } from "node:path";
|
|
10
11
|
|
|
12
|
+
import { isDirectory, isFile } from "./lib/fs.mjs";
|
|
13
|
+
|
|
11
14
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
12
15
|
|
|
13
16
|
/**
|
|
@@ -96,28 +99,31 @@ export function validateFrontmatter(fm) {
|
|
|
96
99
|
|
|
97
100
|
/**
|
|
98
101
|
* @param {string} agentDirPath
|
|
99
|
-
* @returns {ValidationResult}
|
|
102
|
+
* @returns {Promise<ValidationResult>}
|
|
100
103
|
*/
|
|
101
|
-
export function validateAgentDir(agentDirPath) {
|
|
104
|
+
export async function validateAgentDir(agentDirPath) {
|
|
102
105
|
/** @type {string[]} */
|
|
103
106
|
const errors = [];
|
|
104
107
|
|
|
105
108
|
// Check agent.md exists
|
|
106
109
|
const agentMdPath = join(agentDirPath, "agent.md");
|
|
107
|
-
if (!
|
|
110
|
+
if (!(await isFile(agentMdPath))) {
|
|
108
111
|
errors.push("missing agent.md");
|
|
109
112
|
}
|
|
110
113
|
|
|
111
114
|
// Check for at least one harness subdirectory with frontmatter.json
|
|
112
|
-
if (!
|
|
115
|
+
if (!(await isDirectory(agentDirPath))) {
|
|
113
116
|
return { ok: false, errors: ["agent directory does not exist"] };
|
|
114
117
|
}
|
|
115
118
|
|
|
116
|
-
const entries =
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
119
|
+
const entries = await readdir(agentDirPath);
|
|
120
|
+
/** @type {string[]} */
|
|
121
|
+
const harnessDirs = [];
|
|
122
|
+
for (const entry of entries) {
|
|
123
|
+
if (entry !== "node_modules" && (await isDirectory(join(agentDirPath, entry)))) {
|
|
124
|
+
harnessDirs.push(entry);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
121
127
|
|
|
122
128
|
if (harnessDirs.length === 0) {
|
|
123
129
|
errors.push("no harness subdirectories found (need at least one with frontmatter.json)");
|
|
@@ -126,12 +132,12 @@ export function validateAgentDir(agentDirPath) {
|
|
|
126
132
|
// Validate each harness directory
|
|
127
133
|
for (const harnessDir of harnessDirs) {
|
|
128
134
|
const frontmatterPath = join(agentDirPath, harnessDir, "frontmatter.json");
|
|
129
|
-
if (!
|
|
135
|
+
if (!(await isFile(frontmatterPath))) {
|
|
130
136
|
continue; // Skip directories without frontmatter.json
|
|
131
137
|
}
|
|
132
138
|
|
|
133
139
|
try {
|
|
134
|
-
const content =
|
|
140
|
+
const content = await readFile(frontmatterPath, "utf-8");
|
|
135
141
|
const fm = JSON.parse(content);
|
|
136
142
|
const result = validateFrontmatter(fm);
|
|
137
143
|
if (!result.ok) {
|
|
@@ -88,4 +88,15 @@ The deliverable this feature produces and the problem it solves.
|
|
|
88
88
|
| review | Review | verify |
|
|
89
89
|
| document | Document | review |
|
|
90
90
|
|
|
91
|
-
`maxFixRounds: 4` — the maximum fix-retry rounds allowed for a stage's verify loop before the PR gate. Adjust stages only in `pipeline.json`; the script and templates follow it.
|
|
91
|
+
`maxFixRounds: 4` — the maximum fix-retry rounds allowed for a stage's verify loop before the PR gate. Adjust stages only in `pipeline.json`; the script and templates follow it.
|
|
92
|
+
|
|
93
|
+
## Stage templates
|
|
94
|
+
|
|
95
|
+
`templates/` holds one prompt skeleton per stage; a dispatcher fills one to compose that stage's
|
|
96
|
+
chore prompt. The REPORT contract is not defined here —
|
|
97
|
+
`skills/workflow/run-pipeline-stage/prompts/stage-runner.md` owns it and every template mirrors it.
|
|
98
|
+
Drift between the two fails silently: a worker that follows a drifted template emits a well-formed
|
|
99
|
+
block with keys no one reads, so routing stops at that stage. Drift has already broken routing
|
|
100
|
+
twice. Compare a template with the authority whenever either changes. The intended guard is a lint
|
|
101
|
+
parity check beside `scripts/check-skill-frontmatter.mjs`, not a test — a test here would assert on
|
|
102
|
+
file text, which is a content inventory, and `style-tests` bans that shape.
|
|
@@ -31,10 +31,21 @@ Implement the task described above using a test-driven approach:
|
|
|
31
31
|
|
|
32
32
|
Make only the changes required to complete this task — do not refactor unrelated code.
|
|
33
33
|
|
|
34
|
-
|
|
34
|
+
Commit and push the stage's work before reporting (see _Deliver before reporting_ in
|
|
35
|
+
`prompts/stage-runner.md`). A dirty worktree counts as a failed stage.
|
|
36
|
+
|
|
37
|
+
End your response with a ---REPORT--- block. The `run-pipeline-stage` skill's
|
|
38
|
+
`prompts/stage-runner.md` holds the authoritative REPORT contract — if this block and that
|
|
39
|
+
prompt differ, follow the prompt and correct this template.
|
|
35
40
|
---REPORT---
|
|
36
41
|
BEAD_ID: <id>
|
|
37
|
-
STAGE_COMPLETED:
|
|
42
|
+
STAGE_COMPLETED: code
|
|
38
43
|
SUMMARY: <2–3 sentence summary of what was done>
|
|
39
44
|
FILES_CHANGED: <comma-separated list, or "none">
|
|
45
|
+
COMMIT: <sha of the stage commit, or "none">
|
|
46
|
+
PUSHED: <yes|no>
|
|
47
|
+
PR: none
|
|
48
|
+
OUTCOME: <SUCCESS|BLOCKED>
|
|
49
|
+
BLOCKING_ISSUES: ← include only if OUTCOME is BLOCKED; one line per issue
|
|
50
|
+
- <what blocks the stage and what would unblock it>
|
|
40
51
|
---
|
|
@@ -31,10 +31,21 @@ Review the changes made and compare them against the existing documentation in t
|
|
|
31
31
|
when no decisions or macro behaviour changed.
|
|
32
32
|
6. Do not modify source code — documentation only.
|
|
33
33
|
|
|
34
|
-
|
|
34
|
+
Commit and push the docs, and open or update the feature PR before reporting (see _Deliver
|
|
35
|
+
before reporting_ in `prompts/stage-runner.md`). A dirty worktree counts as a failed stage.
|
|
36
|
+
|
|
37
|
+
End your response with a ---REPORT--- block. The `run-pipeline-stage` skill's
|
|
38
|
+
`prompts/stage-runner.md` holds the authoritative REPORT contract — if this block and that
|
|
39
|
+
prompt differ, follow the prompt and correct this template.
|
|
35
40
|
---REPORT---
|
|
36
41
|
BEAD_ID: <id>
|
|
37
|
-
STAGE_COMPLETED:
|
|
42
|
+
STAGE_COMPLETED: document
|
|
38
43
|
SUMMARY: <2–3 sentence summary of what was documented, or "No documentation changes required">
|
|
39
44
|
FILES_CHANGED: <comma-separated list of docs touched, or "none">
|
|
45
|
+
COMMIT: <sha of the stage commit, or "none">
|
|
46
|
+
PUSHED: <yes|no>
|
|
47
|
+
PR: <feature PR url>
|
|
48
|
+
OUTCOME: <SUCCESS|BLOCKED>
|
|
49
|
+
BLOCKING_ISSUES: ← include only if OUTCOME is BLOCKED; one line per issue
|
|
50
|
+
- <what blocks the stage and what would unblock it>
|
|
40
51
|
---
|
|
@@ -18,10 +18,21 @@ Apply only the changes described in the Task section above. Do not make any othe
|
|
|
18
18
|
|
|
19
19
|
**Use context7** when working with any third-party library — fetch live docs to ensure you use the correct, up-to-date API for the version in this project.
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
Commit and push the fix before reporting (see _Deliver before reporting_ in
|
|
22
|
+
`prompts/stage-runner.md`). A dirty worktree counts as a failed stage.
|
|
23
|
+
|
|
24
|
+
End your response with a ---REPORT--- block. The `run-pipeline-stage` skill's
|
|
25
|
+
`prompts/stage-runner.md` holds the authoritative REPORT contract — if this block and that
|
|
26
|
+
prompt differ, follow the prompt and correct this template.
|
|
22
27
|
---REPORT---
|
|
23
28
|
BEAD_ID: <id>
|
|
24
|
-
STAGE_COMPLETED:
|
|
29
|
+
STAGE_COMPLETED: fix
|
|
25
30
|
SUMMARY: <2–3 sentence summary of what was changed>
|
|
26
31
|
FILES_CHANGED: <comma-separated list, or "none">
|
|
32
|
+
COMMIT: <sha of the stage commit, or "none">
|
|
33
|
+
PUSHED: <yes|no>
|
|
34
|
+
PR: none
|
|
35
|
+
OUTCOME: <SUCCESS|BLOCKED>
|
|
36
|
+
BLOCKING_ISSUES: ← include only if OUTCOME is BLOCKED; one line per issue
|
|
37
|
+
- <what remains unresolved and what would unblock it>
|
|
27
38
|
---
|
|
@@ -21,23 +21,29 @@ Discover and run the project's test and lint commands, then report the outcome.
|
|
|
21
21
|
1. `package.json` `scripts` — look for keys named `test`, `lint`, `check`, `typecheck`, or similar.
|
|
22
22
|
2. `Makefile` — look for targets named `test`, `lint`, `check`.
|
|
23
23
|
3. Other well-known config files (e.g. `pyproject.toml`, `Cargo.toml`) — look for test/lint targets.
|
|
24
|
-
4. If no commands are found, report
|
|
24
|
+
4. If no commands are found, report `OUTCOME: SUCCESS` with summary "No test or lint commands found".
|
|
25
25
|
|
|
26
26
|
**Step 2 — Run each discovered command** in the project root. Capture stdout and stderr.
|
|
27
27
|
|
|
28
28
|
**Step 3 — Evaluate**:
|
|
29
|
-
- If all commands exit 0: `
|
|
30
|
-
- If any command exits non-zero: `
|
|
29
|
+
- If all commands exit 0: `OUTCOME: SUCCESS`.
|
|
30
|
+
- If any command exits non-zero: `OUTCOME: BLOCKED`. List each failure concisely under
|
|
31
|
+
`BLOCKING_ISSUES`.
|
|
31
32
|
|
|
32
33
|
Do not fix failures — report them only.
|
|
33
34
|
|
|
34
|
-
End your response with a ---REPORT--- block
|
|
35
|
+
End your response with a ---REPORT--- block. The `run-pipeline-stage` skill's
|
|
36
|
+
`prompts/stage-runner.md` holds the authoritative REPORT contract — if this block and that
|
|
37
|
+
prompt differ, follow the prompt and correct this template.
|
|
35
38
|
---REPORT---
|
|
36
39
|
BEAD_ID: <id>
|
|
37
|
-
STAGE_COMPLETED:
|
|
40
|
+
STAGE_COMPLETED: verify
|
|
38
41
|
SUMMARY: <what commands were run and overall outcome>
|
|
39
42
|
FILES_CHANGED: none
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
+
COMMIT: <sha, or "none" if this stage added no commit>
|
|
44
|
+
PUSHED: <yes|no>
|
|
45
|
+
PR: none
|
|
46
|
+
OUTCOME: <SUCCESS|BLOCKED>
|
|
47
|
+
BLOCKING_ISSUES: ← include only if OUTCOME is BLOCKED; one line per failure
|
|
48
|
+
- <command, its exit status, and the failure summary>
|
|
43
49
|
---
|
|
@@ -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
|
+
}
|
|
@@ -123,6 +123,9 @@ BLOCKING_ISSUES: ← only if OUTCOME is BLOCKED
|
|
|
123
123
|
- <specific blocking issue>
|
|
124
124
|
```
|
|
125
125
|
|
|
126
|
+
Stage-chore templates under `create-task/templates/` mirror this contract. A template that drifts
|
|
127
|
+
still emits a well-formed block with unread keys, so routing fails silently.
|
|
128
|
+
|
|
126
129
|
Subagents report facts — do not ask them to suggest or predict the next step. A REPORT claiming
|
|
127
130
|
`OUTCOME: SUCCESS` with `PUSHED: no`, or a `COMMIT` that is not on the branch, records a failed
|
|
128
131
|
stage: the commit is the evidence, the report is the claim.
|