@jaybeeuu/agent-cortex 2.6.0 → 2.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/CHANGELOG.md +269 -0
  2. package/README.md +102 -8
  3. package/agents/README.md +2 -2
  4. package/agents/ralph/agent.md +19 -16
  5. package/agents/ralph/claude/wake.md +4 -4
  6. package/agents/ralph/pi/wake.md +15 -20
  7. package/bin/agent-cortex.mjs +176 -67
  8. package/bin/installers/claude.mjs +33 -8
  9. package/bin/installers/pi.mjs +11 -1
  10. package/docs/skills/skill-anatomy.md +212 -0
  11. package/extensions/post-merge-watch/README.md +133 -0
  12. package/extensions/post-merge-watch/index.test.ts +247 -0
  13. package/extensions/post-merge-watch/index.ts +123 -0
  14. package/extensions/post-merge-watch/package.json +16 -0
  15. package/extensions/post-merge-watch/watch.test.ts +469 -0
  16. package/extensions/post-merge-watch/watch.ts +431 -0
  17. package/extensions/session-clear/README.md +74 -0
  18. package/extensions/session-clear/index.test.ts +524 -0
  19. package/extensions/session-clear/index.ts +159 -0
  20. package/extensions/session-clear/package.json +16 -0
  21. package/lib/cli.mjs +88 -1
  22. package/lib/harness-update.mjs +254 -0
  23. package/lib/harness-versions.mjs +36 -2
  24. package/lib/self-update.mjs +553 -0
  25. package/package.json +2 -1
  26. package/scripts/lib/docs.mjs +67 -0
  27. package/skills/engineering/implement-task/SKILL.md +124 -0
  28. package/skills/engineering/tdd/SKILL.md +2 -0
  29. package/skills/planning/create-task/REFERENCE.md +20 -3
  30. package/skills/planning/create-task/SKILL.md +1 -1
  31. package/skills/planning/create-task/pipeline.json +16 -0
  32. package/skills/planning/create-task/scripts/create-chores.mjs +51 -24
  33. package/skills/planning/create-task/templates/code.md +8 -3
  34. package/skills/planning/create-task/templates/document.md +8 -3
  35. package/skills/planning/create-task/templates/fix.md +8 -3
  36. package/skills/planning/create-task/templates/housekeeping.md +69 -0
  37. package/skills/planning/create-task/templates/post-merge.md +67 -0
  38. package/skills/planning/create-task/templates/review.md +8 -3
  39. package/skills/planning/create-task/templates/verify.md +8 -3
  40. package/skills/productivity/bd-tool/SKILL.md +55 -0
  41. package/skills/productivity/write-a-skill/SKILL.md +2 -2
  42. package/skills/review/refactor-skill/SKILL.md +2 -2
  43. package/skills/review/review-defects/EXAMPLES.md +122 -0
  44. package/skills/review/review-defects/SKILL.md +91 -0
  45. package/skills/style/style-tests/EXAMPLES.md +30 -0
  46. package/skills/style/style-tests/SKILL.md +10 -6
  47. package/skills/workflow/plan/SKILL.md +14 -3
  48. package/skills/workflow/ralph/REFERENCE.md +137 -30
  49. package/skills/workflow/ralph/SKILL.md +26 -16
  50. package/skills/workflow/ralph/scripts/post-merge-check.mjs +651 -0
  51. package/skills/workflow/run-pipeline-stage/SKILL.md +41 -23
  52. package/skills/workflow/run-pipeline-stage/playbooks/code.md +14 -8
  53. package/skills/workflow/run-pipeline-stage/playbooks/document.md +2 -2
  54. package/skills/workflow/run-pipeline-stage/playbooks/fix.md +2 -2
  55. package/skills/workflow/run-pipeline-stage/playbooks/housekeeping.md +58 -0
  56. package/skills/workflow/run-pipeline-stage/playbooks/post-merge.md +54 -0
  57. package/skills/workflow/run-pipeline-stage/playbooks/review.md +34 -3
  58. package/skills/workflow/run-pipeline-stage/playbooks/verify.md +2 -2
  59. package/skills/workflow/run-pipeline-stage/prompts/stage-runner.md +66 -10
  60. package/skills/workflow/run-pipeline-stage/scripts/recover-brief.mjs +805 -0
  61. package/skills/workflow/run-pipeline-stage/scripts/trail-entry.mjs +702 -0
  62. package/skills/workflow/using-agent-skills/SKILL.md +15 -7
  63. package/token-map.README.md +41 -4
  64. package/token-map.json +16 -5
