@jaybeeuu/agent-cortex 2.6.0 → 2.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,212 @@
1
+ # Skill Anatomy
2
+
3
+ This document is the **canonical skill anatomy** for `agent-cortex` skills.
4
+ Use it as the source of truth for `SKILL.md` structure, language, and review quality gates.
5
+
6
+ ## Front-matter spec
7
+
8
+ Required fields:
9
+ - `name`: kebab-case skill identifier.
10
+ - `description`: max 1024 characters.
11
+
12
+ Frontmatter is machine-validated: `pnpm lint` runs `check:skills`, which fails on a
13
+ frontmatter block that would make pi silently drop the skill.
14
+
15
+ Optional fields:
16
+ - `argument-hint`: input prompt shown to users at invocation.
17
+ - `disable-model-invocation`: hides the skill from automatic model routing; explicit
18
+ `/skill-name` invocation still works. Set it when the danger is a fuzzy description
19
+ match, e.g. a skill whose steps are destructive. Preserved and honoured by pi and
20
+ Claude Code. See `docs/research/disable-model-invocation.md`.
21
+
22
+ ## Description format rules
23
+
24
+ - Target ≤200 characters even though max is 1024.
25
+ - Write in third-person declarative voice.
26
+ - Sentence 1: what the skill does.
27
+ - Sentence 2: starts with `Use when ...`.
28
+ - Include 2-4 natural user trigger phrases (quoted) such as "diagnose this" or "something is broken".
29
+ - Prefer user vocabulary over internal jargon.
30
+
31
+ ## Voice and tone rules
32
+
33
+ - Write the body in imperative second-person: "Run X", "Check Y", "Report Z".
34
+ - Use positive framing; reserve "do not" only for hard safety/correctness boundaries.
35
+ - Add one rationale sentence for non-obvious rules so behavior generalizes in novel cases.
36
+ - Avoid ALL-CAPS emphasis.
37
+
38
+ ## Tool references
39
+
40
+ Reference agent tools by token, never by a hardcoded harness-specific name such as `bash`,
41
+ `task`, or `read_agent`. A tool token is the tool's canonical authoring name prefixed with
42
+ `TOOL:` and wrapped in doubled curly braces — two `{` before it and two `}` after it. The
43
+ wrapper is literal syntax: installers match it and substitute the per-harness name from
44
+ `token-map.json` at install time. Add any new tool to the map's `tools` section in the same
45
+ change; unknown tokens are hard errors. See `token-map.README.md` for the full authoring rules.
46
+
47
+ ## Required sections
48
+
49
+ Every skill must include:
50
+ 1. `## When to use`
51
+ 2. `## Workflow` (numbered steps)
52
+ 3. `## Verification checklist` (`- [ ]` items)
53
+
54
+ ## Optional sections
55
+
56
+ For each optional section, define **what it is**, include an **example snippet**, and state **when to include** and **when to omit**.
57
+
58
+ | Section | What it is | Example snippet | When to include | When to omit |
59
+ |---|---|---|---|---|
60
+ | `When NOT to use` | Explicit out-of-scope invocation boundaries | `- When NOT to use: single-line typo fixes with no process risk.` | Skills with similar neighbors where invocation precision matters | Truly unique one-purpose utility skills |
61
+ | `Red Flags` | Detectable anti-patterns the agent should self-correct during execution | `- Red flag: skipping boundary validation to save time.` | Discipline-enforcement skills (security, TDD, review) | Simple utility wrappers |
62
+ | `Common Rationalizations` | Excuses models use to skip process with rebuttals | `| "This is small" | Small changes still need invariants checked. |` | Process-critical skills where shortcut pressure is common | Mechanical one-step skills |
63
+ | `Philosophy / rationale` | Why the method exists and what trade-offs it protects | `- We front-load validation because downstream rollback cost is higher.` | Opinionated skills expected to handle novel variants | Purely mechanical instructions |
64
+ | `Phase-gate checklists` | Mid-workflow gates that must pass before proceeding | `- [ ] Phase 1 complete: baseline tests captured.` | Multi-phase workflows (3+ distinct phases) | Linear single-phase tasks |
65
+ | `See Also` | Explicit handoff to another skill for a real sub-task: a relative link to its `SKILL.md` plus the reason to load it | `- [review-security](../../review/review-security/SKILL.md) — run before committing.` | Another skill owns a sub-domain better than restating it | Self-contained skills |
66
+ | `Examples` | Full worked sample of expected outcome/output | `- Input: ... Output: ...` | Any non-trivial output contract | Trivially self-evident skills |
67
+ | `Workflow diagrams` | Inline ASCII diagram showing step flow and decision points | Fenced block of `[Box]`, `|`, `-`, `>`, `v` arrows, e.g. `[Start] --> [Validate] --> [Run]` | Multi-step or branching workflows where a glanceable flow improves comprehension | Simple linear workflows already clear from numbered steps |
68
+
69
+ `## See Also` entries are one-line handoffs: a relative link to the related skill's `SKILL.md`, an
70
+ em-dash, and the reason a reader of this skill would load that one. A bare skill name leaves the
71
+ reader guessing whether the link matters; the reason is what turns a link list into routing.
72
+
73
+ Write the path against the authored tree (`../../<group>/<skill>/SKILL.md` from a skill directory).
74
+ The Claude Code installer flattens skills and rewrites these targets at copy time, so a hand-written
75
+ link that resolves here resolves in both install layouts. List only genuine handoffs — inline calls
76
+ inside `## Workflow` already cover skills used as a step, and `using-agent-skills` already maps the
77
+ whole corpus, so a duplicate entry adds noise.
78
+
79
+ ## Workflow diagram style guide
80
+
81
+ When a skill includes a workflow diagram, follow these rules so it renders
82
+ reliably in any CLI:
83
+
84
+ - **ASCII only** — never Mermaid, images, or unicode box-drawing characters.
85
+ Keep to `|`, `-`, `>`, `+`, and `v`.
86
+ - **Fenced block** — wrap the diagram in a fenced code block so monospace is
87
+ preserved.
88
+ - **At most 80 characters per line** — narrow enough for terminal-width readers.
89
+ - **Short box labels** — imperative verbs or nouns (e.g. `[Validate scope]`),
90
+ never full sentences.
91
+ - **Read left-to-right, top-down** — prefer one branch level over dense joins.
92
+ - **Mirror the numbered workflow** — the diagram is a glanceable summary of the
93
+ `## Workflow` steps, never a source of truth that contradicts them.
94
+
95
+ ## Section ordering
96
+
97
+ ### When to use
98
+
99
+ State concrete invocation triggers using natural language phrases users actually type.
100
+
101
+ ### When NOT to use
102
+
103
+ Place this section immediately after `When to use` whenever present.
104
+
105
+ ### Workflow
106
+
107
+ Provide ordered, deterministic steps with observable outcomes.
108
+
109
+ ### Verification checklist
110
+
111
+ Provide completion gates that are testable and stage-scoped.
112
+
113
+ ## File layout and progressive disclosure
114
+
115
+ - Target ~150 lines for `SKILL.md`.
116
+ - Split long docs into purpose-named support files:
117
+ - `EXAMPLES.md` for worked examples.
118
+ - `FORMAT.md` for schemas/templates.
119
+ - `REFERENCE.md` for rare edge cases.
120
+ - Use slash-command references when another skill should be invoked.
121
+
122
+ ## Acceptance criteria checklist
123
+
124
+ A skill passes anatomy review when:
125
+ - [ ] Front matter uses required fields and valid description format.
126
+ - [ ] Tool references use doubled-brace `TOOL:` tokens, not hardcoded tool names.
127
+ - [ ] Required sections exist and follow canonical ordering.
128
+ - [ ] Optional sections (if present) include what/when/example guidance.
129
+ - [ ] `See Also` entries link the target `SKILL.md` relatively and state why to load it.
130
+ - [ ] Workflow diagrams (if present) are ASCII-only and follow the workflow diagram style guide.
131
+ - [ ] Verification checklist contains concrete, machine-checkable items.
132
+ - [ ] Supporting docs are split by purpose when `SKILL.md` would exceed ~150 lines.
133
+
134
+ ## Annotated template
135
+
136
+ ```md
137
+ ---
138
+ name: skill-name
139
+ description: This skill performs a specific capability. Use when a user asks for a matching workflow such as "triage this", "investigate this", or "prepare a handoff".
140
+ argument-hint: Optional prompt shown to users when extra input is needed.
141
+ disable-model-invocation: false
142
+ ---
143
+
144
+ # Skill Name
145
+
146
+ ## When to use
147
+
148
+ - Trigger phrases users actually say, e.g. "triage this failure".
149
+ - Include 2-4 concrete patterns.
150
+
151
+ ## When NOT to use
152
+
153
+ - Out-of-scope cases that should route elsewhere.
154
+
155
+ ## Workflow
156
+
157
+ 1. Gather required context and validate scope boundaries.
158
+ 2. Reject early with the reason when validation fails; otherwise continue.
159
+ 3. Execute deterministic steps in order, recording key decisions.
160
+ 4. Produce the required output artifact or terminal response.
161
+
162
+ Workflow at a glance (include only when the flow branches):
163
+
164
+ [Trigger]
165
+ |
166
+ v
167
+ [Gather context]
168
+ |
169
+ v
170
+ [Validate scope] --(invalid)--> [Reject early]
171
+ |
172
+ (valid)
173
+ v
174
+ [Run deterministic steps]
175
+ |
176
+ v
177
+ [Produce output artifact]
178
+
179
+ ## Red Flags
180
+
181
+ - Red flag: skipping validation of unknown external input.
182
+
183
+ ## Common Rationalizations
184
+
185
+ | Rationalization | Rebuttal |
186
+ |---|---|
187
+ | "This is too small for process" | Process protects against regressions regardless of size. |
188
+
189
+ ## Philosophy / rationale
190
+
191
+ - We optimize for explicit boundaries because they reduce rework and improve reliability.
192
+
193
+ ## Phase-gate checklist
194
+
195
+ - [ ] Phase 1 complete and baseline checks captured.
196
+ - [ ] Phase 2 complete and contract validation passed.
197
+
198
+ ## See Also
199
+
200
+ - [review-security](../../review/review-security/SKILL.md) — run before committing any externally exposed change.
201
+
202
+ ## Examples
203
+
204
+ Input: "Investigate this flaky suite"
205
+ Output: concise root-cause summary, failing tests list, and next action.
206
+
207
+ ## Verification checklist
208
+
209
+ - [ ] Output matches requested format.
210
+ - [ ] Required tests/lints were run and results recorded.
211
+ - [ ] No unrelated files were changed.
212
+ ```
package/lib/cli.mjs CHANGED
@@ -4,10 +4,13 @@ import { basename } from "node:path";
4
4
  * Result of parsing the CLI argv. Every branch returns `command`; the rest are
