@jaybeeuu/agent-cortex 2.5.1 → 2.7.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 +45 -0
- package/README.md +68 -6
- package/agents/ralph/agent.md +4 -2
- package/bin/agent-cortex.mjs +176 -67
- package/bin/installers/claude.mjs +33 -8
- package/bin/installers/pi.mjs +11 -1
- package/docs/skills/skill-anatomy.md +212 -0
- package/lib/cli.mjs +70 -1
- package/lib/harness-update.mjs +254 -0
- package/lib/harness-versions.mjs +190 -0
- package/package.json +2 -1
- package/scripts/lib/docs.mjs +67 -0
- package/skills/planning/create-task/REFERENCE.md +11 -3
- package/skills/planning/create-task/SKILL.md +1 -1
- package/skills/planning/create-task/pipeline.json +8 -0
- package/skills/planning/create-task/scripts/create-chores.mjs +25 -7
- package/skills/planning/create-task/templates/housekeeping.md +65 -0
- package/skills/productivity/write-a-skill/SKILL.md +2 -2
- package/skills/review/refactor-skill/SKILL.md +2 -2
- package/skills/workflow/ralph/REFERENCE.md +38 -7
- package/skills/workflow/ralph/SKILL.md +5 -4
- package/skills/workflow/ralph/scripts/prune-worktrees.mjs +346 -0
- package/skills/workflow/run-pipeline-stage/SKILL.md +8 -5
- package/skills/workflow/run-pipeline-stage/playbooks/housekeeping.md +58 -0
- package/skills/workflow/run-pipeline-stage/prompts/stage-runner.md +13 -1
- package/token-map.README.md +14 -4
- package/token-map.json +6 -1
|
@@ -0,0 +1,190 @@
|
|
|
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`. `missing` means the
|
|
33
|
+
* harness has no artifact root at all: it was never installed.
|
|
34
|
+
* @typedef {"current" | "stale" | "ahead" | "unknown" | "missing"} Staleness
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* One installed harness in the report.
|
|
39
|
+
* @typedef {object} HarnessVersion
|
|
40
|
+
* @property {string} harness
|
|
41
|
+
* @property {string} installed The installed version, or "unknown"
|
|
42
|
+
* @property {string} cli The running CLI version
|
|
43
|
+
* @property {Staleness} staleness
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Everything the `version` command reports.
|
|
48
|
+
* @typedef {object} VersionReport
|
|
49
|
+
* @property {string} cli The running CLI version
|
|
50
|
+
* @property {HarnessVersion[]} harnesses One entry per installed harness
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
const UNKNOWN = "unknown";
|
|
54
|
+
|
|
55
|
+
// Only plain releases are compared. A prerelease or a range is not "current"
|
|
56
|
+
// just because its numeric core matches, so it reports unknown instead.
|
|
57
|
+
const PLAIN_RELEASE = /^(\d+)\.(\d+)\.(\d+)$/;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Compare two plain semver releases.
|
|
61
|
+
* @param {string} a
|
|
62
|
+
* @param {string} b
|
|
63
|
+
* @returns {-1 | 0 | 1 | null} The order, or null when either side is not a plain release
|
|
64
|
+
*/
|
|
65
|
+
function compareReleases(a, b) {
|
|
66
|
+
const left = PLAIN_RELEASE.exec(a);
|
|
67
|
+
const right = PLAIN_RELEASE.exec(b);
|
|
68
|
+
if (left === null || right === null) return null;
|
|
69
|
+
for (let segment = 1; segment <= 3; segment++) {
|
|
70
|
+
const l = Number(left[segment]);
|
|
71
|
+
const r = Number(right[segment]);
|
|
72
|
+
if (l !== r) return l < r ? -1 : 1;
|
|
73
|
+
}
|
|
74
|
+
return 0;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* @param {string | null} installed null when the fingerprint is unreadable
|
|
79
|
+
* @param {string} cli
|
|
80
|
+
* @returns {Staleness}
|
|
81
|
+
*/
|
|
82
|
+
function stalenessOf(installed, cli) {
|
|
83
|
+
if (installed === null) return "unknown";
|
|
84
|
+
const order = compareReleases(installed, cli);
|
|
85
|
+
if (order === null) return "unknown";
|
|
86
|
+
if (order < 0) return "stale";
|
|
87
|
+
return order > 0 ? "ahead" : "current";
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Read the version one harness recorded at install time.
|
|
92
|
+
* @param {string} home
|
|
93
|
+
* @param {HarnessFingerprint} fingerprint
|
|
94
|
+
* @returns {Promise<{ installed: boolean, version: string | null }>} `installed`
|
|
95
|
+
* is false when the artifact root is absent; `version` is null when the
|
|
96
|
+
* fingerprint is missing, malformed, or carries no version string
|
|
97
|
+
*/
|
|
98
|
+
async function readInstalledVersion(home, fingerprint) {
|
|
99
|
+
const root = join(home, ...fingerprint.root);
|
|
100
|
+
if (!(await isDirectory(root))) return { installed: false, version: null };
|
|
101
|
+
try {
|
|
102
|
+
/** @type {{ version?: unknown }} */
|
|
103
|
+
const manifest = JSON.parse(await readFile(join(root, ...fingerprint.file), "utf-8"));
|
|
104
|
+
const version = manifest?.version;
|
|
105
|
+
return { installed: true, version: typeof version === "string" && version !== "" ? version : null };
|
|
106
|
+
} catch {
|
|
107
|
+
return { installed: true, version: null };
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* One harness in the update table: unlike a report entry, every supported
|
|
113
|
+
* harness appears, so an absent install shows as `installed: null` / `missing`.
|
|
114
|
+
* @typedef {object} HarnessStatus
|
|
115
|
+
* @property {string} harness
|
|
116
|
+
* @property {string | null} installed The installed version, or null when the harness is not installed
|
|
117
|
+
* @property {string} cli The running CLI version
|
|
118
|
+
* @property {Staleness} staleness
|
|
119
|
+
*/
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Report the running CLI version and the installed version of every harness
|
|
123
|
+
* whose artifact root exists.
|
|
124
|
+
* @param {{ cli: string, home?: string }} options `home` defaults to the real
|
|
125
|
+
* home directory; tests pass a fixture directory
|
|
126
|
+
* @returns {Promise<VersionReport>}
|
|
127
|
+
*/
|
|
128
|
+
export async function collectVersionReport({ cli, home = homedir() }) {
|
|
129
|
+
/** @type {HarnessVersion[]} */
|
|
130
|
+
const harnesses = [];
|
|
131
|
+
for (const fingerprint of HARNESS_FINGERPRINTS) {
|
|
132
|
+
const { installed, version } = await readInstalledVersion(home, fingerprint);
|
|
133
|
+
if (!installed) continue;
|
|
134
|
+
harnesses.push({
|
|
135
|
+
harness: fingerprint.harness,
|
|
136
|
+
installed: version ?? UNKNOWN,
|
|
137
|
+
cli,
|
|
138
|
+
staleness: stalenessOf(version, cli),
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
return { cli, harnesses };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Read the status of every harness, including the ones that are not installed:
|
|
146
|
+
* `ctx update` selects from this full list, so a harness with no artifact root
|
|
147
|
+
* is reported as missing rather than omitted.
|
|
148
|
+
* @param {{ cli: string, home?: string }} options `home` defaults to the real
|
|
149
|
+
* home directory; tests pass a fixture directory
|
|
150
|
+
* @returns {Promise<HarnessStatus[]>}
|
|
151
|
+
*/
|
|
152
|
+
export async function collectHarnessStatus({ cli, home = homedir() }) {
|
|
153
|
+
/** @type {HarnessStatus[]} */
|
|
154
|
+
const statuses = [];
|
|
155
|
+
for (const fingerprint of HARNESS_FINGERPRINTS) {
|
|
156
|
+
const { installed, version } = await readInstalledVersion(home, fingerprint);
|
|
157
|
+
statuses.push({
|
|
158
|
+
harness: fingerprint.harness,
|
|
159
|
+
installed: version,
|
|
160
|
+
cli,
|
|
161
|
+
staleness: installed ? stalenessOf(version, cli) : "missing",
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
return statuses;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Render the report as the default, human-readable output: the CLI version on
|
|
169
|
+
* the first line, then one line per installed harness. A harness whose version
|
|
170
|
+
* could not be read prints `unknown` twice — once for the version and once for
|
|
171
|
+
* the marker, because neither is known.
|
|
172
|
+
* @param {VersionReport} report
|
|
173
|
+
* @returns {string} Text without a trailing newline
|
|
174
|
+
*/
|
|
175
|
+
export function formatVersionReport(report) {
|
|
176
|
+
const lines = [report.cli];
|
|
177
|
+
for (const entry of report.harnesses) {
|
|
178
|
+
lines.push(`${entry.harness} ${entry.installed} ${entry.staleness}`);
|
|
179
|
+
}
|
|
180
|
+
return lines.join("\n");
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Render the report as JSON, one record per installed harness.
|
|
185
|
+
* @param {VersionReport} report
|
|
186
|
+
* @returns {string} JSON without a trailing newline
|
|
187
|
+
*/
|
|
188
|
+
export function serialiseVersionReport(report) {
|
|
189
|
+
return JSON.stringify(report, null, 2);
|
|
190
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jaybeeuu/agent-cortex",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.7.0",
|
|
4
4
|
"description": "Personal PI package with custom agents, skills, and extensions",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package"
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
"scripts/",
|
|
22
22
|
"skills/",
|
|
23
23
|
"agents/",
|
|
24
|
+
"docs/skills/skill-anatomy.md",
|
|
24
25
|
"extensions/*.ts",
|
|
25
26
|
"extensions/lib/",
|
|
26
27
|
"extensions/*/*.ts",
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Documentation a staged install ships.
|
|
2
|
+
//
|
|
3
|
+
// Skills are installed onto machines that have no copy of this checkout, so a
|
|
4
|
+
// skill that reads a repository document — write-a-skill and refactor-skill both
|
|
5
|
+
// read docs/skills/skill-anatomy.md — needs that document inside the install.
|
|
6
|
+
// The list is derived from token-map.json rather than declared again here: a
|
|
7
|
+
// `paths` entry whose relative path sits under `docs/` is a document skills
|
|
8
|
+
// reference by install-relative path, so the token map stays the single source
|
|
9
|
+
// of where the install puts it.
|
|
10
|
+
//
|
|
11
|
+
// The copies are verbatim, and no token substitution runs over them. A shipped doc
|
|
12
|
+
// therefore must not print a substitution token: one that needs to teach the
|
|
13
|
+
// syntax describes the wrapper in words (see the tool-reference section of the
|
|
14
|
+
// anatomy guide). test/installed-docs.test.mjs fails when a shipped doc carries
|
|
15
|
+
// one, because the materialised plugin must contain no literal token.
|
|
16
|
+
//
|
|
17
|
+
// Zero dependencies so it runs on the CI Node (20) and local Node alike.
|
|
18
|
+
|
|
19
|
+
import { copyFile, mkdir } from "node:fs/promises";
|
|
20
|
+
import { dirname, join } from "node:path";
|
|
21
|
+
import { isFile } from "./fs.mjs";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Package-relative paths of the docs an install ships, sorted.
|
|
25
|
+
* @param {import("./compose-agent.mjs").TokenMap} tokenMap
|
|
26
|
+
* @returns {string[]}
|
|
27
|
+
*/
|
|
28
|
+
export function shippedDocs(tokenMap) {
|
|
29
|
+
const docs = new Set();
|
|
30
|
+
for (const entry of Object.values(tokenMap.paths ?? {})) {
|
|
31
|
+
if (entry === null || typeof entry !== "object") continue;
|
|
32
|
+
for (const spec of Object.values(entry)) {
|
|
33
|
+
if (spec === null || typeof spec !== "object") continue;
|
|
34
|
+
const { relative } = /** @type {{ relative?: unknown }} */ (spec);
|
|
35
|
+
if (typeof relative === "string" && relative.startsWith("docs/")) docs.add(relative);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return [...docs].sort();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Copy every shipped doc from the package root into the staged tree, at the same
|
|
43
|
+
* relative path. A missing source is a hard error: the token map points a skill
|
|
44
|
+
* at a document the install would otherwise not contain.
|
|
45
|
+
* @param {{ root: string, output: string, dryRun?: boolean,
|
|
46
|
+
* tokenMap: import("./compose-agent.mjs").TokenMap }} options
|
|
47
|
+
* @returns {Promise<{ files: string[], dir: string }>}
|
|
48
|
+
*/
|
|
49
|
+
export async function installDocs({ root, output, dryRun = false, tokenMap }) {
|
|
50
|
+
/** @type {string[]} */
|
|
51
|
+
const files = [];
|
|
52
|
+
for (const relative of shippedDocs(tokenMap)) {
|
|
53
|
+
const from = join(root, relative);
|
|
54
|
+
if (!(await isFile(from))) {
|
|
55
|
+
throw new Error(
|
|
56
|
+
`token-map.json points a skill at ${relative}, but ${from} does not exist — ship the document or drop the path entry`,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
if (!dryRun) {
|
|
60
|
+
const to = join(output, relative);
|
|
61
|
+
await mkdir(dirname(to), { recursive: true });
|
|
62
|
+
await copyFile(from, to);
|
|
63
|
+
}
|
|
64
|
+
files.push(relative);
|
|
65
|
+
}
|
|
66
|
+
return { files, dir: join(output, "docs") };
|
|
67
|
+
}
|
|
@@ -16,12 +16,19 @@ node <skill-scripts>/create-chores.mjs --parent <bead-id> [--priority <0-4>]
|
|
|
16
16
|
|
|
17
17
|
## Stage chore beads
|
|
18
18
|
|
|
19
|
-
One per stage in `pipeline.json` (currently `code`, `verify`, `review`, `document`):
|
|
19
|
+
One per stage in `pipeline.json` (currently `code`, `verify`, `review`, `document`, `housekeeping`):
|
|
20
20
|
|
|
21
21
|
- **Type**: `chore`
|
|
22
22
|
- **Title**: `[<parent-id>] <stage title>` (e.g. `[abc-123] Code`)
|
|
23
|
-
- **Labels**: `stage:<id>` (e.g. `stage:code`, `stage:verify`)
|
|
24
|
-
- **Dependencies**: `parent-child` to the parent bead; `blocks` between stages per each stage's `dependsOn` (the script requires `dependsOn` targets to appear earlier in `pipeline.json`)
|
|
23
|
+
- **Labels**: `stage:<id>` (e.g. `stage:code`, `stage:verify`), plus any `labels` the stage declares — `housekeeping` adds `lifecycle:housekeeping`
|
|
24
|
+
- **Dependencies**: `parent-child` to the parent bead; `blocks` between stages per each stage's `dependsOn` (the script requires `dependsOn` targets to appear earlier in `pipeline.json`)
|
|
25
|
+
|
|
26
|
+
**A stage may wait on the gate instead of a stage.** The reserved `dependsOn` target
|
|
27
|
+
`featurePrReview` means "blocked behind the PR gate bead". The gate is not a stage — it is created
|
|
28
|
+
after them — so the script wires those stages to it in a second pass, once the gate exists. The
|
|
29
|
+
`housekeeping` stage uses it: its chore must not become ready until a human has merged the PR and
|
|
30
|
+
closed the gate, and the `blocks` edge keeps it out of `bd ready` while the feature is in flight.
|
|
31
|
+
The gate itself still waits on the last stage that does not wait on the gate (`document`).
|
|
25
32
|
|
|
26
33
|
## PR gate bead (`featurePrReview`)
|
|
27
34
|
|
|
@@ -87,6 +94,7 @@ The deliverable this feature produces and the problem it solves.
|
|
|
87
94
|
| verify | Verify | code |
|
|
88
95
|
| review | Review | verify |
|
|
89
96
|
| document | Document | review |
|
|
97
|
+
| housekeeping | Housekeeping | `featurePrReview` — the PR gate bead, not a stage |
|
|
90
98
|
|
|
91
99
|
`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
100
|
|
|
@@ -141,7 +141,7 @@ Steps 2–6 create beads; step 7 expands one. A bead that already exists but has
|
|
|
141
141
|
Input: title `Add export API to invoices`, description `…`, priority `2`, parent `feature-456`. Output: the bead ID plus the script's JSON mapping stage IDs → chore bead IDs, with `featurePrReview` as the HITL PR gate:
|
|
142
142
|
|
|
143
143
|
```json
|
|
144
|
-
{"code": "abc123", "verify": "def456", "review": "ghi789", "document": "jkl012", "featurePrReview": "bd-
|
|
144
|
+
{"code": "abc123", "verify": "def456", "review": "ghi789", "document": "jkl012", "housekeeping": "mno345", "featurePrReview": "bd-pqr678"}
|
|
145
145
|
```
|
|
146
146
|
|
|
147
147
|
Feature path input: "several endpoints, a UI, and a migration". Output: `feature-456` (`execution:hitl`), `plan-457` ("Plan: …", `execution:hitl`, owned by the feature, blocking every child), and one `execution:afk` impl child per deliverable, each expanded into its own stage chores plus PR gate.
|
|
@@ -28,6 +28,14 @@
|
|
|
28
28
|
"description": "Review changes and update documentation if key decisions, behaviour changes, or constraints were introduced.",
|
|
29
29
|
"template": "templates/document.md",
|
|
30
30
|
"dependsOn": ["review"]
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"id": "housekeeping",
|
|
34
|
+
"title": "Housekeeping",
|
|
35
|
+
"description": "Remove the worktrees of closed beads. Run `node <ralph-scripts>/prune-worktrees.mjs` from the main checkout and retry once if it exits non-zero. The exit status is 1 whenever any worktree survives — an open bead counts as survival — so fail this chore only when a closed-bead worktree is still refused after the retry, and leave it open. Never close it over a worktree the script did not remove.",
|
|
36
|
+
"template": "templates/housekeeping.md",
|
|
37
|
+
"labels": ["lifecycle:housekeeping"],
|
|
38
|
+
"dependsOn": ["featurePrReview"]
|
|
31
39
|
}
|
|
32
40
|
]
|
|
33
41
|
}
|
|
@@ -27,6 +27,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
27
27
|
* @property {string} description
|
|
28
28
|
* @property {string} template
|
|
29
29
|
* @property {string[]} dependsOn
|
|
30
|
+
* @property {string[]} [labels] Labels beside `stage:<id>`; e.g. `lifecycle:housekeeping`
|
|
30
31
|
*/
|
|
31
32
|
|
|
32
33
|
/**
|
|
@@ -136,6 +137,10 @@ function wireBlocks(beadId, dependsOnId, blocks) {
|
|
|
136
137
|
|
|
137
138
|
// ─── Main ─────────────────────────────────────────────────────────────────────
|
|
138
139
|
|
|
140
|
+
// Reserved `dependsOn` target: the PR gate bead. The gate is not a stage — it is
|
|
141
|
+
// created after them — so a stage that waits on it is wired in a second pass.
|
|
142
|
+
const GATE_ROLE = 'featurePrReview';
|
|
143
|
+
|
|
139
144
|
const __filename = fileURLToPath(import.meta.url);
|
|
140
145
|
const __dirname = dirname(__filename);
|
|
141
146
|
|
|
@@ -150,6 +155,10 @@ const adoptable = readAdoptable(parentId);
|
|
|
150
155
|
/** @type {Map<string, string>} */
|
|
151
156
|
const stageToBeadId = new Map();
|
|
152
157
|
|
|
158
|
+
/** Stages that wait on the gate rather than on another stage, held until it exists.
|
|
159
|
+
* @type {Array<{ beadId: string, blocks: Set<string> }>} */
|
|
160
|
+
const gateDependents = [];
|
|
161
|
+
|
|
153
162
|
for (const stage of pipeline.stages) {
|
|
154
163
|
const adopted = adoptable.get(`stage:${stage.id}`);
|
|
155
164
|
|
|
@@ -160,7 +169,7 @@ for (const stage of pipeline.stages) {
|
|
|
160
169
|
'--type', 'chore',
|
|
161
170
|
'--description', JSON.stringify(stage.description),
|
|
162
171
|
'--priority', priority,
|
|
163
|
-
'--labels', `stage:${stage.id}`,
|
|
172
|
+
'--labels', [`stage:${stage.id}`, ...(stage.labels ?? [])].join(','),
|
|
164
173
|
'--parent', parentId,
|
|
165
174
|
'--silent',
|
|
166
175
|
]);
|
|
@@ -171,6 +180,7 @@ for (const stage of pipeline.stages) {
|
|
|
171
180
|
const blocks = adopted ? adopted.blocks : new Set();
|
|
172
181
|
|
|
173
182
|
for (const depStageId of stage.dependsOn) {
|
|
183
|
+
if (depStageId === GATE_ROLE) { continue; }
|
|
174
184
|
const depBeadId = stageToBeadId.get(depStageId);
|
|
175
185
|
if (!depBeadId) {
|
|
176
186
|
console.error(`Stage '${stage.id}' depends on '${depStageId}', but it has not been created yet. Check dependsOn ordering in pipeline.json.`);
|
|
@@ -178,15 +188,17 @@ for (const stage of pipeline.stages) {
|
|
|
178
188
|
}
|
|
179
189
|
wireBlocks(beadId, depBeadId, blocks);
|
|
180
190
|
}
|
|
181
|
-
}
|
|
182
191
|
|
|
183
|
-
|
|
184
|
-
if (!finalStageId) {
|
|
185
|
-
console.error('No pipeline stages found in pipeline.json.');
|
|
186
|
-
process.exit(1);
|
|
192
|
+
if (stage.dependsOn.includes(GATE_ROLE)) { gateDependents.push({ beadId, blocks }); }
|
|
187
193
|
}
|
|
188
194
|
|
|
189
|
-
|
|
195
|
+
// The gate waits on the last stage that does not itself wait on the gate, so the
|
|
196
|
+
// chain stays acyclic once a stage can follow the gate.
|
|
197
|
+
const chainStageIds = pipeline.stages
|
|
198
|
+
.filter((stage) => !stage.dependsOn.includes(GATE_ROLE))
|
|
199
|
+
.map((stage) => stage.id);
|
|
200
|
+
|
|
201
|
+
const documentStageBeadId = stageToBeadId.get('document') ?? stageToBeadId.get(chainStageIds.at(-1) ?? '');
|
|
190
202
|
if (!documentStageBeadId) {
|
|
191
203
|
console.error('Could not resolve the final stage bead needed for PR gate dependency.');
|
|
192
204
|
process.exit(1);
|
|
@@ -220,6 +232,12 @@ const prGateBeadId = adoptedGate
|
|
|
220
232
|
const gateBlocks = adoptedGate ? adoptedGate.blocks : new Set();
|
|
221
233
|
wireBlocks(prGateBeadId, documentStageBeadId, gateBlocks);
|
|
222
234
|
|
|
235
|
+
// Second pass: the stages that wait on the gate finally get their dep, now that the
|
|
236
|
+
// gate bead exists.
|
|
237
|
+
for (const { beadId, blocks } of gateDependents) {
|
|
238
|
+
wireBlocks(beadId, prGateBeadId, blocks);
|
|
239
|
+
}
|
|
240
|
+
|
|
223
241
|
/** @type {Record<string, string>} */
|
|
224
242
|
const result = {
|
|
225
243
|
...Object.fromEntries(stageToBeadId),
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
## Project context
|
|
2
|
+
<bd prime output — verbatim>
|
|
3
|
+
|
|
4
|
+
## Task
|
|
5
|
+
<bd show <id> output — verbatim>
|
|
6
|
+
|
|
7
|
+
## Progress log
|
|
8
|
+
Write progress to: `.agent-cortex/ralph/ralph-<bead-id>.log`
|
|
9
|
+
Log stage start, each pruner run with its exit status, and stage complete (see Progress Logging in
|
|
10
|
+
run-pipeline-stage skill).
|
|
11
|
+
|
|
12
|
+
## Instructions
|
|
13
|
+
This stage removes worktrees, so **do not work in the bead worktree**. Run every command from the
|
|
14
|
+
repository's main checkout — the directory that holds `.agent-cortex/worktrees/` — because the
|
|
15
|
+
worktree this run may remove can be your own.
|
|
16
|
+
|
|
17
|
+
1. **Run the pruner once** from the main checkout:
|
|
18
|
+
```bash
|
|
19
|
+
node <ralph-scripts>/prune-worktrees.mjs
|
|
20
|
+
```
|
|
21
|
+
`<ralph-scripts>` is the absolute path to `skills/workflow/ralph/scripts/`, resolved from wherever
|
|
22
|
+
that skill was loaded. Read the per-worktree report it prints — it lists every scanned worktree,
|
|
23
|
+
its classification (`closed-bead`, `open-bead`, `orphan`, `unclassified`) and its outcome
|
|
24
|
+
(`removed`, `kept`, `refused`).
|
|
25
|
+
|
|
26
|
+
2. **Retry once on a non-zero exit.** Exit status `1` means at least one scanned worktree survived
|
|
27
|
+
the run, and an open bead counts as survival — a normal run against a live backlog exits `1`. So
|
|
28
|
+
run the same command a second time when the first run exits non-zero. One retry, never more.
|
|
29
|
+
|
|
30
|
+
3. **Decide the outcome from the report, not the exit status.** After the retry:
|
|
31
|
+
- **SUCCESS** — no worktree classified `closed-bead` is left in `refused` state. Report every
|
|
32
|
+
worktree the run removed and every one it kept, with the printed reason.
|
|
33
|
+
- **BLOCKED** — a `closed-bead` worktree is still `refused`, or the pruner could not run at all
|
|
34
|
+
(missing node, unreadable directory). A refused closed-bead worktree is a worktree this run did
|
|
35
|
+
not remove, whatever the reason printed.
|
|
36
|
+
|
|
37
|
+
4. **Never close this chore over a worktree the script did not remove.** This stage is the one
|
|
38
|
+
exception to the close-on-both-outcomes rule, because this stage is last in the chain and nothing
|
|
39
|
+
waits on its close. Close it only when every `closed-bead` worktree is gone; the worktree removal
|
|
40
|
+
is the only thing this stage completes.
|
|
41
|
+
|
|
42
|
+
On BLOCKED, **do not close the chore**. Leave it open — return it to `open` status if your claim
|
|
43
|
+
moved it to `in_progress` — report the surviving worktrees with the reasons the pruner printed,
|
|
44
|
+
and hand the chore back to a human. Create no `stage:fix` chore and no fresh `stage:housekeeping`
|
|
45
|
+
chore: there is nothing for a fix round to repair, and no later stage to re-block. A `kept` or
|
|
46
|
+
`refused` entry for an open bead is a normal report, not a failure.
|
|
47
|
+
|
|
48
|
+
Do not delete a worktree by hand, do not pass `--remove-orphans`, and do not edit the pruner. This
|
|
49
|
+
stage reports what the script did.
|
|
50
|
+
|
|
51
|
+
End your response with a ---REPORT--- block. The `run-pipeline-stage` skill's
|
|
52
|
+
`prompts/stage-runner.md` holds the authoritative REPORT contract — if this block and that prompt
|
|
53
|
+
differ, follow the prompt and correct this template.
|
|
54
|
+
---REPORT---
|
|
55
|
+
BEAD_ID: <id>
|
|
56
|
+
STAGE_COMPLETED: housekeeping
|
|
57
|
+
SUMMARY: <2–3 sentences: worktrees removed, worktrees kept and why>
|
|
58
|
+
FILES_CHANGED: none
|
|
59
|
+
COMMIT: none
|
|
60
|
+
PUSHED: no
|
|
61
|
+
PR: none
|
|
62
|
+
OUTCOME: <SUCCESS|BLOCKED>
|
|
63
|
+
BLOCKING_ISSUES: ← include only if OUTCOME is BLOCKED; one line per issue
|
|
64
|
+
- <worktree path and the reason the pruner printed>
|
|
65
|
+
---
|
|
@@ -25,7 +25,7 @@ Clarify the skill goal with the user. Ask what triggers users would actually say
|
|
|
25
25
|
|
|
26
26
|
### 2. Draft from the anatomy template
|
|
27
27
|
|
|
28
|
-
Read `
|
|
28
|
+
Read `{{PATH:skill_anatomy}}` as the source of truth. Follow the annotated template for section ordering and front-matter fields. Target ~150 lines for `SKILL.md` but stay under the 150-line hard cap. When the workflow branches or has decision points, include one inline ASCII workflow diagram per the anatomy doc's diagram style guide — ASCII only, never Mermaid.
|
|
29
29
|
|
|
30
30
|
### 3. Apply voice and tone rules
|
|
31
31
|
|
|
@@ -62,7 +62,7 @@ Output: A new `skills/review-security/SKILL.md` with front matter, When to use,
|
|
|
62
62
|
|
|
63
63
|
## Verification checklist
|
|
64
64
|
|
|
65
|
-
- [ ] `
|
|
65
|
+
- [ ] `{{PATH:skill_anatomy}}` was read before drafting
|
|
66
66
|
- [ ] Required sections present: `## When to use`, `## Workflow`, `## Verification checklist`
|
|
67
67
|
- [ ] Section order follows the anatomy template
|
|
68
68
|
- [ ] Workflow diagram (if present) is ASCII-only and follows the anatomy diagram style guide
|
|
@@ -8,7 +8,7 @@ description: Refactor an existing SKILL.md to the canonical anatomy template thr
|
|
|
8
8
|
## When to use
|
|
9
9
|
|
|
10
10
|
- A skill's SKILL.md predates the anatomy template and needs upgrading.
|
|
11
|
-
- You want to align a skill with `
|
|
11
|
+
- You want to align a skill with `{{PATH:skill_anatomy}}` before the refactor wave touches it.
|
|
12
12
|
- You want to compare a skill's structure to the current template standard.
|
|
13
13
|
- Trigger phrases: "refactor this skill", "restructure this skill", "review this skill", "upgrade X to the template", "align with anatomy".
|
|
14
14
|
|
|
@@ -27,7 +27,7 @@ Read the target skill's `skills/<name>/SKILL.md` and the canonical anatomy refer
|
|
|
27
27
|
|
|
28
28
|
```bash
|
|
29
29
|
cat skills/<name>/SKILL.md
|
|
30
|
-
cat
|
|
30
|
+
cat {{PATH:skill_anatomy}}
|
|
31
31
|
```
|
|
32
32
|
|
|
33
33
|
If the target has no `SKILL.md` yet, abort — this skill upgrades existing files; use `write-a-skill` for new creation.
|
|
@@ -47,7 +47,7 @@ are fine.
|
|
|
47
47
|
|
|
48
48
|
## Expansion Guard
|
|
49
49
|
|
|
50
|
-
Stage chores are pre-created, never created on demand. `create-chores.mjs` (in `skills/planning/create-task/`) reads `pipeline.json` and creates the whole chain in one shot — `code` → `verify` → `review` → `document`, plus the `featurePrReview` HITL gate — with `blocks` deps that encode the order, and with the parent's priority and inherited labels. It is idempotent: a re-run adopts the chores that already exist under the parent, creates only the missing ones, and skips deps that are already wired. Ralph's dispatch is therefore always "claim what `bd ready -l execution:afk` returns".
|
|
50
|
+
Stage chores are pre-created, never created on demand. `create-chores.mjs` (in `skills/planning/create-task/`) reads `pipeline.json` and creates the whole chain in one shot — `code` → `verify` → `review` → `document`, plus the `featurePrReview` HITL gate, plus the `housekeeping` chore blocked behind that gate — with `blocks` deps that encode the order, and with the parent's priority and inherited labels. It is idempotent: a re-run adopts the chores that already exist under the parent, creates only the missing ones, and skips deps that are already wired. Ralph's dispatch is therefore always "claim what `bd ready -l execution:afk` returns".
|
|
51
51
|
|
|
52
52
|
A ready `execution:afk` task bead with **no** `stage:*` chore children has nothing to dispatch. Expand it, then dispatch the resulting `stage:code` chore:
|
|
53
53
|
|
|
@@ -57,7 +57,7 @@ A ready `execution:afk` task bead with **no** `stage:*` chore children has nothi
|
|
|
57
57
|
```
|
|
58
58
|
Expect `0` printed. `grep -c` exits `1` when it counts zero matches, so add `|| true` when the
|
|
59
59
|
check runs under `set -e` or inside an `&&` chain — the printed count is the answer, not the exit
|
|
60
|
-
status. A bead with a full chain — one `stage:*` chore per stage in `pipeline.json` (
|
|
60
|
+
status. A bead with a full chain — one `stage:*` chore per stage in `pipeline.json` (five for the
|
|
61
61
|
shipped pipeline) plus the `lifecycle:feature-pr` gate — never needs an expansion. The check
|
|
62
62
|
answers "no chores at all", so a **partial** chain (a lower count from a failed run) passes it
|
|
63
63
|
and its missing stages never arrive. Nothing detects that automatically: re-run step 2's command
|
|
@@ -72,7 +72,7 @@ A ready `execution:afk` task bead with **no** `stage:*` chore children has nothi
|
|
|
72
72
|
```json
|
|
73
73
|
{ "kind": "expand", "choreId": null, "parentId": "<parent-id>", "title": "<parent title>", "agentId": "<agent-id>", "logLine": 1 }
|
|
74
74
|
```
|
|
75
|
-
4. **Report the mapping** the worker returns — `code`, `verify`, `review`, `document`, `featurePrReview` — and close nothing. The worker owns no bead on this run; the expansion is bookkeeping, not a stage.
|
|
75
|
+
4. **Report the mapping** the worker returns — `code`, `verify`, `review`, `document`, `housekeeping`, `featurePrReview` — and close nothing. The worker owns no bead on this run; the expansion is bookkeeping, not a stage.
|
|
76
76
|
5. **Dispatch normally.** The next `bd ready -l execution:afk` pass picks up the `stage:code` chore, and the pre-created chain carries the rest.
|
|
77
77
|
6. **Handle the completion as an expansion completion**, not a stage completion — see below.
|
|
78
78
|
|
|
@@ -97,7 +97,7 @@ On completion, remove the entry from `inflight` and confirm the chore tree exist
|
|
|
97
97
|
bd children <parent-id> --json | grep -c '"stage:'
|
|
98
98
|
```
|
|
99
99
|
|
|
100
|
-
Expect one match per `stage:*` label in the chain —
|
|
100
|
+
Expect one match per `stage:*` label in the chain — five for the shipped `pipeline.json`; the
|
|
101
101
|
`featurePrReview` gate carries `lifecycle:feature-pr` instead, so it is not counted. A lower count
|
|
102
102
|
means the expansion did not finish: `0` created nothing, and a partial chain leaves stages that
|
|
103
103
|
will never become ready. Either way, surface the worker's output in chat, record the parent for the
|
|
@@ -204,6 +204,37 @@ 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
|
+
### Housekeeping chore
|
|
227
|
+
|
|
228
|
+
Pruning is a pipeline stage, not a habit: `create-chores.mjs` pre-creates a `stage:housekeeping` chore for every expanded task, carrying `lifecycle:housekeeping` and blocked behind that task's `lifecycle:feature-pr` gate. It stays out of `bd ready` until a human closes the gate, so it never competes with pipeline work.
|
|
229
|
+
|
|
230
|
+
- **Executor.** A dispatched worker — the `stage:housekeeping` chore in `bd ready -l execution:afk` — runs `prune-worktrees.mjs` from the **main checkout**, never from the feature worktree: the worktree it removes can be the parent's own. Ralph dispatches it with the main checkout as cwd for that reason.
|
|
231
|
+
- **Retry once.** Exit status `1` means some scanned worktree survived, and an open bead counts as survival, so a live backlog always exits non-zero. The worker therefore retries the run once. The retry is the whole allowance — never a third run.
|
|
232
|
+
- **Never close over a worktree the script did not remove.** The outcome comes from the per-worktree report, not the exit status: the chore succeeds when no `closed-bead` worktree is left `refused`. A closed-bead worktree still `refused` after the retry fails the chore, and the chore is left **open** with the printed reasons in its notes. A `kept` or `refused` entry for an open bead is a normal report, not a failure.
|
|
233
|
+
|
|
234
|
+
Ralph creates nothing for this stage and closes nothing: the worker owns the chore, as it owns every leaf bead.
|
|
235
|
+
|
|
236
|
+
---
|
|
237
|
+
|
|
207
238
|
## Feature PR Gate
|
|
208
239
|
|
|
209
240
|
When the `document` chore for a parent task completes:
|
|
@@ -219,15 +250,15 @@ When the `document` chore for a parent task completes:
|
|
|
219
250
|
4. Find the child HITL task bead for this parent with label `lifecycle:feature-pr` and `execution:hitl` (created by `create-task`).
|
|
220
251
|
5. Update that HITL bead with the PR URL/status (comment or note) so the reviewer has the link.
|
|
221
252
|
6. Do not schedule new parent features while this gate is **unblocked** — an unblocked gate is a feature genuinely awaiting a human merge (see _Gate predicate_ below).
|
|
222
|
-
7. Once the PR is merged, a human closes the HITL PR gate bead, which unblocks the scheduling brake. After that, close the parent feature bead:
|
|
253
|
+
7. Once the PR is merged, a human closes the HITL PR gate bead, which unblocks the scheduling brake **and** the pre-created `stage:housekeeping` chore. After that, close the parent feature bead:
|
|
223
254
|
```bash
|
|
224
255
|
bd close <parent-id>
|
|
225
256
|
```
|
|
226
|
-
Only then continue with next parent features.
|
|
257
|
+
The housekeeping run that follows removes the parent's worktree, because its bead now reads `closed`. Only then continue with next parent features.
|
|
227
258
|
|
|
228
259
|
### Gate predicate — parked vs pending
|
|
229
260
|
|
|
230
|
-
Ralph's only scheduling brake is a gate that is **unblocked**. `create-chores` pre-creates a `lifecycle:feature-pr` gate for *every* planned feature, blocked behind that feature's own `document` chore; such a gate is **parked** — the feature has not reached its PR stage — and must never stop dispatch. Only when the `document` chore closes does the gate unblock, and that is the state in which a human must merge.
|
|
261
|
+
Ralph's only scheduling brake is a gate that is **unblocked**. `create-chores` pre-creates a `lifecycle:feature-pr` gate for *every* planned feature, blocked behind that feature's own `document` chore; such a gate is **parked** — the feature has not reached its PR stage — and must never stop dispatch. Only when the `document` chore closes does the gate unblock, and that is the state in which a human must merge. The feature's `stage:housekeeping` chore waits on the gate bead itself, so it becomes ready only after that human closes it.
|
|
231
262
|
|
|
232
263
|
Resolve the question with one command instead of ad-hoc `bd` queries:
|
|
233
264
|
|