package/lib/cli.mjs CHANGED
@@ -4,10 +4,14 @@ import { basename } from "node:path";
4
4
  * Result of parsing the CLI argv. Every branch returns `command`; the rest are
5
5
  * populated only for the commands that accept them.
6
6
  * @typedef {object} ParsedArgs
7
- * @property {"help" | "version" | "install" | "migrate" | "unknown"} command
7
+ * @property {"help" | "version" | "install" | "update" | "self-update" | "migrate" | "unknown"} command
8
8
  * @property {boolean} [report] `version` subcommand: report installed harnesses too
9
9
  * @property {boolean} [json] `version --json`: print the report as JSON
10
+ * @property {boolean} [check] `self-update --check`: report and stop
10
11
  * @property {string | null} [harness]
12
+ * @property {string[]} [harnesses] `update`: the harnesses named on the command line
13
+ * @property {boolean} [all] `update -a, --all`: every harness that is not at the CLI version
14
+ * @property {boolean} [force] `update -f, --force`: re-run every installer regardless of version
11
15
  * @property {string} [name]
12
16
  * @property {string} [optionError]
13
17
  * @property {boolean} [dryRun]
@@ -125,6 +129,50 @@ export function parseArgs(argv, programName = DEFAULT_PROGRAM_NAME) {
125
129
  return { command: "install", harness, ...options };
126
130
  }
127
131
 
