@jaybeeuu/agent-cortex 2.7.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 (56) hide show
  1. package/CHANGELOG.md +239 -0
  2. package/README.md +61 -5
  3. package/agents/README.md +2 -2
  4. package/agents/ralph/agent.md +17 -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 +20 -1
  8. package/extensions/post-merge-watch/README.md +133 -0
  9. package/extensions/post-merge-watch/index.test.ts +247 -0
  10. package/extensions/post-merge-watch/index.ts +123 -0
  11. package/extensions/post-merge-watch/package.json +16 -0
  12. package/extensions/post-merge-watch/watch.test.ts +469 -0
  13. package/extensions/post-merge-watch/watch.ts +431 -0
  14. package/extensions/session-clear/README.md +74 -0
  15. package/extensions/session-clear/index.test.ts +524 -0
  16. package/extensions/session-clear/index.ts +159 -0
  17. package/extensions/session-clear/package.json +16 -0
  18. package/lib/cli.mjs +42 -1
  19. package/lib/self-update.mjs +553 -0
  20. package/package.json +1 -1
  21. package/skills/engineering/implement-task/SKILL.md +124 -0
  22. package/skills/engineering/tdd/SKILL.md +2 -0
  23. package/skills/planning/create-task/REFERENCE.md +15 -6
  24. package/skills/planning/create-task/SKILL.md +1 -1
  25. package/skills/planning/create-task/pipeline.json +8 -0
  26. package/skills/planning/create-task/scripts/create-chores.mjs +27 -18
  27. package/skills/planning/create-task/templates/code.md +8 -3
  28. package/skills/planning/create-task/templates/document.md +8 -3
  29. package/skills/planning/create-task/templates/fix.md +8 -3
  30. package/skills/planning/create-task/templates/housekeeping.md +8 -4
  31. package/skills/planning/create-task/templates/post-merge.md +67 -0
  32. package/skills/planning/create-task/templates/review.md +8 -3
  33. package/skills/planning/create-task/templates/verify.md +8 -3
  34. package/skills/productivity/bd-tool/SKILL.md +55 -0
  35. package/skills/review/review-defects/EXAMPLES.md +122 -0
  36. package/skills/review/review-defects/SKILL.md +91 -0
  37. package/skills/style/style-tests/EXAMPLES.md +30 -0
  38. package/skills/style/style-tests/SKILL.md +10 -6
  39. package/skills/workflow/plan/SKILL.md +14 -3
  40. package/skills/workflow/ralph/REFERENCE.md +124 -27
  41. package/skills/workflow/ralph/SKILL.md +24 -15
  42. package/skills/workflow/ralph/scripts/post-merge-check.mjs +651 -0
  43. package/skills/workflow/run-pipeline-stage/SKILL.md +37 -22
  44. package/skills/workflow/run-pipeline-stage/playbooks/code.md +14 -8
  45. package/skills/workflow/run-pipeline-stage/playbooks/document.md +2 -2
  46. package/skills/workflow/run-pipeline-stage/playbooks/fix.md +2 -2
  47. package/skills/workflow/run-pipeline-stage/playbooks/housekeeping.md +1 -1
  48. package/skills/workflow/run-pipeline-stage/playbooks/post-merge.md +54 -0
  49. package/skills/workflow/run-pipeline-stage/playbooks/review.md +34 -3
  50. package/skills/workflow/run-pipeline-stage/playbooks/verify.md +2 -2
  51. package/skills/workflow/run-pipeline-stage/prompts/stage-runner.md +55 -11
  52. package/skills/workflow/run-pipeline-stage/scripts/recover-brief.mjs +805 -0
  53. package/skills/workflow/run-pipeline-stage/scripts/trail-entry.mjs +702 -0
  54. package/skills/workflow/using-agent-skills/SKILL.md +15 -7
  55. package/token-map.README.md +27 -0
  56. package/token-map.json +10 -4
package/lib/cli.mjs CHANGED
@@ -4,9 +4,10 @@ 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" | "update" | "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]
11
12
  * @property {string[]} [harnesses] `update`: the harnesses named on the command line
12
13
  * @property {boolean} [all] `update -a, --all`: every harness that is not at the CLI version
