@jaybeeuu/agent-cortex 2.7.0 → 2.9.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 (87) hide show
  1. package/CHANGELOG.md +412 -0
  2. package/README.md +87 -8
  3. package/agents/README.md +4 -4
  4. package/agents/guppi/agent.md +162 -0
  5. package/agents/guppi/claude/frontmatter.json +6 -0
  6. package/agents/guppi/pi/frontmatter.json +6 -0
  7. package/agents/plan/agent.md +6 -0
  8. package/agents/ralph/agent.md +17 -16
  9. package/agents/ralph/claude/wake.md +4 -4
  10. package/agents/ralph/pi/wake.md +15 -20
  11. package/agents/ralph-plan/agent.md +6 -0
  12. package/bin/agent-cortex.mjs +20 -1
  13. package/bin/installers/claude.mjs +1 -20
  14. package/bin/installers/pi.mjs +8 -2
  15. package/extensions/interrupt-bookmark/README.md +129 -0
  16. package/extensions/interrupt-bookmark/index.test.ts +717 -0
  17. package/extensions/interrupt-bookmark/index.ts +354 -0
  18. package/extensions/interrupt-bookmark/package.json +20 -0
  19. package/extensions/post-merge-watch/README.md +133 -0
  20. package/extensions/post-merge-watch/index.test.ts +247 -0
  21. package/extensions/post-merge-watch/index.ts +123 -0
  22. package/extensions/post-merge-watch/package.json +16 -0
  23. package/extensions/post-merge-watch/watch.test.ts +469 -0
  24. package/extensions/post-merge-watch/watch.ts +431 -0
  25. package/extensions/session-clear/README.md +74 -0
  26. package/extensions/session-clear/index.test.ts +524 -0
  27. package/extensions/session-clear/index.ts +159 -0
  28. package/extensions/session-clear/package.json +16 -0
  29. package/extensions/skill-stats/README.md +84 -3
  30. package/extensions/skill-stats/index.ts +62 -3
  31. package/extensions/skill-stats/package.json +1 -1
  32. package/extensions/skill-stats/rollup.test.ts +643 -0
  33. package/extensions/skill-stats/rollup.ts +415 -0
  34. package/extensions/skill-stats/timeline.ts +101 -0
  35. package/lib/cli.mjs +42 -1
  36. package/lib/self-update.mjs +553 -0
  37. package/package.json +1 -1
  38. package/scripts/lib/manifest.mjs +29 -0
  39. package/skills/engineering/implement-task/SKILL.md +124 -0
  40. package/skills/engineering/tdd/SKILL.md +2 -0
  41. package/skills/planning/create-task/REFERENCE.md +15 -6
  42. package/skills/planning/create-task/SKILL.md +1 -1
  43. package/skills/planning/create-task/pipeline.json +8 -0
  44. package/skills/planning/create-task/scripts/create-chores.mjs +27 -18
  45. package/skills/planning/create-task/templates/code.md +8 -3
  46. package/skills/planning/create-task/templates/document.md +8 -3
  47. package/skills/planning/create-task/templates/fix.md +8 -3
  48. package/skills/planning/create-task/templates/housekeeping.md +8 -4
  49. package/skills/planning/create-task/templates/post-merge.md +67 -0
  50. package/skills/planning/create-task/templates/review.md +8 -3
  51. package/skills/planning/create-task/templates/verify.md +8 -3
  52. package/skills/productivity/bd-tool/SKILL.md +55 -0
  53. package/skills/review/review-defects/EXAMPLES.md +198 -0
  54. package/skills/review/review-defects/SKILL.md +121 -0
  55. package/skills/review/review-report/EXAMPLES.md +114 -0
  56. package/skills/review/review-report/SKILL.md +138 -0
  57. package/skills/review/review-report/scripts/report.mjs +1032 -0
  58. package/skills/style/style-tests/EXAMPLES.md +30 -0
  59. package/skills/style/style-tests/SKILL.md +10 -6
  60. package/skills/workflow/hitl-queue/SKILL.md +134 -0
  61. package/skills/workflow/hitl-queue/scripts/queue.mjs +626 -0
  62. package/skills/workflow/plan/SKILL.md +27 -3
  63. package/skills/workflow/prioritise-work/CHALLENGE.md +139 -0
  64. package/skills/workflow/prioritise-work/SKILL.md +150 -0
  65. package/skills/workflow/prioritise-work/scripts/challenge.mjs +505 -0
  66. package/skills/workflow/prioritise-work/scripts/strategy.mjs +566 -0
  67. package/skills/workflow/prioritise-work/templates/strategy.md +61 -0
  68. package/skills/workflow/ralph/REFERENCE.md +124 -27
  69. package/skills/workflow/ralph/SKILL.md +24 -15
  70. package/skills/workflow/ralph/scripts/post-merge-check.mjs +651 -0
  71. package/skills/workflow/run-pipeline-stage/SKILL.md +37 -22
  72. package/skills/workflow/run-pipeline-stage/playbooks/code.md +14 -8
  73. package/skills/workflow/run-pipeline-stage/playbooks/document.md +2 -2
  74. package/skills/workflow/run-pipeline-stage/playbooks/fix.md +2 -2
  75. package/skills/workflow/run-pipeline-stage/playbooks/housekeeping.md +1 -1
  76. package/skills/workflow/run-pipeline-stage/playbooks/post-merge.md +54 -0
  77. package/skills/workflow/run-pipeline-stage/playbooks/review.md +37 -3
  78. package/skills/workflow/run-pipeline-stage/playbooks/verify.md +2 -2
  79. package/skills/workflow/run-pipeline-stage/prompts/stage-runner.md +55 -11
  80. package/skills/workflow/run-pipeline-stage/scripts/recover-brief.mjs +805 -0
  81. package/skills/workflow/run-pipeline-stage/scripts/trail-entry.mjs +702 -0
  82. package/skills/workflow/score-bead/FORMAT.md +73 -0
  83. package/skills/workflow/score-bead/SKILL.md +112 -0
  84. package/skills/workflow/score-bead/scripts/rice.mjs +664 -0
  85. package/skills/workflow/using-agent-skills/SKILL.md +21 -8
  86. package/token-map.README.md +27 -0
  87. package/token-map.json +16 -4