132
+ if (command === "update") {
133
+ // `update <harness>...` refreshes exactly those harnesses; `-a` refreshes
134
+ // every harness that is not at the CLI version and `-f` forces a re-run.
135
+ // `-f` alone has no meaning, so it is rejected rather than ignored.
136
+ /** @type {string[]} */
137
+ const harnesses = [];
138
+ let all = false;
139
+ let force = false;
140
+ for (const arg of rest) {
141
+ if (arg === "--help" || arg === "-h") return { command: "help" };
142
+ if (arg === "-a" || arg === "--all") {
143
+ all = true;
144
+ continue;
145
+ }
146
+ if (arg === "-f" || arg === "--force") {
147
+ force = true;
148
+ continue;
149
+ }
150
+ if (arg.startsWith("-")) {
151
+ return { command: "update", optionError: `Unknown option "${arg}". Run "${programName} --help" for usage.` };
152
+ }
153
+ harnesses.push(arg);
154
+ }
155
+ if (force && !all) {
156
+ return { command: "update", optionError: `"--force" requires "--all". Run "${programName} --help" for usage.` };
157
+ }
158
+ return { command: "update", harnesses, all, force };
159
+ }
160
+
161
+ if (command === "self-update") {
162
+ // A bare run acts; `--check` reports and stops. Nothing else is accepted,
163
+ // so a typo fails before the CLI touches the global install.
164
+ let check = false;
165
+ for (const arg of rest) {
166
+ if (arg === "--help" || arg === "-h") return { command: "help" };
167
+ if (arg === "--check") {
168
+ check = true;
169
+ continue;
170
+ }
171
+ return { command: "self-update", optionError: `Unknown option "${arg}". Run "${programName} --help" for usage.` };
172
+ }
173
+ return { command: "self-update", check };
174
+ }
175
+
128
176
  if (command === "migrate") {
129
177
  // Dry run is the default, so `--apply` is the only state-changing flag;
130
178
  // `--dry-run` stays accepted for scripts that pass it explicitly. `--dir`
@@ -239,6 +287,45 @@ Commands:
239
287
  cannot register the staged package
240
288
  (missing from PATH); default warns and
241
289
  prints the manual commands
290
+ update [<harness>...] Refresh installed harnesses by re-running their
291
+ installers. With no argument the command lists every
292
+ harness with its installed version and the CLI version,
293
+ then asks which to refresh (a non-interactive shell
294
+ prints the same table and the flags below, then exits
295
+ non-zero without blocking).
296
+ Options:
297
+ <harness>... Refresh exactly these harnesses, no prompt
298
+ -a, --all Refresh every harness that is not at the
299
+ CLI version (not installed, stale, or
300
+ ahead); ignored when harnesses are named
301
+ -f, --force With --all: re-run every installer even
302
+ when the version already matches
303
+ --help, -h Show this help text
304
+ self-update Update the globally installed CLI through the manager that
305
+ owns this install, then refresh the harness installs with
306
+ the binary the update installed. A bare run acts; --check
307
+ only reports. The manager is inferred from the running
308
+ entry point's real path (pnpm's global/<v> or store link,
309
+ or npm's lib/node_modules), then confirmed by asking that
310
+ manager to list this package among its globals at the
311
+ version installed beside the entry point; a manager on
312
+ PATH is never used in its place. An owner that cannot be
313
+ inferred prints both managers' install commands and exits
314
+ 1; an inferred manager that does not confirm prints its own
315
+ command, says nothing was updated, and exits 1. Otherwise
316
+ it prints the CLI version before and after the install,
317
+ then runs the new binary's "update -a" with this terminal,
318
+ so the refresh's own output is visible, and exits with the
319
+ refresh's exit code. The refresh is skipped after --check,
320
+ after a failed update, and when the installed version
321
+ already matches the published one; that case installs
322
+ nothing and points at "update -a". A refresh that cannot
323
+ start prints the error and the command to run by hand, and
324
+ still exits non-zero.
325
+ Options:
326
+ (no option) Report, update, then refresh harnesses
327
+ --check Report the installed and published
328
+ versions and stop, writing nothing
242
329
  migrate Migrate the current beads workspace to the latest
243
330
  agent-cortex migration version, applying versioned steps
244
331
  in order (repo-agnostic — works in any beads workspace)
@@ -0,0 +1,254 @@
1
+ // The selection and reporting half of `ctx update`: which harnesses a refresh
2
+ // covers, how the table and the report read, and the interactive prompt.
3
+ //
4
+ // Delegation is not here. The caller injects `refresh(harness)`, which the CLI
5
+ // wires to the harness installers — so update-vs-install stays the installer's
6
+ // decision and this module never writes an artifact itself.
7
+
8
+ import { homedir } from "node:os";
9
+ import { collectHarnessStatus } from "./harness-versions.mjs";
10
+
11
+ /** @typedef {import("./harness-versions.mjs").HarnessStatus} HarnessStatus */
12
+
13
+ const NOT_INSTALLED = "not installed";
14
+ const UNKNOWN = "unknown";
15
+
16
+ /**
17
+ * One refreshed harness in the update report.
18
+ * @typedef {object} UpdateEntry
19
+ * @property {string} harness
20
+ * @property {string} previous The version before the refresh, or "not installed"
21
+ * @property {string} next The version the installer recorded afterwards
22
+ * @property {string[]} artifacts What the installer wrote
23
+ */
24
+
25
+ /**
26
+ * The harnesses an update covers, and the ones it leaves alone.
27
+ * @typedef {object} UpdatePlan
28
+ * @property {HarnessStatus[]} targets
29
+ * @property {HarnessStatus[]} skipped
30
+ * @property {string[]} unknown Names that are not supported harnesses
31
+ */
32
+
33
+ /**
34
+ * Decide which harnesses an update refreshes. An explicit name is always a
35
+ * target (the user asked for that installer to re-run); `--all` targets every
36
+ * harness that is not at the CLI version; `--all --force` targets every harness.
37
+ * @param {object} options
38
+ * @param {HarnessStatus[]} options.statuses One entry per supported harness
39
+ * @param {string[]} [options.harnesses] Names from the command line or the prompt
40
+ * @param {boolean} [options.all] `ctx update -a`
41
+ * @param {boolean} [options.force] `ctx update -a -f`
42
+ * @returns {UpdatePlan}
43
+ */
44
+ export function selectHarnesses({ statuses, harnesses = [], all = false, force = false }) {
45
+ /** @type {string[]} */
46
+ const unknown = [];
47
+ /** @type {HarnessStatus[]} */
48
+ let targets;
49
+ if (harnesses.length > 0) {
50
+ targets = [];
51
+ for (const name of harnesses) {
52
+ const status = statuses.find((entry) => entry.harness === name);
53
+ if (status === undefined) {
54
+ if (!unknown.includes(name)) unknown.push(name);
55
+ continue;
56
+ }
57
+ if (!targets.includes(status)) targets.push(status);
58
+ }
59
+ } else if (all && force) {
60
+ targets = [...statuses];
61
+ } else if (all) {
62
+ targets = statuses.filter((entry) => entry.staleness !== "current");
63
+ } else {
64
+ targets = [];
65
+ }
66
+ const skipped = statuses.filter((entry) => !targets.includes(entry));
67
+ return { targets, skipped, unknown };
68
+ }
69
+
70
+ /**
71
+ * Count a noun for the report: `1 file`, `4 files`.
72
+ * @param {number} amount
73
+ * @param {string} noun Singular form
74
+ * @returns {string}
75
+ */
76
+ function count(amount, noun) {
77
+ return `${amount} ${noun}${amount === 1 ? "" : "s"}`;
78
+ }
79
+
80
+ /**
81
+ * What a pi refresh wrote, for the update report.
82
+ * @param {import("../bin/installers/pi.mjs").InstallPiResult} result
83
+ * @returns {string[]}
84
+ */
85
+ export function describePiArtifacts(result) {
86
+ return [
87
+ `agents (${count(result.agents.length, "file")})`,
88
+ `skills (${count(result.skills.skills, "skill")}, ${count(result.skills.md, "markdown file")})`,
89
+ `extensions (${count(result.extensions.files, "file")})`,
90
+ `bundled pi packages (${result.packages.bundled.length > 0 ? result.packages.bundled.join(", ") : "none"})`,
91
+ `package manifest (version ${result.manifest.manifest.version})`,
92
+ ];
93
+ }
94
+
95
+ /**
96
+ * What a claude refresh wrote, for the update report.
97
+ * @param {import("../bin/installers/claude.mjs").InstallClaudeResult} result
98
+ * @returns {string[]}
99
+ */
100
+ export function describeClaudeArtifacts(result) {
101
+ return [
102
+ `agents (${count(result.agents.length, "file")})`,
103
+ `skills (${count(result.skills.names.length, "skill")}, ${count(result.skills.md, "markdown file")})`,
104
+ `hooks (${count(result.hookFiles.length, "file")})`,
105
+ `hand-authored extras (${count(result.handAuthored.length, "file")})`,
106
+ `plugin manifest (version ${result.pluginVersion})`,
107
+ `settings (${result.settings?.action ?? "not written"})`,
108
+ `instructions (${result.instructions?.action ?? "not written"})`,
109
+ ];
110
+ }
111
+
112
+ /**
113
+ * Refresh every target through the injected installer and report the result.
114
+ * The new version is read back from the fingerprint the installer wrote, never
115
+ * assumed from the CLI version, so a failed materialisation shows as unknown.
116
+ * @param {object} options
117
+ * @param {string} options.cli The running CLI version
118
+ * @param {string} [options.home] Home directory holding the install roots
119
+ * @param {HarnessStatus[]} options.targets
120
+ * @param {(harness: string) => Promise<string[]>} options.refresh Runs one harness installer
121
+ * @returns {Promise<UpdateEntry[]>}
122
+ */
123
+ export async function runUpdate({ cli, home = homedir(), targets, refresh }) {
124
+ /** @type {{ harness: string, previous: string, artifacts: string[] }[]} */
125
+ const refreshed = [];
126
+ for (const target of targets) {
127
+ const artifacts = await refresh(target.harness);
128
+ refreshed.push({ harness: target.harness, previous: target.installed ?? NOT_INSTALLED, artifacts });
129
+ }
130
+ if (refreshed.length === 0) return [];
131
+ const statuses = await collectHarnessStatus({ cli, home });
132
+ return refreshed.map((entry) => {
133
+ const status = statuses.find((candidate) => candidate.harness === entry.harness);
134
+ return { ...entry, next: status?.installed ?? UNKNOWN };
135
+ });
136
+ }
137
+
138
+ /**
139
+ * Render the harness table: one row per supported harness, so an absent
140
+ * install is visible instead of silently missing.
141
+ * @param {HarnessStatus[]} statuses
142
+ * @returns {string} Text without a trailing newline
143
+ */
144
+ export function formatHarnessTable(statuses) {
145
+ /** @type {string[][]} */
146
+ const rows = [["harness", "installed", "cli", "status"]];
147
+ for (const status of statuses) {
148
+ rows.push([status.harness, status.installed ?? NOT_INSTALLED, status.cli, status.staleness]);
149
+ }
150
+ const widths = rows[0].map((_, column) => Math.max(...rows.map((row) => row[column].length)));
151
+ return rows
152
+ .map((row) => row.map((cell, column) => (column === widths.length - 1 ? cell : cell.padEnd(widths[column]))).join(" "))
153
+ .join("\n");
154
+ }
155
+
156
+ /**
157
+ * The instructions a non-interactive `ctx update` prints instead of prompting.
158
+ * @param {string} programName The resolved command name
159
+ * @returns {string} Text without a trailing newline
160
+ */
161
+ export function formatUpdateFlags(programName) {
162
+ return [
163
+ "No harness selected, and this shell is not interactive. Pass a selection:",
164
+ ` ${programName} update <harness>... refresh the named harnesses`,
165
+ ` ${programName} update -a refresh every harness that is not at the CLI version`,
166
+ ` ${programName} update -a -f re-run every harness installer regardless of version`,
167
+ ].join("\n");
168
+ }
169
+
170
+ /**
171
+ * Render the outcome of an update. Only skipped harnesses that are already at
172
+ * the CLI version are listed; one left alone for another reason (a stale
173
+ * harness beside an explicit selection) is not "already at" anything.
174
+ * @param {object} options
175
+ * @param {string} options.cli The running CLI version
176
+ * @param {UpdateEntry[]} options.entries
177
+ * @param {HarnessStatus[]} [options.skipped]
178
+ * @returns {string} Text without a trailing newline
179
+ */
180
+ export function formatUpdateReport({ cli, entries, skipped = [] }) {
181
+ const current = skipped.filter((entry) => entry.staleness === "current");
182
+ /** @type {string[]} */
183
+ const lines = [];
184
+ for (const entry of entries) {
185
+ lines.push(`${entry.harness} ${entry.previous} → ${entry.next}`);
186
+ for (const artifact of entry.artifacts) {
187
+ lines.push(` ✓ ${artifact}`);
188
+ }
189
+ }
190
+ if (entries.length === 0) {
191
+ lines.push(`Nothing to update — every harness is at ${cli}.`);
192
+ } else if (current.length > 0) {
193
+ lines.push(`Skipped (already at ${cli}): ${current.map((entry) => entry.harness).join(", ")}`);
194
+ }
195
+ return lines.join("\n");
196
+ }
197
+
198
+ /**
199
+ * Turn a prompt answer into harness names: row numbers, `a` for every row, or
200
+ * blank for none.
201
+ * @param {string} answer
202
+ * @param {HarnessStatus[]} statuses The rows the prompt listed, in order
203
+ * @returns {string[]}
204
+ */
205
+ export function parseHarnessSelection(answer, statuses) {
206
+ const trimmed = answer.trim();
207
+ if (trimmed === "") return [];
208
+ if (/^(a|all)$/i.test(trimmed)) return statuses.map((status) => status.harness);
209
+ /** @type {string[]} */
210
+ const chosen = [];
211
+ for (const token of trimmed.split(/[,\s]+/)) {
212
+ const index = Number(token);
213
+ const status = Number.isInteger(index) ? statuses[index - 1] : undefined;
214
+ if (status === undefined) {
215
+ throw new Error(`Invalid selection "${token}". Enter row numbers 1-${statuses.length}, "a", or blank to cancel.`);
216
+ }
217
+ if (!chosen.includes(status.harness)) chosen.push(status.harness);
218
+ }
219
+ return chosen;
220
+ }
221
+
222
+ /**
223
+ * Ask on an interactive terminal which harnesses to refresh. The table is the
224
+ * list the user picks from; a closed input stream cancels the selection.
225
+ * @param {object} options
226
+ * @param {HarnessStatus[]} options.statuses
227
+ * @param {NodeJS.ReadableStream} [options.input]
228
+ * @param {NodeJS.WritableStream} [options.output]
229
+ * @returns {Promise<string[]>}
230
+ */
231
+ export async function promptHarnessSelection({ statuses, input = process.stdin, output = process.stdout }) {
232
+ output.write(`${formatHarnessTable(statuses)}\n\n`);
233
+ const answer = await readLine(input, output, `Select harnesses (row numbers, "a" for all, blank to cancel): `);
234
+ return parseHarnessSelection(answer, statuses);
235
+ }
236
+
237
+ /**
238
+ * Read one line, prompting on `output` first. A stream that ends without a
239
+ * newline returns what it carried, so a closed stdin cancels rather than hangs.
240
+ * @param {NodeJS.ReadableStream} input
241
+ * @param {NodeJS.WritableStream} output
242
+ * @param {string} question
243
+ * @returns {Promise<string>}
244
+ */
245
+ async function readLine(input, output, question) {
246
+ output.write(question);
247
+ let buffered = "";
248
+ for await (const chunk of input) {
249
+ buffered += String(chunk);
250
+ const newline = buffered.indexOf("\n");
251
+ if (newline !== -1) return buffered.slice(0, newline);
252
+ }
253
+ return buffered;
254
+ }
@@ -29,8 +29,9 @@ const HARNESS_FINGERPRINTS = [
29
29
  /**
30
30
  * How an installed copy relates to the running CLI. `unknown` covers both a
31
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
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
34
35
  */
35
36
 
36
37
  /**
@@ -107,6 +108,16 @@ async function readInstalledVersion(home, fingerprint) {
107
108
  }
108
109
  }
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
+
110
121
  /**
111
122
  * Report the running CLI version and the installed version of every harness
112
123
  * whose artifact root exists.
@@ -130,6 +141,29 @@ export async function collectVersionReport({ cli, home = homedir() }) {
130
141
  return { cli, harnesses };
131
142
  }
132
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
+
133
167
  /**
134
168
  * Render the report as the default, human-readable output: the CLI version on
135
169
  * the first line, then one line per installed harness. A harness whose version