5
5
  * populated only for the commands that accept them.
6
6
  * @typedef {object} ParsedArgs
7
- * @property {"help" | "version" | "install" | "migrate" | "unknown"} command
7
+ * @property {"help" | "version" | "install" | "update" | "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
10
  * @property {string | null} [harness]
11
+ * @property {string[]} [harnesses] `update`: the harnesses named on the command line
12
+ * @property {boolean} [all] `update -a, --all`: every harness that is not at the CLI version
13
+ * @property {boolean} [force] `update -f, --force`: re-run every installer regardless of version
11
14
  * @property {string} [name]
12
15
  * @property {string} [optionError]
13
16
  * @property {boolean} [dryRun]
@@ -125,6 +128,35 @@ export function parseArgs(argv, programName = DEFAULT_PROGRAM_NAME) {
125
128
  return { command: "install", harness, ...options };
126
129
  }
127
130
 
131
+ if (command === "update") {
132
+ // `update <harness>...` refreshes exactly those harnesses; `-a` refreshes
133
+ // every harness that is not at the CLI version and `-f` forces a re-run.
134
+ // `-f` alone has no meaning, so it is rejected rather than ignored.
135
+ /** @type {string[]} */
136
+ const harnesses = [];
137
+ let all = false;
138
+ let force = false;
139
+ for (const arg of rest) {
140
+ if (arg === "--help" || arg === "-h") return { command: "help" };
141
+ if (arg === "-a" || arg === "--all") {
142
+ all = true;
143
+ continue;
144
+ }
145
+ if (arg === "-f" || arg === "--force") {
146
+ force = true;
147
+ continue;
148
+ }
149
+ if (arg.startsWith("-")) {
150
+ return { command: "update", optionError: `Unknown option "${arg}". Run "${programName} --help" for usage.` };
151
+ }
152
+ harnesses.push(arg);
153
+ }
154
+ if (force && !all) {
155
+ return { command: "update", optionError: `"--force" requires "--all". Run "${programName} --help" for usage.` };
156
+ }
157
+ return { command: "update", harnesses, all, force };
158
+ }
159
+
128
160
  if (command === "migrate") {
129
161
  // Dry run is the default, so `--apply` is the only state-changing flag;
130
162
  // `--dry-run` stays accepted for scripts that pass it explicitly. `--dir`
@@ -239,6 +271,20 @@ Commands:
239
271
  cannot register the staged package
240
272
  (missing from PATH); default warns and
241
273
  prints the manual commands
274
+ update [<harness>...] Refresh installed harnesses by re-running their
275
+ installers. With no argument the command lists every
276
+ harness with its installed version and the CLI version,
277
+ then asks which to refresh (a non-interactive shell
278
+ prints the same table and the flags below, then exits
279
+ non-zero without blocking).
280
+ Options:
281
+ <harness>... Refresh exactly these harnesses, no prompt
282
+ -a, --all Refresh every harness that is not at the
283
+ CLI version (not installed, stale, or
284
+ ahead); ignored when harnesses are named
285
+ -f, --force With --all: re-run every installer even
286
+ when the version already matches
287
+ --help, -h Show this help text
242
288
  migrate Migrate the current beads workspace to the latest
243
289
  agent-cortex migration version, applying versioned steps
244
290
  in order (repo-agnostic — works in any beads workspace)
@@ -0,0 +1,254 @@
1
+ // The selection and reporting half of `ctx update`: which harnesses a refresh
2
+ // covers, how the table and the report read, and the interactive prompt.
3
+ //
4
+ // Delegation is not here. The caller injects `refresh(harness)`, which the CLI
5
+ // wires to the harness installers — so update-vs-install stays the installer's
6
+ // decision and this module never writes an artifact itself.
7
+
8
+ import { homedir } from "node:os";
9
+ import { collectHarnessStatus } from "./harness-versions.mjs";
10
+
11
+ /** @typedef {import("./harness-versions.mjs").HarnessStatus} HarnessStatus */
12
+
13
+ const NOT_INSTALLED = "not installed";
14
+ const UNKNOWN = "unknown";
15
+
16
+ /**
17
+ * One refreshed harness in the update report.
18
+ * @typedef {object} UpdateEntry
19
+ * @property {string} harness
20
+ * @property {string} previous The version before the refresh, or "not installed"
21
+ * @property {string} next The version the installer recorded afterwards
22
+ * @property {string[]} artifacts What the installer wrote
23
+ */
24
+
25
+ /**
26
+ * The harnesses an update covers, and the ones it leaves alone.
27
+ * @typedef {object} UpdatePlan
28
+ * @property {HarnessStatus[]} targets
29
+ * @property {HarnessStatus[]} skipped
30
+ * @property {string[]} unknown Names that are not supported harnesses
31
+ */
32
+
33
+ /**
34
+ * Decide which harnesses an update refreshes. An explicit name is always a
35
+ * target (the user asked for that installer to re-run); `--all` targets every
36
+ * harness that is not at the CLI version; `--all --force` targets every harness.
37
+ * @param {object} options
38
+ * @param {HarnessStatus[]} options.statuses One entry per supported harness
39
+ * @param {string[]} [options.harnesses] Names from the command line or the prompt
40
+ * @param {boolean} [options.all] `ctx update -a`
41
+ * @param {boolean} [options.force] `ctx update -a -f`
42
+ * @returns {UpdatePlan}
43
+ */
44
+ export function selectHarnesses({ statuses, harnesses = [], all = false, force = false }) {
45
+ /** @type {string[]} */
46
+ const unknown = [];
47
+ /** @type {HarnessStatus[]} */
48
+ let targets;
49
+ if (harnesses.length > 0) {
50
+ targets = [];
51
+ for (const name of harnesses) {
52
+ const status = statuses.find((entry) => entry.harness === name);
53
+ if (status === undefined) {
54
+ if (!unknown.includes(name)) unknown.push(name);
55
+ continue;
56
+ }
57
+ if (!targets.includes(status)) targets.push(status);
58
+ }
59
+ } else if (all && force) {
60
+ targets = [...statuses];
61
+ } else if (all) {
62
+ targets = statuses.filter((entry) => entry.staleness !== "current");
63
+ } else {
64
+ targets = [];
65
+ }
66
+ const skipped = statuses.filter((entry) => !targets.includes(entry));
67
+ return { targets, skipped, unknown };
68
+ }
69
+
70
+ /**
71
+ * Count a noun for the report: `1 file`, `4 files`.
72
+ * @param {number} amount
73
+ * @param {string} noun Singular form
74
+ * @returns {string}
75
+ */
76
+ function count(amount, noun) {
77
+ return `${amount} ${noun}${amount === 1 ? "" : "s"}`;
78
+ }
79
+
80
+ /**
81
+ * What a pi refresh wrote, for the update report.
82
+ * @param {import("../bin/installers/pi.mjs").InstallPiResult} result
83
+ * @returns {string[]}
84
+ */
85
+ export function describePiArtifacts(result) {
86
+ return [
87
+ `agents (${count(result.agents.length, "file")})`,
88
+ `skills (${count(result.skills.skills, "skill")}, ${count(result.skills.md, "markdown file")})`,
89
+ `extensions (${count(result.extensions.files, "file")})`,
90
+ `bundled pi packages (${result.packages.bundled.length > 0 ? result.packages.bundled.join(", ") : "none"})`,
91
+ `package manifest (version ${result.manifest.manifest.version})`,
92
+ ];
93
+ }
94
+
95
+ /**
96
+ * What a claude refresh wrote, for the update report.
97
+ * @param {import("../bin/installers/claude.mjs").InstallClaudeResult} result
98
+ * @returns {string[]}
99
+ */
100
+ export function describeClaudeArtifacts(result) {
101
+ return [
102
+ `agents (${count(result.agents.length, "file")})`,
103
+ `skills (${count(result.skills.names.length, "skill")}, ${count(result.skills.md, "markdown file")})`,
104
+ `hooks (${count(result.hookFiles.length, "file")})`,
105
+ `hand-authored extras (${count(result.handAuthored.length, "file")})`,
106
+ `plugin manifest (version ${result.pluginVersion})`,
107
+ `settings (${result.settings?.action ?? "not written"})`,
108
+ `instructions (${result.instructions?.action ?? "not written"})`,
109
+ ];
110
+ }
111
+
112
+ /**
113
+ * Refresh every target through the injected installer and report the result.
114
+ * The new version is read back from the fingerprint the installer wrote, never
115
+ * assumed from the CLI version, so a failed materialisation shows as unknown.
116
+ * @param {object} options
117
+ * @param {string} options.cli The running CLI version
118
+ * @param {string} [options.home] Home directory holding the install roots
119
+ * @param {HarnessStatus[]} options.targets
120
+ * @param {(harness: string) => Promise<string[]>} options.refresh Runs one harness installer
121
+ * @returns {Promise<UpdateEntry[]>}
122
+ */
123
+ export async function runUpdate({ cli, home = homedir(), targets, refresh }) {
124
+ /** @type {{ harness: string, previous: string, artifacts: string[] }[]} */
125
+ const refreshed = [];
126
+ for (const target of targets) {
127
+ const artifacts = await refresh(target.harness);
128
+ refreshed.push({ harness: target.harness, previous: target.installed ?? NOT_INSTALLED, artifacts });
129
+ }
130
+ if (refreshed.length === 0) return [];
131
+ const statuses = await collectHarnessStatus({ cli, home });
132
+ return refreshed.map((entry) => {
133
+ const status = statuses.find((candidate) => candidate.harness === entry.harness);
134
+ return { ...entry, next: status?.installed ?? UNKNOWN };
135
+ });
136
+ }
137
+
138
+ /**
139
+ * Render the harness table: one row per supported harness, so an absent
140
+ * install is visible instead of silently missing.
141
+ * @param {HarnessStatus[]} statuses
142
+ * @returns {string} Text without a trailing newline
143
+ */
144
+ export function formatHarnessTable(statuses) {
145
+ /** @type {string[][]} */
146
+ const rows = [["harness", "installed", "cli", "status"]];
147
+ for (const status of statuses) {
148
+ rows.push([status.harness, status.installed ?? NOT_INSTALLED, status.cli, status.staleness]);
149
+ }
150
+ const widths = rows[0].map((_, column) => Math.max(...rows.map((row) => row[column].length)));
151
+ return rows
152
+ .map((row) => row.map((cell, column) => (column === widths.length - 1 ? cell : cell.padEnd(widths[column]))).join(" "))
153
+ .join("\n");
154
+ }
155
+
156
+ /**
157
+ * The instructions a non-interactive `ctx update` prints instead of prompting.
158
+ * @param {string} programName The resolved command name
159
+ * @returns {string} Text without a trailing newline
160
+ */
161
+ export function formatUpdateFlags(programName) {
162
+ return [
163
+ "No harness selected, and this shell is not interactive. Pass a selection:",
164
+ ` ${programName} update <harness>... refresh the named harnesses`,
165
+ ` ${programName} update -a refresh every harness that is not at the CLI version`,
166
+ ` ${programName} update -a -f re-run every harness installer regardless of version`,
167
+ ].join("\n");
168
+ }
169
+
170
+ /**
171
+ * Render the outcome of an update. Only skipped harnesses that are already at
172
+ * the CLI version are listed; one left alone for another reason (a stale
173
+ * harness beside an explicit selection) is not "already at" anything.
174
+ * @param {object} options
175
+ * @param {string} options.cli The running CLI version
176
+ * @param {UpdateEntry[]} options.entries
177
+ * @param {HarnessStatus[]} [options.skipped]
178
+ * @returns {string} Text without a trailing newline
179
+ */
180
+ export function formatUpdateReport({ cli, entries, skipped = [] }) {
181
+ const current = skipped.filter((entry) => entry.staleness === "current");
182
+ /** @type {string[]} */
183
+ const lines = [];
184
+ for (const entry of entries) {
185
+ lines.push(`${entry.harness} ${entry.previous} → ${entry.next}`);
186
+ for (const artifact of entry.artifacts) {
187
+ lines.push(` ✓ ${artifact}`);
188
+ }
189
+ }
190
+ if (entries.length === 0) {
191
+ lines.push(`Nothing to update — every harness is at ${cli}.`);
192
+ } else if (current.length > 0) {
193
+ lines.push(`Skipped (already at ${cli}): ${current.map((entry) => entry.harness).join(", ")}`);
194
+ }
195
+ return lines.join("\n");
196
+ }
197
+
198
+ /**
199
+ * Turn a prompt answer into harness names: row numbers, `a` for every row, or
200
+ * blank for none.
201
+ * @param {string} answer
202
+ * @param {HarnessStatus[]} statuses The rows the prompt listed, in order
203
+ * @returns {string[]}
204
+ */
205
+ export function parseHarnessSelection(answer, statuses) {
206
+ const trimmed = answer.trim();
207
+ if (trimmed === "") return [];
208
+ if (/^(a|all)$/i.test(trimmed)) return statuses.map((status) => status.harness);
209
+ /** @type {string[]} */
210
+ const chosen = [];
211
+ for (const token of trimmed.split(/[,\s]+/)) {
212
+ const index = Number(token);
213
+ const status = Number.isInteger(index) ? statuses[index - 1] : undefined;
214
+ if (status === undefined) {
215
+ throw new Error(`Invalid selection "${token}". Enter row numbers 1-${statuses.length}, "a", or blank to cancel.`);
216
+ }
217
+ if (!chosen.includes(status.harness)) chosen.push(status.harness);
218
+ }
219
+ return chosen;
220
+ }
221
+
222
+ /**
223
+ * Ask on an interactive terminal which harnesses to refresh. The table is the
224
+ * list the user picks from; a closed input stream cancels the selection.
225
+ * @param {object} options
226
+ * @param {HarnessStatus[]} options.statuses
227
+ * @param {NodeJS.ReadableStream} [options.input]
228
+ * @param {NodeJS.WritableStream} [options.output]
229
+ * @returns {Promise<string[]>}
230
+ */
231
+ export async function promptHarnessSelection({ statuses, input = process.stdin, output = process.stdout }) {
232
+ output.write(`${formatHarnessTable(statuses)}\n\n`);
233
+ const answer = await readLine(input, output, `Select harnesses (row numbers, "a" for all, blank to cancel): `);
234
+ return parseHarnessSelection(answer, statuses);
235
+ }
236
+
237
+ /**
238
+ * Read one line, prompting on `output` first. A stream that ends without a
239
+ * newline returns what it carried, so a closed stdin cancels rather than hangs.
240
+ * @param {NodeJS.ReadableStream} input
241
+ * @param {NodeJS.WritableStream} output
242
+ * @param {string} question
243
+ * @returns {Promise<string>}
244
+ */
245
+ async function readLine(input, output, question) {
246
+ output.write(question);
247
+ let buffered = "";
248
+ for await (const chunk of input) {
249
+ buffered += String(chunk);
250
+ const newline = buffered.indexOf("\n");
251
+ if (newline !== -1) return buffered.slice(0, newline);
252
+ }
253
+ return buffered;
254
+ }
@@ -29,8 +29,9 @@ const HARNESS_FINGERPRINTS = [
29
29
  /**
30
30
  * How an installed copy relates to the running CLI. `unknown` covers both a
31
31
  * fingerprint that cannot be read and a version that is not a plain release —
32
- * an unreadable install is never reported as `current`.
33
- * @typedef {"current" | "stale" | "ahead" | "unknown"} Staleness
32
+ * an unreadable install is never reported as `current`. `missing` means the
33
+ * harness has no artifact root at all: it was never installed.
34
+ * @typedef {"current" | "stale" | "ahead" | "unknown" | "missing"} Staleness
34
35
  */
35
36
 
36
37
  /**
@@ -107,6 +108,16 @@ async function readInstalledVersion(home, fingerprint) {
107
108
  }
108
109
  }
109
110
 
111
+ /**
112
+ * One harness in the update table: unlike a report entry, every supported
113
+ * harness appears, so an absent install shows as `installed: null` / `missing`.
114
+ * @typedef {object} HarnessStatus
115
+ * @property {string} harness
116
+ * @property {string | null} installed The installed version, or null when the harness is not installed
117
+ * @property {string} cli The running CLI version
118
+ * @property {Staleness} staleness
119
+ */
120
+
110
121
  /**
111
122
  * Report the running CLI version and the installed version of every harness
112
123
  * whose artifact root exists.
@@ -130,6 +141,29 @@ export async function collectVersionReport({ cli, home = homedir() }) {
130
141
  return { cli, harnesses };
131
142
  }
132
143
 
144
+ /**
145
+ * Read the status of every harness, including the ones that are not installed:
146
+ * `ctx update` selects from this full list, so a harness with no artifact root
147
+ * is reported as missing rather than omitted.
148
+ * @param {{ cli: string, home?: string }} options `home` defaults to the real
149
+ * home directory; tests pass a fixture directory
150
+ * @returns {Promise<HarnessStatus[]>}
151
+ */
152
+ export async function collectHarnessStatus({ cli, home = homedir() }) {
153
+ /** @type {HarnessStatus[]} */
154
+ const statuses = [];
155
+ for (const fingerprint of HARNESS_FINGERPRINTS) {
156
+ const { installed, version } = await readInstalledVersion(home, fingerprint);
157
+ statuses.push({
158
+ harness: fingerprint.harness,
159
+ installed: version,
160
+ cli,
161
+ staleness: installed ? stalenessOf(version, cli) : "missing",
162
+ });
163
+ }
164
+ return statuses;
165
+ }
166
+
133
167
  /**
134
168
  * Render the report as the default, human-readable output: the CLI version on
135
169
  * the first line, then one line per installed harness. A harness whose version
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jaybeeuu/agent-cortex",
3
- "version": "2.6.0",
3
+ "version": "2.7.0",
4
4
  "description": "Personal PI package with custom agents, skills, and extensions",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -21,6 +21,7 @@
21
21
  "scripts/",
22
22
  "skills/",
23
23
  "agents/",
24
+ "docs/skills/skill-anatomy.md",
24
25
  "extensions/*.ts",
25
26
  "extensions/lib/",
26
27
  "extensions/*/*.ts",
@@ -0,0 +1,67 @@
1
+ // Documentation a staged install ships.
2
+ //
3
+ // Skills are installed onto machines that have no copy of this checkout, so a
4
+ // skill that reads a repository document — write-a-skill and refactor-skill both
5
+ // read docs/skills/skill-anatomy.md — needs that document inside the install.
6
+ // The list is derived from token-map.json rather than declared again here: a
7
+ // `paths` entry whose relative path sits under `docs/` is a document skills
8
+ // reference by install-relative path, so the token map stays the single source
9
+ // of where the install puts it.
10
+ //
11
+ // The copies are verbatim, and no token substitution runs over them. A shipped doc
12
+ // therefore must not print a substitution token: one that needs to teach the
13
+ // syntax describes the wrapper in words (see the tool-reference section of the
14
+ // anatomy guide). test/installed-docs.test.mjs fails when a shipped doc carries
15
+ // one, because the materialised plugin must contain no literal token.
16
+ //
17
+ // Zero dependencies so it runs on the CI Node (20) and local Node alike.
18
+
19
+ import { copyFile, mkdir } from "node:fs/promises";
20
+ import { dirname, join } from "node:path";
21
+ import { isFile } from "./fs.mjs";
22
+
23
+ /**
24
+ * Package-relative paths of the docs an install ships, sorted.
25
+ * @param {import("./compose-agent.mjs").TokenMap} tokenMap
26
+ * @returns {string[]}
27
+ */
28
+ export function shippedDocs(tokenMap) {
29
+ const docs = new Set();
30
+ for (const entry of Object.values(tokenMap.paths ?? {})) {
31
+ if (entry === null || typeof entry !== "object") continue;
32
+ for (const spec of Object.values(entry)) {
33
+ if (spec === null || typeof spec !== "object") continue;
34
+ const { relative } = /** @type {{ relative?: unknown }} */ (spec);
35
+ if (typeof relative === "string" && relative.startsWith("docs/")) docs.add(relative);
36
+ }
37
+ }
38
+ return [...docs].sort();
39
+ }
40
+
41
+ /**
42
+ * Copy every shipped doc from the package root into the staged tree, at the same
43
+ * relative path. A missing source is a hard error: the token map points a skill
44
+ * at a document the install would otherwise not contain.
45
+ * @param {{ root: string, output: string, dryRun?: boolean,
46
+ * tokenMap: import("./compose-agent.mjs").TokenMap }} options
47
+ * @returns {Promise<{ files: string[], dir: string }>}
48
+ */
49
+ export async function installDocs({ root, output, dryRun = false, tokenMap }) {
50
+ /** @type {string[]} */
51
+ const files = [];
52
+ for (const relative of shippedDocs(tokenMap)) {
53
+ const from = join(root, relative);
54
+ if (!(await isFile(from))) {
55
+ throw new Error(
56
+ `token-map.json points a skill at ${relative}, but ${from} does not exist — ship the document or drop the path entry`,
57
+ );
58
+ }
59
+ if (!dryRun) {
60
+ const to = join(output, relative);
61
+ await mkdir(dirname(to), { recursive: true });
62
+ await copyFile(from, to);
63
+ }
64
+ files.push(relative);
65
+ }
66
+ return { files, dir: join(output, "docs") };
67
+ }