@@ -157,6 +158,21 @@ export function parseArgs(argv, programName = DEFAULT_PROGRAM_NAME) {
157
158
  return { command: "update", harnesses, all, force };
158
159
  }
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
+
160
176
  if (command === "migrate") {
161
177
  // Dry run is the default, so `--apply` is the only state-changing flag;
162
178
  // `--dry-run` stays accepted for scripts that pass it explicitly. `--dir`
@@ -285,6 +301,31 @@ Commands:
285
301
  -f, --force With --all: re-run every installer even
286
302
  when the version already matches
287
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
288
329
  migrate Migrate the current beads workspace to the latest
289
330
  agent-cortex migration version, applying versioned steps
290
331
  in order (repo-agnostic — works in any beads workspace)
@@ -0,0 +1,553 @@
1
+ // `ctx self-update`: bring the globally installed CLI up to date through the
2
+ // manager that actually owns this install.
3
+ //
4
+ // The owning manager is inferred from the running entry point's real path, then
5
+ // confirmed by asking that manager to list this package among its global
6
+ // installs. Inference alone is a guess: two managers can be installed and only
7
+ // one owns this copy, so on a machine whose owner is not the manager on PATH a
8
+ // guess updates the wrong store — or fails. An owner that cannot be inferred or
9
+ // confirmed is reported, and never replaced by a manager found on PATH.
10
+ //
11
+ // Every process is spawned with an argv array (`execFile`, `spawn`), never a
12
+ // shell string, so a path with a space or a shell metacharacter stays one
13
+ // argument.
14
+
15
+ import { execFile, spawn } from "node:child_process";
16
+ import { readFile } from "node:fs/promises";
17
+ import { dirname, join } from "node:path";
18
+ import { promisify } from "node:util";
19
+
20
+ const execFileAsync = promisify(execFile);
21
+
22
+ /** The package this CLI ships in. */
23
+ export const PACKAGE_NAME = "@jaybeeuu/agent-cortex";
24
+
25
+ /**
26
+ * The bin name this package publishes under its own name. `package.json` maps
27
+ * `agent-cortex`, `cortex` and `ctx` onto the same file, so the canonical name
28
+ * addresses the newly installed copy whatever alias the user typed.
29
+ */
30
+ export const BIN_NAME = "agent-cortex";
31
+
32
+ /**
33
+ * The manager that installed this copy. `pnpm` and `npm` are the two global
34
+ * installation layouts the inference below recognises.
35
+ * @typedef {"pnpm" | "npm"} Manager
36
+ */
37
+
38
+ /**
39
+ * The argv each manager needs: list the global installs (confirmation), read
40
+ * the published version, and install the latest release.
41
+ * @typedef {object} ManagerCommands
42
+ * @property {string[]} list
43
+ * @property {string[]} view
44
+ * @property {string[]} install
45
+ */
46
+
47
+ /** @type {Record<Manager, ManagerCommands>} */
48
+ const MANAGER_COMMANDS = {
49
+ pnpm: {
50
+ list: ["ls", "-g", "--depth", "0", "--json"],
51
+ view: ["view", PACKAGE_NAME, "version"],
52
+ install: ["add", "-g", `${PACKAGE_NAME}@latest`],
53
+ },
54
+ npm: {
55
+ list: ["ls", "-g", "--depth", "0", "--json"],
56
+ view: ["view", PACKAGE_NAME, "version"],
57
+ install: ["install", "-g", `${PACKAGE_NAME}@latest`],
58
+ },
59
+ };
60
+
61
+ /**
62
+ * The manager that owns an install, with the binary to spawn.
63
+ * @typedef {object} Owner
64
+ * @property {Manager} manager
65
+ * @property {string} prefix The manager's global prefix — the pnpm home, or the node prefix
66
+ * @property {string} command The manager binary inside that prefix
67
+ */
68
+
69
+ /**
70
+ * Result of one spawned command.
71
+ * @typedef {object} RunResult
72
+ * @property {number} code The exit code, or 1 when the process could not run
73
+ * @property {string} stdout
74
+ * @property {string} stderr
75
+ */
76
+
77
+ /**
78
+ * Spawn a command with an argv array and collect its output. Injected so a test
79
+ * can drive the manager without installing anything globally.
80
+ * @typedef {(command: string, args: string[]) => Promise<RunResult>} Run
81
+ */
82
+
83
+ /**
84
+ * The outcome of handing the terminal to a child process.
85
+ * @typedef {object} SpawnResult
86
+ * @property {number} code The child's exit code, or 1 when it never ran
87
+ * @property {string} [error] Why the child could not be started at all
88
+ */
89
+
90
+ /**
91
+ * Spawn a command with the caller's stdio and resolve its exit code. Injected
92
+ * for the same reason as `Run`.
93
+ * @typedef {(command: string, args: string[]) => Promise<SpawnResult>} Spawn
94
+ */
95
+
96
+ /** @type {(text: string) => void} */
97
+ const writeLine = (text) => {
98
+ process.stdout.write(`${text}\n`);
99
+ };
100
+
101
+ /** @type {(text: string) => void} */
102
+ const writeErrorLine = (text) => {
103
+ process.stderr.write(`${text}\n`);
104
+ };
105
+
106
+ // pnpm keeps a versioned home: `global/<v>` holds the install tree the bin shim
107
+ // points at, and `store/<v>` holds the content-addressed copy that tree links
108
+ // into. Resolving the entry point's real path can land in either.
109
+ const PNPM_MARKERS = new Set(["global", "store"]);
110
+ const VERSIONED = /^v?\d+$/;
111
+
112
+ /**
113
+ * Split a path on either separator while remembering whether it was absolute,
114
+ * so a prefix is rebuilt with the shape it arrived with: a POSIX absolute path
115
+ * stays absolute, and a Windows path keeps its drive segment.
116
+ * @param {string} path
117
+ * @returns {{ segments: string[], prefixOf: (index: number) => string }}
118
+ */
119
+ function splitPath(path) {
120
+ const unified = path.replace(/\\/g, "/");
121
+ const segments = unified.split("/").filter((segment) => segment !== "");
122
+ const absolute = unified.startsWith("/");
123
+ /**
124
+ * @param {number} index
125
+ * @returns {string} The path up to (not including) segment `index`
126
+ */
127
+ const prefixOf = (index) => {
128
+ const head = segments.slice(0, index).join("/");
129
+ return absolute ? `/${head}` : head;
130
+ };
131
+ return { segments, prefixOf };
132
+ }
133
+
134
+ /**
135
+ * @param {Manager} manager
136
+ * @param {string} prefix
137
+ * @returns {Owner}
138
+ */
139
+ function ownerOf(manager, prefix) {
140
+ return { manager, prefix, command: `${prefix}/bin/${manager}` };
141
+ }
142
+
143
+ /**
144
+ * Infer the owning manager from the running entry point's real path. The two
145
+ * recognised layouts are the ones the supported managers create:
146
+ *
147
+ * - pnpm: `<prefix>/global/<v>/<hash>/node_modules/<package>/...` for the path
148
+ * the bin shim execs, and `<prefix>/store/<v>/links/<package>/<version>/
149
+ * <hash>/node_modules/<package>/...` once the global tree's link is resolved;
150
+ * - npm: `<prefix>/lib/node_modules/<package>/...`.
151
+ *
152
+ * Anything else — a checkout, a locally linked install, a manager with another
153
+ * layout — returns null. The caller reports that instead of guessing, because a
154
+ * wrong guess updates a manager that does not own this copy.
155
+ * @param {string} entryPoint The resolved real path of the running CLI script
156
+ * @returns {Owner | null}
157
+ */
158
+ export function inferOwner(entryPoint) {
159
+ const { segments, prefixOf } = splitPath(entryPoint);
160
+ const modules = segments.indexOf("node_modules");
161
+ // The package must sit under node_modules, and a global prefix in front of it.
162
+ if (modules <= 1) return null;
163
+
164
+ if (segments[modules - 1] === "lib") {
165
+ const prefix = prefixOf(modules - 1);
166
+ return prefix === "" || prefix === "/" ? null : ownerOf("npm", prefix);
167
+ }
168
+
169
+ for (let index = modules - 1; index > 0; index--) {
170
+ if (!PNPM_MARKERS.has(segments[index])) continue;
171
+ // A marker segment that is not followed by a version is not a pnpm home.
172
+ if (!VERSIONED.test(segments[index + 1] ?? "")) break;
173
+ const prefix = prefixOf(index);
174
+ return prefix === "" || prefix === "/" ? null : ownerOf("pnpm", prefix);
175
+ }
176
+ return null;
177
+ }
178
+
179
+ /**
180
+ * Read the version a global install is at, from the manifest beside the entry
181
+ * point, so a stale process still reports what is on disk. Throws when the
182
+ * manifest is missing, unreadable, or carries no version.
183
+ * @param {string} manifestPath
184
+ * @returns {Promise<string>}
185
+ */
186
+ export async function readInstalledVersion(manifestPath) {
187
+ /** @type {unknown} */
188
+ let manifest;
189
+ try {
190
+ manifest = JSON.parse(await readFile(manifestPath, "utf-8"));
191
+ } catch (err) {
192
+ throw new Error(`Cannot read the installed version from ${manifestPath}: ${/** @type {Error} */ (err).message}`);
193
+ }
194
+ const version = /** @type {{ version?: unknown }} */ (manifest).version;
195
+ if (typeof version !== "string" || version === "") {
196
+ throw new Error(`Cannot read the installed version from ${manifestPath}: the manifest has no "version" string.`);
197
+ }
198
+ return version;
199
+ }
200
+
201
+ /**
202
+ * Parse `<manager> ls -g --depth 0 --json` into package name → installed
203
+ * version. pnpm prints an array of install trees; npm prints an object keyed by
204
+ * global prefix, with the dependencies inside each value.
205
+ * @param {string} stdout
206
+ * @returns {Record<string, string>}
207
+ */
208
+ export function parseGlobalListing(stdout) {
209
+ /** @type {unknown} */
210
+ const parsed = JSON.parse(stdout);
211
+ /** @type {Record<string, string>} */
212
+ const versions = {};
213
+ /**
214
+ * @param {unknown} tree
215
+ * @returns {void}
216
+ */
217
+ const collect = (tree) => {
218
+ if (tree === null || typeof tree !== "object") return;
219
+ const dependencies = /** @type {{ dependencies?: unknown }} */ (tree).dependencies;
220
+ if (dependencies === null || typeof dependencies !== "object") return;
221
+ for (const [name, entry] of Object.entries(dependencies)) {
222
+ if (name in versions || entry === null || typeof entry !== "object") continue;
223
+ const version = /** @type {{ version?: unknown }} */ (entry).version;
224
+ if (typeof version === "string") versions[name] = version;
225
+ }
226
+ };
227
+ collect(parsed);
228
+ if (Array.isArray(parsed)) {
229
+ for (const tree of parsed) collect(tree);
230
+ } else if (parsed !== null && typeof parsed === "object") {
231
+ for (const tree of Object.values(parsed)) collect(tree);
232
+ }
233
+ return versions;
234
+ }
235
+
236
+ /**
237
+ * The outcome of asking the inferred manager to list its global installs.
238
+ * @typedef {object} Confirmation
239
+ * @property {boolean} ok
240
+ * @property {string} [reason] Why the owner is unconfirmed
241
+ */
242
+
243
+ /**
244
+ * Confirm the inferred manager owns this copy: its own global listing must carry
245
+ * this package at the version installed beside the entry point. Two managers can
246
+ * be installed, so the listing is the only evidence that separates the owner
247
+ * from a candidate.
248
+ * @param {object} options
249
+ * @param {Owner} options.owner
250
+ * @param {string} options.installedVersion
251
+ * @param {Run} options.run
252
+ * @returns {Promise<Confirmation>}
253
+ */
254
+ export async function confirmOwner({ owner, installedVersion, run }) {
255
+ const args = MANAGER_COMMANDS[owner.manager].list;
256
+ const result = await run(owner.command, args);
257
+ if (result.code !== 0) {
258
+ const detail = (result.stderr.trim() || result.stdout.trim() || "no output").split("\n")[0];
259
+ return { ok: false, reason: `"${display(owner.command, args)}" exited ${result.code}: ${detail}` };
260
+ }
261
+ /** @type {Record<string, string>} */
262
+ let versions;
263
+ try {
264
+ versions = parseGlobalListing(result.stdout);
265
+ } catch (err) {
266
+ return { ok: false, reason: `"${display(owner.command, args)}" printed output that is not JSON: ${/** @type {Error} */ (err).message}` };
267
+ }
268
+ const listed = versions[PACKAGE_NAME];
269
+ if (listed === undefined) {
270
+ return { ok: false, reason: `"${display(owner.command, args)}" does not list ${PACKAGE_NAME} among its global packages.` };
271
+ }
272
+ if (listed !== installedVersion) {
273
+ return {
274
+ ok: false,
275
+ reason: `"${display(owner.command, args)}" lists ${PACKAGE_NAME} at ${listed}, but this copy is ${installedVersion}.`,
276
+ };
277
+ }
278
+ return { ok: true };
279
+ }
280
+
281
+ /**
282
+ * Read the published version through the confirmed manager's own `view`
283
+ * command, so the answer comes from the registry that manager installs from.
284
+ * @param {object} options
285
+ * @param {Owner} options.owner
286
+ * @param {Run} options.run
287
+ * @returns {Promise<string>}
288
+ */
289
+ export async function readPublishedVersion({ owner, run }) {
290
+ const args = MANAGER_COMMANDS[owner.manager].view;
291
+ const result = await run(owner.command, args);
292
+ if (result.code !== 0) {
293
+ const detail = (result.stderr.trim() || result.stdout.trim() || "no output").split("\n")[0];
294
+ throw new Error(`"${display(owner.command, args)}" exited ${result.code}: ${detail}`);
295
+ }
296
+ // A `view` that succeeded always prints the version; anything else means the
297
+ // registry answer cannot be trusted, so an update is not offered.
298
+ const published = lastLine(result.stdout);
299
+ if (published === "") {
300
+ throw new Error(`"${display(owner.command, args)}" printed no version.`);
301
+ }
302
+ return published;
303
+ }
304
+
305
+ /**
306
+ * The version a command printed last, ignoring blank lines. A manager that
307
+ * prints a banner or a warning before its answer still yields the answer.
308
+ * @param {string} text
309
+ * @returns {string}
310
+ */
311
+ function lastLine(text) {
312
+ return text.trim().split("\n").filter((line) => line !== "").pop() ?? "";
313
+ }
314
+
315
+ /**
316
+ * Read the version the binary the update just installed reports. The running
317
+ * process is the old code and its manifest is the old manifest, so the new
318
+ * binary is the only source for the version that is now on disk.
319
+ * @param {object} options
320
+ * @param {string} options.bin The installed binary the refresh will run
321
+ * @param {Run} options.run
322
+ * @returns {Promise<string | null>} The version, or null when the probe answers nothing usable
323
+ */
324
+ export async function probeBinaryVersion({ bin, run }) {
325
+ const args = ["--version"];
326
+ const result = await run(bin, args);
327
+ if (result.code !== 0) return null;
328
+ const version = lastLine(result.stdout);
329
+ return version === "" ? null : version;
330
+ }
331
+
332
+ /**
333
+ * Render one command for a human. Display only: nothing here is ever executed,
334
+ * every spawn receives the argv array itself.
335
+ * @param {string} command
336
+ * @param {string[]} args
337
+ * @returns {string}
338
+ */
339
+ export function display(command, args) {
340
+ return [command, ...args].join(" ");
341
+ }
342
+
343
+ /**
344
+ * Render the report a `--check` run prints and a bare run prints before it acts.
345
+ * @param {object} options
346
+ * @param {string} options.programName The command name the user typed
347
+ * @param {string} options.installedVersion
348
+ * @param {string} options.publishedVersion
349
+ * @param {Owner} options.owner
350
+ * @param {boolean} [options.check]
351
+ * @returns {string} Text without a trailing newline
352
+ */
353
+ export function formatSelfUpdateReport({ programName, installedVersion, publishedVersion, owner, check = false }) {
354
+ const status =
355
+ installedVersion === publishedVersion
356
+ ? "current"
357
+ : `update available: ${installedVersion} → ${publishedVersion}`;
358
+ const lines = [
359
+ `${programName} self-update`,
360
+ ` installed ${installedVersion}`,
361
+ ` published ${publishedVersion}`,
362
+ ` owner ${owner.manager} (${owner.command})`,
363
+ ` status ${status}`,
364
+ ` command ${display(owner.command, MANAGER_COMMANDS[owner.manager].install)}`,
365
+ ];
366
+ if (check) lines.push("", "Check only — nothing was updated.");
367
+ return lines.join("\n");
368
+ }
369
+
370
+ /**
371
+ * The message for an entry point that is in neither recognised layout. Both
372
+ * commands are printed: the CLI cannot know which one owns the copy, and the
373
+ * user can.
374
+ * @param {object} options
375
+ * @param {string} options.entryPoint
376
+ * @returns {string} Text without a trailing newline
377
+ */
378
+ export function formatUnknownOwner({ entryPoint }) {
379
+ /** @type {Manager[]} */
380
+ const managers = ["pnpm", "npm"];
381
+ return [
382
+ "Cannot identify the package manager that owns this install.",
383
+ ` This copy runs from ${entryPoint}, which is not a pnpm or npm global layout.`,
384
+ " Update it with the manager that installed it:",
385
+ ...managers.map((manager) => ` ${display(manager, MANAGER_COMMANDS[manager].install)}`),
386
+ ].join("\n");
387
+ }
388
+
389
+ /**
390
+ * The message for an inferred owner its own listing does not confirm.
391
+ * @param {object} options
392
+ * @param {Owner} options.owner
393
+ * @param {string} options.reason
394
+ * @returns {string} Text without a trailing newline
395
+ */
396
+ export function formatUnconfirmedOwner({ owner, reason }) {
397
+ return [
398
+ `Cannot confirm that ${owner.manager} owns this install.`,
399
+ ` ${reason}`,
400
+ " Nothing was updated. If that manager does own it, run:",
401
+ ` ${display(owner.command, MANAGER_COMMANDS[owner.manager].install)}`,
402
+ ].join("\n");
403
+ }
404
+
405
+ /**
406
+ * Run one command with an argv array and collect its output. A command that
407
+ * cannot be spawned resolves with its error message in `stderr` instead of
408
+ * throwing, so every failure reaches the caller as a reportable result.
409
+ * @type {Run}
410
+ */
411
+ export async function execRun(command, args) {
412
+ try {
413
+ const { stdout, stderr } = await execFileAsync(command, args, { encoding: "utf-8", maxBuffer: 8 * 1024 * 1024 });
414
+ return { code: 0, stdout, stderr };
415
+ } catch (err) {
416
+ const failure = /** @type {NodeJS.ErrnoException & { stdout?: string, stderr?: string }} */ (err);
417
+ return {
418
+ code: typeof failure.code === "number" ? failure.code : 1,
419
+ stdout: failure.stdout ?? "",
420
+ stderr: failure.stderr ?? failure.message,
421
+ };
422
+ }
423
+ }
424
+
425
+ /**
426
+ * Run one command with the caller's stdio and resolve its exit code, so the
427
+ * refreshed CLI's own output reaches the terminal. A command that cannot be
428
+ * started at all resolves with the reason instead of a bare code: a spawn
429
+ * failure the caller cannot name is a failure the user cannot act on.
430
+ * @type {Spawn}
431
+ */
432
+ export function spawnInherit(command, args) {
433
+ return new Promise((resolve) => {
434
+ const child = spawn(command, args, { stdio: "inherit" });
435
+ child.on("error", (err) => resolve({ code: 1, error: err.message }));
436
+ child.on("close", (code) => resolve({ code: code ?? 1 }));
437
+ });
438
+ }
439
+
440
+ /**
441
+ * Everything `self-update` needs to run.
442
+ * @typedef {object} SelfUpdateOptions
443
+ * @property {string} entryPoint Resolved real path of the running CLI script
444
+ * @property {string} programName The command name the user typed
445
+ * @property {boolean} [check] Report and stop, writing nothing
446
+ * @property {Run} [run]
447
+ * @property {Spawn} [spawn]
448
+ * @property {(text: string) => void} [write] Stdout sink
449
+ * @property {(text: string) => void} [writeError] Stderr sink
450
+ */
451
+
452
+ /**
453
+ * Report the install's versions and, unless it is told to stop, update it
454
+ * through the confirmed owner and re-run the new binary's harness update.
455
+ *
456
+ * The order is the guarantee: nothing is spawned that writes until the owner is
457
+ * confirmed and the published version is readable, and the re-run only happens
458
+ * once the install has succeeded. Every failure path returns non-zero having
459
+ * printed what failed and what to run instead. The re-run is skipped in three
460
+ * cases — `check`, a failed install, and an install already at the published
461
+ * version — and otherwise its exit code is this command's exit code.
462
+ * @param {SelfUpdateOptions} options
463
+ * @returns {Promise<number>} The exit code
464
+ */
465
+ export async function runSelfUpdate({
466
+ entryPoint,
467
+ programName,
468
+ check = false,
469
+ run = execRun,
470
+ spawn: spawnCommand = spawnInherit,
471
+ write = writeLine,
472
+ writeError = writeErrorLine,
473
+ }) {
474
+ const manifestPath = join(dirname(entryPoint), "..", "package.json");
475
+ /** @type {string} */
476
+ let installedVersion;
477
+ try {
478
+ installedVersion = await readInstalledVersion(manifestPath);
479
+ } catch (err) {
480
+ writeError(`${/** @type {Error} */ (err).message}`);
481
+ return 1;
482
+ }
483
+
484
+ const owner = inferOwner(entryPoint);
485
+ if (owner === null) {
486
+ writeError(formatUnknownOwner({ entryPoint }));
487
+ return 1;
488
+ }
489
+
490
+ const confirmation = await confirmOwner({ owner, installedVersion, run });
491
+ if (!confirmation.ok) {
492
+ writeError(formatUnconfirmedOwner({ owner, reason: confirmation.reason ?? "the owner did not list this package." }));
493
+ return 1;
494
+ }
495
+
496
+ /** @type {string} */
497
+ let publishedVersion;
498
+ try {
499
+ publishedVersion = await readPublishedVersion({ owner, run });
500
+ } catch (err) {
501
+ // A registry error is its own failure: the installed copy is not known to be
502
+ // out of date, so no update is offered and none is run.
503
+ writeError(`Cannot read the published version of ${PACKAGE_NAME}.\n ${/** @type {Error} */ (err).message}\n Nothing was updated.`);
504
+ return 1;
505
+ }
506
+
507
+ write(formatSelfUpdateReport({ programName, installedVersion, publishedVersion, owner, check }));
508
+ if (check) return 0;
509
+
510
+ // Already at the published version: there is nothing to install, and a
511
+ // re-exec would hand over to the same version. The harness installs are still
512
+ // refreshed on demand by `update -a`.
513
+ if (installedVersion === publishedVersion) {
514
+ write(` ✓ ${PACKAGE_NAME} is already at ${publishedVersion}; nothing was installed.`);
515
+ write(` → refresh the harness installs with "${programName} update -a"`);
516
+ return 0;
517
+ }
518
+
519
+ const installArgs = MANAGER_COMMANDS[owner.manager].install;
520
+ const update = await run(owner.command, installArgs);
521
+ if (update.code !== 0) {
522
+ const detail = update.stderr.trim() || update.stdout.trim() || "no output";
523
+ writeError(
524
+ `The update failed: "${display(owner.command, installArgs)}" exited ${update.code}.\n ${detail.split("\n").join("\n ")}\nNothing was run after it.`,
525
+ );
526
+ return 1;
527
+ }
528
+ write(` ✓ ${display(owner.command, installArgs)}`);
529
+
530
+ // The running process is the old code, so the version now on disk comes from
531
+ // the new binary, and the harness installs are refreshed by it.
532
+ const bin = `${owner.prefix}/bin/${BIN_NAME}`;
533
+ const refreshed = await probeBinaryVersion({ bin, run });
534
+ if (refreshed === null) {
535
+ writeError(
536
+ `Cannot read the version the newly installed binary reports: "${display(bin, ["--version"])}" printed nothing usable.\n The refresh below is unaffected.`,
537
+ );
538
+ } else {
539
+ write(` cli ${installedVersion} → ${refreshed}`);
540
+ }
541
+
542
+ const refreshArgs = ["update", "-a"];
543
+ write(` → refreshing installed harnesses with ${display(bin, refreshArgs)}`);
544
+ const refresh = await spawnCommand(bin, refreshArgs);
545
+ if (refresh.error !== undefined) {
546
+ // The install succeeded, so the refresh is a separate, repeatable step the
547
+ // user can finish by hand. It must not look like a silent success.
548
+ writeError(
549
+ `Cannot run ${display(bin, refreshArgs)}: ${refresh.error}\n The CLI was updated; the harness installs were not refreshed.\n Run it yourself:\n ${display(bin, refreshArgs)}`,
550
+ );
551
+ }
552
+ return refresh.code;
553
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jaybeeuu/agent-cortex",
3
- "version": "2.7.0",
3
+ "version": "2.8.0",
4
4
  "description": "Personal PI package with custom agents, skills, and extensions",
5
5
  "keywords": [
6
6
  "pi-package"