@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.
- package/CHANGELOG.md +269 -0
- package/README.md +102 -8
- package/agents/README.md +2 -2
- package/agents/ralph/agent.md +19 -16
- package/agents/ralph/claude/wake.md +4 -4
- package/agents/ralph/pi/wake.md +15 -20
- 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/extensions/post-merge-watch/README.md +133 -0
- package/extensions/post-merge-watch/index.test.ts +247 -0
- package/extensions/post-merge-watch/index.ts +123 -0
- package/extensions/post-merge-watch/package.json +16 -0
- package/extensions/post-merge-watch/watch.test.ts +469 -0
- package/extensions/post-merge-watch/watch.ts +431 -0
- package/extensions/session-clear/README.md +74 -0
- package/extensions/session-clear/index.test.ts +524 -0
- package/extensions/session-clear/index.ts +159 -0
- package/extensions/session-clear/package.json +16 -0
- package/lib/cli.mjs +88 -1
- package/lib/harness-update.mjs +254 -0
- package/lib/harness-versions.mjs +36 -2
- package/lib/self-update.mjs +553 -0
- package/package.json +2 -1
- package/scripts/lib/docs.mjs +67 -0
- package/skills/engineering/implement-task/SKILL.md +124 -0
- package/skills/engineering/tdd/SKILL.md +2 -0
- package/skills/planning/create-task/REFERENCE.md +20 -3
- package/skills/planning/create-task/SKILL.md +1 -1
- package/skills/planning/create-task/pipeline.json +16 -0
- package/skills/planning/create-task/scripts/create-chores.mjs +51 -24
- package/skills/planning/create-task/templates/code.md +8 -3
- package/skills/planning/create-task/templates/document.md +8 -3
- package/skills/planning/create-task/templates/fix.md +8 -3
- package/skills/planning/create-task/templates/housekeeping.md +69 -0
- package/skills/planning/create-task/templates/post-merge.md +67 -0
- package/skills/planning/create-task/templates/review.md +8 -3
- package/skills/planning/create-task/templates/verify.md +8 -3
- package/skills/productivity/bd-tool/SKILL.md +55 -0
- package/skills/productivity/write-a-skill/SKILL.md +2 -2
- package/skills/review/refactor-skill/SKILL.md +2 -2
- package/skills/review/review-defects/EXAMPLES.md +122 -0
- package/skills/review/review-defects/SKILL.md +91 -0
- package/skills/style/style-tests/EXAMPLES.md +30 -0
- package/skills/style/style-tests/SKILL.md +10 -6
- package/skills/workflow/plan/SKILL.md +14 -3
- package/skills/workflow/ralph/REFERENCE.md +137 -30
- package/skills/workflow/ralph/SKILL.md +26 -16
- package/skills/workflow/ralph/scripts/post-merge-check.mjs +651 -0
- package/skills/workflow/run-pipeline-stage/SKILL.md +41 -23
- package/skills/workflow/run-pipeline-stage/playbooks/code.md +14 -8
- package/skills/workflow/run-pipeline-stage/playbooks/document.md +2 -2
- package/skills/workflow/run-pipeline-stage/playbooks/fix.md +2 -2
- package/skills/workflow/run-pipeline-stage/playbooks/housekeeping.md +58 -0
- package/skills/workflow/run-pipeline-stage/playbooks/post-merge.md +54 -0
- package/skills/workflow/run-pipeline-stage/playbooks/review.md +34 -3
- package/skills/workflow/run-pipeline-stage/playbooks/verify.md +2 -2
- package/skills/workflow/run-pipeline-stage/prompts/stage-runner.md +66 -10
- package/skills/workflow/run-pipeline-stage/scripts/recover-brief.mjs +805 -0
- package/skills/workflow/run-pipeline-stage/scripts/trail-entry.mjs +702 -0
- package/skills/workflow/using-agent-skills/SKILL.md +15 -7
- package/token-map.README.md +41 -4
- package/token-map.json +16 -5
|
@@ -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.
|
|
3
|
+
"version": "2.8.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
|
+
}
|