@@ -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.9.0",
4
4
  "description": "Personal PI package with custom agents, skills, and extensions",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -0,0 +1,29 @@
1
+ // Shared manifest helpers for the install-time generators.
2
+ //
3
+ // Both installs declare where this package came from, in their own manifest: the pi package's
4
+ // `package.json` (`repository`, npm's field) and the Claude plugin's `.claude-plugin/plugin.json`
5
+ // (`repository`, which Claude accepts only as a string). Both read the same value out of this
6
+ // checkout's `package.json`, so one normaliser decides it in both places rather than two copies
7
+ // drifting apart.
8
+ //
9
+ // A generated manifest is also what the shipped `review-report` skill reads to find its feedback
10
+ // repository, so the field is behaviour, not decoration: dropping it makes `--file` fail on every
11
+ // install of the artefact.
12
+
13
+ /**
14
+ * Normalise this package's `repository` field into the string a generated manifest declares.
15
+ *
16
+ * npm permits a string or an object (`{ type, url, directory }`); a value that is neither is
17
+ * dropped rather than emitted as junk, and the installer's caller keeps walking.
18
+ * @param {unknown} repository parsed package.json `repository` value
19
+ * @returns {string | undefined}
20
+ */
21
+ export function normaliseRepository(repository) {
22
+ if (typeof repository === "string") return repository.trim() === "" ? undefined : repository;
23
+ if (repository === null || typeof repository !== "object") return undefined;
24
+ const { url, directory } = /** @type {{ url?: unknown, directory?: unknown }} */ (repository);
25
+ for (const candidate of [url, directory]) {
26
+ if (typeof candidate === "string" && candidate.trim() !== "") return candidate;
27
+ }
28
+ return undefined;
29
+ }