@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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,35 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.7.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 464c761: Add `ctx update`, which refreshes installed harnesses by re-running their installers. Bare
8
+ `ctx update` lists every harness with its installed version and the CLI version, then asks which
9
+ to refresh; a non-interactive shell prints the same table plus the flags and exits non-zero
10
+ instead of blocking. `ctx update pi claude` refreshes exactly the named harnesses, `ctx update -a`
11
+ refreshes every harness that is not at the CLI version, and `ctx update -a -f` re-runs every
12
+ installer regardless of version. The report gives each harness its previous and new version and
13
+ the artifacts the installer wrote. Selection and reporting are the only additions: every refresh
14
+ goes through the existing `bin/installers/` entry points, so update-vs-install stays the
15
+ installer's decision.
16
+
17
+ ### Patch Changes
18
+
19
+ - bfce679: Add a `housekeeping` pipeline stage that runs after the human merge gate. `create-chores.mjs` now
20
+ creates a `stage:housekeeping` chore labelled `lifecycle:housekeeping` and blocked behind the
21
+ `lifecycle:feature-pr` gate bead, so it stays out of `bd ready` until a human merges the PR and
22
+ closes the gate. A dispatched worker then runs `prune-worktrees.mjs` from the main checkout, retries
23
+ once on a non-zero exit, and fails the chore without closing it when a closed-bead worktree is still
24
+ refused. The four existing stages and the gate are unchanged.
25
+ - 8d11154: Ship the skill anatomy doc into both installs. `write-a-skill` and `refactor-skill` read
26
+ `docs/skills/skill-anatomy.md`, but neither install contained it, so the reference was dead on any
27
+ machine without the checkout. Both installers now copy the document into the staged tree once per
28
+ install (`scripts/lib/docs.mjs`), and the skills name it through a new `{{PATH:skill_anatomy}}` entry
29
+ in `token-map.json`, so the token map stays the single source of the per-harness path. A new test
30
+ stages both installs and fails when a rendered skill references a repository doc the install does
31
+ not contain.
32
+
3
33
  ## 2.6.0
4
34
 
5
35
  ### Minor Changes
package/README.md CHANGED
@@ -209,6 +209,7 @@ The staged package contains:
209
209
  | `package.json` | pi manifest — `keywords: ["pi-package"]`, `pi.extensions` + `pi.skills`; the version tracks the package |
210
210
  | `agents/` | composed flat agents (ralph, plan, ralph-plan, strategy) with `{{TOOL:...}}` / `{{PATH:...}}` substituted — the shipped `agent-modes` extension resolves them from the package root |
211
211
  | `skills/` | token-substituted skill copies (real files, never symlinks) |
212
+ | `docs/` | the repository documents those skills read (currently `docs/skills/skill-anatomy.md`), copied verbatim once per install so the reference resolves on a machine without the checkout |
212
213
  | `extensions/` | copy of the repo `extensions/` tree — imports nothing from pi's runtime except the host-aliased `typebox`, so pi loads it from any directory |
213
214
  | `token-map.json` | the token map `agent-modes` reads from the package root |
214
215
  | `node_modules/` | the required third-party pi packages and their transitive closure |
@@ -243,9 +244,9 @@ never removes an entry you added. Each report gives the `pi remove` command that
243
244
  resolves it, with the resolved path, because `pi remove` resolves a relative
244
245
  argument against the current directory.
245
246
 
246
- Re-run `agent-cortex install pi` after every `git pull` to refresh the staged
247
- package; registration is already in place, so that re-run changes nothing in
248
- pi's settings. Verify with `pi list` and `pi config` (restart pi to apply).
247
+ Re-run `agent-cortex install pi` (or `agent-cortex update pi`) after every `git pull` to refresh
248
+ the staged package; registration is already in place, so that re-run changes nothing in pi's
249
+ settings. Verify with `pi list` and `pi config` (restart pi to apply).
249
250
 
250
251
  Refreshing the installed runtime after a release, and trying uncommitted work without
251
252
  overwriting it, are recorded in [`docs/dev-loop.md`](docs/dev-loop.md).
@@ -408,8 +409,9 @@ from a different root is refreshed in place rather than re-pointed at the fresh
408
409
  install. Re-point it with
409
410
  `claude plugin marketplace remove jaybeeuu` and re-run the install.
410
411
 
411
- The plain install IS the update — there is no separate build step. (`--output <dir>` only
412
- generates a preview and never refreshes an installed plugin.)
412
+ The plain install IS the update — there is no separate build step, and `agent-cortex update claude`
413
+ is the same materialise-and-register run selected by name. (`--output <dir>` only generates a
414
+ preview and never refreshes an installed plugin.)
413
415
 
414
416
  #### Uninstall
415
417
 
@@ -474,7 +476,7 @@ version — picked up at install time) — then run
474
476
  `agent-cortex install claude` (re-materialises the home plugin, merges settings, and
475
477
  re-registers it; `--output <dir>` generates a preview only). The only committed file under
476
478
  `claude/` is the settings template; `~/.agent-cortex/claude` is generated in full
477
- (`.claude-plugin/plugin.json`, `agents/`, `skills/`, `hooks.json`, `.mcp.json`, `scripts/`)
479
+ (`.claude-plugin/plugin.json`, `agents/`, `skills/`, `docs/`, `hooks.json`, `.mcp.json`, `scripts/`)
478
480
  so never edit anything under it. CI validates the Claude materialiser (structural checks on
479
481
  a temp-dir install) so sources and installer can never silently diverge.
480
482
 
@@ -502,6 +504,42 @@ Only plain release versions are compared, so a prerelease on either side also re
502
504
  The command is read-only: it stats and reads those manifests, and never installs, writes, or
503
505
  reaches the network.
504
506
 
507
+ ## Refreshing installed harnesses (`agent-cortex update`)
508
+
509
+ `agent-cortex update` re-runs a harness installer and reports what changed. It installs nothing
510
+ itself: every refresh goes through the same path as `agent-cortex install <harness>`, so update and
511
+ install can never disagree about what is out of date.
512
+
513
+ ```sh
514
+ agent-cortex update # list the harnesses, then ask which to refresh
515
+ agent-cortex update pi claude # refresh exactly these, no prompt
516
+ agent-cortex update -a # refresh every harness not at the CLI version
517
+ agent-cortex update -a -f # re-run every installer regardless of version
518
+ ```
519
+
520
+ The prompt lists every supported harness with its installed version, the CLI version, and a status
521
+ marker (`current`, `stale`, `ahead`, `unknown`, or `missing`) — the data `agent-cortex version`
522
+ reports, plus a row for a harness with no install. Answer with row numbers counted from the top
523
+ row, `a` for every harness, or blank to cancel.
524
+
525
+ A harness named on the command line always refreshes, whatever version it reports, and takes
526
+ precedence over `-a`. `-a` refreshes every harness that does not report `current`, so an
527
+ uninstalled, stale, ahead, or unreadable harness is refreshed. `-f` requires `-a`: alone it says
528
+ nothing about which harnesses to touch. An unknown name fails before any installer runs.
529
+
530
+ A shell that is not a terminal cannot answer the prompt, so a bare `agent-cortex update` there
531
+ prints the same table plus the flags above and exits non-zero — a script can never mistake
532
+ "nothing chosen" for "nothing to do".
533
+
534
+ The report gives each refreshed harness its previous version, its new version, and the artifacts
535
+ the installer wrote; harnesses already at the CLI version are listed as skipped. A harness's new
536
+ version is read back from the manifest its installer wrote, so a refresh that failed to
537
+ materialise reports `unknown` rather than claiming the CLI version.
538
+
539
+ This refreshes the installs, not the CLI. Bump the global package first
540
+ (`pnpm add -g @jaybeeuu/agent-cortex@latest`), then update the harnesses — see
541
+ [`docs/dev-loop.md`](docs/dev-loop.md).
542
+
505
543
  ## Migrating a beads workspace
506
544
 
507
545
  `agent-cortex migrate` brings the `.beads` workspace it runs in — any repo, not just this
@@ -21,7 +21,7 @@ Each pipeline stage is a separate **chore bead**, pre-created by `create-chores.
21
21
  - **Single-feature epics** (epic has exactly one feature task child): skip the epic branch entirely. The feature branch (`feature/<parent-id>`) is based directly from `origin/main`. The HITL PR targets `main` directly. No epic PR is required.
22
22
  - **Multi-feature epics** (epic has two or more feature task children): each epic runs on `epic/<epic-id>` (base: `origin/main`, never local `main`). Each feature branch is based from its epic branch. Completed features PR into `epic/<epic-id>`; the completed epic PRs into `main`.
23
23
  - Each parent feature task runs on `feature/<parent-id>` in `.agent-cortex/worktrees/<parent-id>`.
24
- - Each implemented bead owns a child HITL PR-gate task bead (`lifecycle:feature-pr`, blocked by its own `document` chore) for PR review/merge.
24
+ - Each implemented bead owns a child HITL PR-gate task bead (`lifecycle:feature-pr`, blocked by its own `document` chore) for PR review/merge, plus a `stage:housekeeping` chore blocked behind that gate.
25
25
  - Completed features must be reviewed by PR merge from the agent branch (`feature/<parent-id>`) into its base (epic branch for multi-feature epics, `main` for single-feature epics), then the HITL PR task bead must be closed by a human before Ralph continues feature scheduling.
26
26
  - Completed multi-feature epics must be reviewed by PR merge into `main`.
27
27
 
@@ -93,11 +93,13 @@ A ready `execution:afk` task bead with no `stage:*` chore children has nothing t
93
93
  { "kind": "expand", "choreId": null, "parentId": "<parent-id>", "title": "<parent title>", "agentId": "<agent-id>", "logLine": 1 }
94
94
  ```
95
95
  That entry is what keeps it in the wait set and makes its completion attributable to the parent.
96
- 4. The worker reports the returned mapping — `code`, `verify`, `review`, `document`, `featurePrReview` — and closes nothing. It emits **no** `---REPORT---` block and owns no bead, so there is nothing to parse and no close to confirm.
96
+ 4. The worker reports the returned mapping — `code`, `verify`, `review`, `document`, `housekeeping`, `featurePrReview` — and closes nothing. It emits **no** `---REPORT---` block and owns no bead, so there is nothing to parse and no close to confirm.
97
97
  5. Its completion means the parent now has a `stage:*` chain: the event loop's next `bd ready -l execution:afk` pass picks up the `stage:code` chore (event loop step 7).
98
98
 
99
99
  **Expansion completion contract.** An expansion worker is dispatched like a stage worker but is not one: no chore claim, no `---REPORT---`, no leaf-bead close. Handle its completion with the expansion branch in event-loop step 3, which confirms the chain exists (`bd children <parent-id> --json | grep -c '"stage:'` prints one match per stage chore — the gate carries `lifecycle:feature-pr` and is not counted) and dispatches the ready `stage:code` chore. A count below the stage total in `pipeline.json` is a partial chain, whose missing stages never arrive, so re-dispatch the expansion instead of dispatching on. If the parent still has no `stage:*` children the expansion failed: surface the worker's output in chat, record the parent for the shutdown summary, and re-dispatch the expansion — `create-chores.mjs` is idempotent, so a second run adopts the chores that exist, creates only the missing ones, and reports the whole mapping. Either way, create nothing and close nothing yourself.
100
100
 
101
+ **Housekeeping chore.** One stage runs outside the pipeline's ordering: `stage:housekeeping`, labelled `lifecycle:housekeeping` and blocked behind the PR gate bead, so it becomes ready only after a human merges the PR and closes the gate. Dispatch it with the **main checkout** as cwd — not the feature worktree — because the worktree it removes can be the parent's own. The worker runs `prune-worktrees.mjs`, retries once on a non-zero exit (exit `1` is normal: an open bead counts as survival), and then decides from the per-worktree report. It fails the chore and leaves it **open** when a `closed-bead` worktree is still `refused`; a `kept` or `refused` entry for an open bead is a normal report. Never close a chore over a worktree the script did not remove.
102
+
101
103
  **Never expand an existing bead by running `create-task`** (or any other command that creates a bead). `create-task` creates a bead first and expands second, so running it for an existing bead leaves a **second parent** beside the original, which stays chore-less and never gets dispatched. `create-chores.mjs` expands a bead that already exists; it never creates parents.
102
104
 
103
105
  ---
@@ -1,11 +1,22 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { parseArgs, buildHelpText, resolveProgramName, validateHarness } from "../lib/cli.mjs";
3
+ import { parseArgs, buildHelpText, resolveProgramName, validateHarness, SUPPORTED_HARNESSES } from "../lib/cli.mjs";
4
4
  import {
5
5
  collectVersionReport,
6
+ collectHarnessStatus,
6
7
  formatVersionReport,
7
8
  serialiseVersionReport,
8
9
  } from "../lib/harness-versions.mjs";
10
+ import {
11
+ describeClaudeArtifacts,
12
+ describePiArtifacts,
13
+ formatHarnessTable,
14
+ formatUpdateFlags,
15
+ formatUpdateReport,
16
+ promptHarnessSelection,
17
+ runUpdate,
18
+ selectHarnesses,
19
+ } from "../lib/harness-update.mjs";
9
20
  import { join, dirname } from "node:path";
10
21
  import { fileURLToPath } from "node:url";
11
22
  import { readFile } from "node:fs/promises";
@@ -90,50 +101,76 @@ if (parsed.command === "install") {
90
101
  process.exit(1);
91
102
  }
92
103
 
93
- if (parsed.harness === "pi") {
104
+ try {
105
+ await installHarness(/** @type {"pi" | "claude"} */ (parsed.harness), {
106
+ dryRun: parsed.dryRun ?? false,
107
+ ...(parsed.output ? { output: parsed.output } : {}),
108
+ ...(parsed.pluginRoot ? { pluginRoot: parsed.pluginRoot } : {}),
109
+ requireRegister: parsed.requireRegister ?? false,
110
+ });
111
+ } catch (err) {
112
+ process.stderr.write(`Install failed: ${/** @type {Error} */ (err).message}\n`);
113
+ process.exit(1);
114
+ }
115
+ process.exit(0);
116
+ }
117
+
118
+ if (parsed.command === "update") {
119
+ process.exitCode = await runUpdateCommand(parsed, programName);
120
+ }
121
+
122
+ /**
123
+ * Run one harness's installer and, for a plain install, its registration.
124
+ * `install <harness>` and `update` both come through here, so update-vs-install
125
+ * stays the installer's decision and neither command grows a second install path.
126
+ * @param {"pi" | "claude"} harness
127
+ * @param {object} [options]
128
+ * @param {boolean} [options.dryRun]
129
+ * @param {string} [options.output]
130
+ * @param {string} [options.pluginRoot]
131
+ * @param {boolean} [options.requireRegister]
132
+ * @returns {Promise<string[]>} What the installer wrote, for the update report
133
+ */
134
+ async function installHarness(harness, { dryRun = false, output, pluginRoot, requireRegister = false } = {}) {
135
+ if (harness === "pi") {
94
136
  // A plain `agent-cortex install pi` stages the package into the home
95
137
  // install root (~/.agent-cortex/pi) and registers that directory with pi
96
138
  // through pi's own `pi install` (idempotent by state: a re-run is a no-op
97
139
  // update); `--output <dir>` is the stage-only form (tests/CI) and prints
98
140
  // the registration command instead.
99
- try {
100
- const result = await installPi({
101
- dryRun: parsed.dryRun ?? false,
102
- warn: () => {}, // warnings surface once in the printed summary
103
- ...(parsed.output ? { output: parsed.output } : {}),
104
- ...(parsed.pluginRoot ? { pluginRoot: parsed.pluginRoot } : {}),
141
+ const result = await installPi({
142
+ dryRun,
143
+ warn: () => {}, // warnings surface once in the printed summary
144
+ ...(output ? { output } : {}),
145
+ ...(pluginRoot ? { pluginRoot } : {}),
146
+ });
147
+ printPiInstall(result);
148
+ if (output === undefined) {
149
+ const registration = await registerPi({
150
+ output: result.output,
151
+ dryRun,
152
+ requireRegister,
153
+ warn: (msg) => process.stdout.write(` ⚠ ${msg}\n`),
105
154
  });
106
- printPiInstall(result);
107
- if (parsed.output === undefined) {
108
- const registration = await registerPi({
109
- output: result.output,
110
- dryRun: parsed.dryRun ?? false,
111
- requireRegister: parsed.requireRegister ?? false,
112
- warn: (msg) => process.stdout.write(` ⚠ ${msg}\n`),
113
- });
114
- // The old installer's rendered copies are only stale once the staged
115
- // package is what pi loads — and nothing is deleted on a dry run. The
116
- // flag is passed through as well, so the sweep's own guarantee does not
117
- // depend on this guard staying in place.
118
- if (registration.registered && !(parsed.dryRun ?? false)) {
119
- printPiSweep(
120
- await sweepLegacyPiUserDirs({
121
- piUserDir: dirname(registration.settingsPath),
122
- dryRun: parsed.dryRun ?? false,
123
- }),
124
- );
125
- }
126
- } else {
127
- process.stdout.write(` → register it with pi: pi install ${result.output}\n`);
155
+ // The old installer's rendered copies are only stale once the staged
156
+ // package is what pi loads — and nothing is deleted on a dry run. The
157
+ // flag is passed through as well, so the sweep's own guarantee does not
158
+ // depend on this guard staying in place.
159
+ if (registration.registered && !dryRun) {
160
+ printPiSweep(
161
+ await sweepLegacyPiUserDirs({
162
+ piUserDir: dirname(registration.settingsPath),
163
+ dryRun,
164
+ }),
165
+ );
128
166
  }
129
- } catch (err) {
130
- process.stderr.write(`Install failed: ${/** @type {Error} */ (err).message}\n`);
131
- process.exit(1);
167
+ } else {
168
+ process.stdout.write(` → register it with pi: pi install ${result.output}\n`);
132
169
  }
133
- process.exit(0);
170
+ return describePiArtifacts(result);
134
171
  }
135
172
 
136
- process.stdout.write(`Installing agent-cortex for "${parsed.harness}" harness…\n`);
173
+ process.stdout.write(`Installing agent-cortex for "${harness}" harness…\n`);
137
174
 
138
175
  // claude is the first wired harness: a plain `agent-cortex install claude`
139
176
  // materialises the plugin into the home install root (~/.agent-cortex/claude)
@@ -144,40 +181,90 @@ if (parsed.command === "install") {
144
181
  // (marketplace add → install → update); `--output <dir>` is the generate-only
145
182
  // form (tests/CI validation), and the generated claude/ plugin subtree is
146
183
  // never committed — only the settings template is.
147
- if (parsed.harness === "claude") {
148
- // Avoid loading the installer on the help/summary paths and for other harnesses.
149
- const { installClaude, registerClaude } = await import("./installers/claude.mjs");
150
- try {
151
- const result = await installClaude({
152
- dryRun: parsed.dryRun ?? false,
153
- warn: () => {}, // warnings surface once via result.warnings
154
- ...(parsed.output ? { output: parsed.output } : {}),
155
- });
156
- for (const warning of result.warnings) {
157
- process.stdout.write(` ⚠ ${warning}\n`);
184
+
185
+ // Avoid loading the installer on the help/summary paths and for other harnesses.
186
+ const { installClaude, registerClaude } = await import("./installers/claude.mjs");
187
+ const result = await installClaude({
188
+ dryRun,
189
+ warn: () => {}, // warnings surface once via result.warnings
190
+ ...(output ? { output } : {}),
191
+ });
192
+ for (const warning of result.warnings) {
193
+ process.stdout.write(` ⚠ ${warning}\n`);
194
+ }
195
+ // Runtime registration happens only for the plain install — `--output`
196
+ // keeps generating only (documented in --help).
197
+ if (output === undefined) {
198
+ await registerClaude({
199
+ // A default install always materialises the marketplace manifest; the
200
+ // generate-only form (--output) is the only path where it is null, and
201
+ // this branch is not that path.
202
+ root: /** @type {string} */ (result.marketplaceRoot),
203
+ manifest: /** @type {object} */ (result.marketplaceManifest),
204
+ pluginVersion: result.pluginVersion,
205
+ // The enablement check reads the same file this install just merged:
206
+ // a plugin Claude Code does not enable loads nothing.
207
+ ...(result.settings ? { settingsPath: result.settings.path } : {}),
208
+ dryRun,
209
+ requireRegister,
210
+ });
211
+ }
212
+ return describeClaudeArtifacts(result);
213
+ }
214
+
215
+ /**
216
+ * `ctx update`: choose the harnesses, refresh each through its installer, report
217
+ * what changed. Every write happens inside `installHarness`'s call into the
218
+ * harness installer — this command adds selection and reporting only.
219
+ * @param {import("../lib/cli.mjs").ParsedArgs} parsed
220
+ * @param {string} programName
221
+ * @returns {Promise<number>} The exit code
222
+ */
223
+ async function runUpdateCommand(parsed, programName) {
224
+ if (parsed.optionError) {
225
+ process.stderr.write(`${parsed.optionError}\n`);
226
+ return 1;
227
+ }
228
+ try {
229
+ const cliVersion = await readPackageVersion();
230
+ const statuses = await collectHarnessStatus({ cli: cliVersion });
231
+ const all = parsed.all ?? false;
232
+ let harnesses = parsed.harnesses ?? [];
233
+
234
+ // Bare `ctx update` asks; a non-interactive shell gets the same table plus
235
+ // the flags that select without a prompt, and a non-zero exit so a script
236
+ // never mistakes "nothing chosen" for "nothing to do".
237
+ if (harnesses.length === 0 && !all) {
238
+ if (process.stdin.isTTY !== true || process.stdout.isTTY !== true) {
239
+ process.stdout.write(`${formatHarnessTable(statuses)}\n\n${formatUpdateFlags(programName)}\n`);
240
+ return 1;
158
241
  }
159
- // Runtime registration happens only for the plain install — `--output`
160
- // keeps generating only (documented in --help).
161
- if (parsed.output === undefined) {
162
- await registerClaude({
163
- // A default install always materialises the marketplace manifest; the
164
- // generate-only form (--output) is the only path where it is null, and
165
- // this branch is not that path.
166
- root: /** @type {string} */ (result.marketplaceRoot),
167
- manifest: /** @type {object} */ (result.marketplaceManifest),
168
- pluginVersion: result.pluginVersion,
169
- // The enablement check reads the same file this install just merged:
170
- // a plugin Claude Code does not enable loads nothing.
171
- ...(result.settings ? { settingsPath: result.settings.path } : {}),
172
- dryRun: parsed.dryRun ?? false,
173
- requireRegister: parsed.requireRegister ?? false,
174
- });
242
+ harnesses = await promptHarnessSelection({ statuses });
243
+ if (harnesses.length === 0) {
244
+ process.stdout.write("No harness selected; nothing updated.\n");
245
+ return 0;
175
246
  }
176
- process.exit(0);
177
- } catch (err) {
178
- process.stderr.write(`Install failed: ${/** @type {Error} */ (err).message}\n`);
179
- process.exit(1);
180
247
  }
248
+
249
+ const plan = selectHarnesses({ statuses, harnesses, all, force: parsed.force ?? false });
250
+ if (plan.unknown.length > 0) {
251
+ const names = plan.unknown.map((name) => `"${name}"`).join(", ");
252
+ process.stderr.write(
253
+ `Unknown harness ${names}. Supported harnesses: ${SUPPORTED_HARNESSES.join(", ")}\nRun "${programName} --help" for usage.\n`,
254
+ );
255
+ return 1;
256
+ }
257
+
258
+ const entries = await runUpdate({
259
+ cli: cliVersion,
260
+ targets: plan.targets,
261
+ refresh: (harness) => installHarness(/** @type {"pi" | "claude"} */ (harness), {}),
262
+ });
263
+ process.stdout.write(`${formatUpdateReport({ cli: cliVersion, entries, skipped: plan.skipped })}\n`);
264
+ return 0;
265
+ } catch (err) {
266
+ process.stderr.write(`Update failed: ${/** @type {Error} */ (err).message}\n`);
267
+ return 1;
181
268
  }
182
269
  }
183
270
 
@@ -217,6 +304,9 @@ function printPiInstall(result) {
217
304
  process.stdout.write(` ✓ ${agent.name}.agent.md → ${agent.filePath}\n`);
218
305
  }
219
306
  process.stdout.write(` ✓ Substituted ${result.skills.md} markdown file(s) across ${result.skills.skills} skill(s) → ${result.skills.dir}\n`);
307
+ if (result.docs.files.length > 0) {
308
+ process.stdout.write(` ✓ Copied ${result.docs.files.length} doc(s) → ${result.docs.dir}\n`);
309
+ }
220
310
  process.stdout.write(` ✓ Copied ${result.extensions.files} extension file(s) → ${result.extensions.dir}\n`);
221
311
  for (const name of result.packages.bundled) {
222
312
  process.stdout.write(` ✓ Bundled pi package ${name} → ${join(result.packages.dir, name)}\n`);
@@ -22,6 +22,12 @@
22
22
  // layout (scripts/lib/skill-links.mjs),
23
23
  // so a grouped-tree handoff still
24
24
  // resolves in the plugin.
25
+ // <output>/docs/... documents the skills read after
26
+ // install (docs/skills/skill-anatomy.md),
27
+ // copied verbatim once per install — a
28
+ // skill names them by {{PATH:key}}, so
29
+ // the token map decides both location
30
+ // and shipment
25
31
  // <output>/hooks.json + hooks/<scripts> from hooks/claude/ (support files
26
32
  // referenced via ${CLAUDE_PLUGIN_ROOT})
27
33
  // <output>/.mcp.json + scripts/ hand-authored extras copied from
@@ -67,6 +73,7 @@ import { join, dirname, basename, resolve } from "node:path";
67
73
  import { fileURLToPath } from "node:url";
68
74
  import { homedir } from "node:os";
69
75
  import { composeAgent, loadTokenMap, substituteTokens } from "../../scripts/lib/compose-agent.mjs";
76
+ import { installDocs } from "../../scripts/lib/docs.mjs";
70
77
  import { isDirectory, isFile } from "../../scripts/lib/fs.mjs";
71
78
  import { flattenSkillLinks } from "../../scripts/lib/skill-links.mjs";
72
79
 
@@ -633,6 +640,26 @@ async function isSymlink(filePath) {
633
640
  }
634
641
  }
635
642
 
643
+ /**
644
+ * Result of generating (or planning) the Claude Code plugin.
645
+ * @typedef {object} InstallClaudeResult
646
+ * @property {string} output
647
+ * @property {string | null} marketplaceRoot
648
+ * @property {string | null} manifestPath
649
+ * @property {object | null} marketplaceManifest
650
+ * @property {string[]} agents
651
+ * @property {{ names: string[], md: number, files: number, dir: string }} skills
652
+ * @property {{ files: string[], dir: string }} docs
653
+ * @property {boolean} hooks
654
+ * @property {string[]} hookFiles
655
+ * @property {string[]} handAuthored
656
+ * @property {{ path: string, action: string, removedSymlink: boolean } | null} settings
657
+ * @property {{ path: string, action: string, removedSymlink: boolean } | null} instructions
658
+ * @property {string} pluginVersion
659
+ * @property {string[]} warnings
660
+ * @property {boolean} dryRun
661
+ */
662
+
636
663
  /**
637
664
  * Generate the Claude Code plugin subtree.
638
665
  *
@@ -654,13 +681,7 @@ async function isSymlink(filePath) {
654
681
  * explicitly materialises the section even
655
682
  * with --output — the test/CI seam.
656
683
  * @param {(msg: string) => void} [options.warn] Warning sink, also collected in `warnings`.
657
- * @returns {Promise<{ output: string, marketplaceRoot: string|null, manifestPath: string|null,
658
- * marketplaceManifest: object|null, agents: string[],
659
- * skills: {names: string[], md: number, files: number, dir: string},
660
- * hooks: boolean, hookFiles: string[], handAuthored: string[],
661
- * settings: {path: string, action: string, removedSymlink: boolean}|null,
662
- * instructions: {path: string, action: string, removedSymlink: boolean}|null,
663
- * pluginVersion: string, warnings: string[], dryRun: boolean }>}
684
+ * @returns {Promise<InstallClaudeResult>}
664
685
  */
665
686
  export async function installClaude({ root = PACKAGE_ROOT, output, dryRun = false, pluginRoot, settingsPath, instructionsPath, warn = DEFAULT_WARN } = {}) {
666
687
  const tokenMap = await loadTokenMap(root);
@@ -690,7 +711,7 @@ export async function installClaude({ root = PACKAGE_ROOT, output, dryRun = fals
690
711
  // or a tmp dir, never claude-extras/, but clearing the output first would
691
712
  // still destroy sources if they ever collided). They are re-copied fresh in
692
713
  // the write phase. In dry-run nothing is cleaned or written.
693
- const cleanable = ["agents", "skills", ".claude-plugin", "hooks", "scripts", "hooks.json", ".mcp.json"];
714
+ const cleanable = ["agents", "skills", "docs", ".claude-plugin", "hooks", "scripts", "hooks.json", ".mcp.json"];
694
715
  const handAuthored = await buildHandAuthored(root);
695
716
 
696
717
  // Regenerate the plugin children (removes stale output from earlier flows,
@@ -721,6 +742,7 @@ export async function installClaude({ root = PACKAGE_ROOT, output, dryRun = fals
721
742
  const agents = await buildAgents(root);
722
743
  const skillOut = join(out, "skills");
723
744
  const skills = await buildSkills(root, skillOut, { dryRun, pluginRoot: rootPlugin, tokenMap, warn: warnSink });
745
+ const docs = await installDocs({ root, output: out, dryRun, tokenMap });
724
746
  const pluginJson = await buildPluginJson(root);
725
747
  const hooksSrc = join(root, "hooks", "claude", "hooks.json");
726
748
  const hooksJson = (await isFile(hooksSrc)) ? await readFile(hooksSrc, "utf-8") : null;
@@ -735,6 +757,7 @@ export async function installClaude({ root = PACKAGE_ROOT, output, dryRun = fals
735
757
  pluginVersion: JSON.parse(pluginJson).version,
736
758
  agents: agents.files.map((f) => f.file.replace(/\.md$/, "")),
737
759
  skills: { names: skills.names, md: skills.md, files: skills.files, dir: skillOut },
760
+ docs,
738
761
  hooks: hooksJson !== null,
739
762
  hookFiles: hookFiles.map((f) => f.rel),
740
763
  handAuthored: handAuthored.map((f) => f.rel),
@@ -748,6 +771,7 @@ export async function installClaude({ root = PACKAGE_ROOT, output, dryRun = fals
748
771
  console.log(`DRY-RUN — would generate into ${out}, no files written:`);
749
772
  console.log(` agents: ${summary.agents.join(", ")}`);
750
773
  console.log(` skills: ${summary.skills.names.length} copied → ${skillOut}/<name>/ (token-substituted)`);
774
+ if (docs.files.length) console.log(` docs: ${docs.files.join(", ")} → ${docs.dir}/ (verbatim copies)`);
751
775
  console.log(` .claude-plugin/plugin.json (version ${JSON.parse(pluginJson).version})`);
752
776
  if (isDefaultInstall && manifestPath) {
753
777
  console.log(` marketplace manifest → ${manifestPath} (plugin agent-cortex @ ./${basename(out)})`);
@@ -778,6 +802,7 @@ export async function installClaude({ root = PACKAGE_ROOT, output, dryRun = fals
778
802
  console.log(
779
803
  `Copied ${summary.skills.names.length} skill(s) → ${skillOut}/<name>/ (token-substituted, no symlinks)`,
780
804
  );
805
+ if (docs.files.length) console.log(`Copied ${docs.files.length} doc(s) → ${docs.dir}/ (verbatim)`);
781
806
 
782
807
  await mkdir(join(out, ".claude-plugin"), { recursive: true });
783
808
  await writeFile(join(out, ".claude-plugin", "plugin.json"), pluginJson);
@@ -22,6 +22,11 @@
22
22
  // <extension>/../../agents, i.e. this dir
23
23
  // <output>/skills/<group>/<name>/ token-substituted skill copies — real
24
24
  // files, never symlinks
25
+ // <output>/docs/... documents the skills read after install
26
+ // (docs/skills/skill-anatomy.md), copied
27
+ // verbatim once per install — a skill names
28
+ // them by {{PATH:key}}, so the token map
29
+ // decides both location and shipment
25
30
  // <output>/extensions/ copy of the repo extensions/ tree (loaded
26
31
  // by pi's package discovery)
27
32
  // <output>/token-map.json the map agent-modes reads from the package
@@ -115,6 +120,7 @@ import { join, dirname, resolve } from "node:path";
115
120
  import { fileURLToPath } from "node:url";
116
121
  import { homedir } from "node:os";
117
122
  import { composeAgent, loadTokenMap, substituteTokens, translateToolList } from "../../scripts/lib/compose-agent.mjs";
123
+ import { installDocs } from "../../scripts/lib/docs.mjs";
118
124
  import { isDirectory, isFile } from "../../scripts/lib/fs.mjs";
119
125
 
120
126
  const PI = "pi";
@@ -141,6 +147,7 @@ const PI = "pi";
141
147
  * @property {string} output
142
148
  * @property {{ name: string, filePath: string }[]} agents
143
149
  * @property {{ skills: number, md: number, files: number, dir: string }} skills
150
+ * @property {{ files: string[], dir: string }} docs
144
151
  * @property {{ files: number, dir: string }} extensions
145
152
  * @property {{ dir: string, bundled: string[], staged: string[], resources: PackageResources }} packages
146
153
  * @property {{ path: string, manifest: PiManifest }} manifest
@@ -181,7 +188,7 @@ const STAGED_NAME = "@jaybeeuu/agent-cortex-pi";
181
188
  // before every write so re-staging cannot leave stale copies behind (a skill
182
189
  // deleted in the repo, a package dropped from the manifest). Anything else in
183
190
  // the output directory is left alone.
184
- const OWNED_CHILDREN = ["package.json", "token-map.json", "agents", "skills", "extensions", "node_modules"];
191
+ const OWNED_CHILDREN = ["package.json", "token-map.json", "agents", "skills", "docs", "extensions", "node_modules"];
185
192
 
186
193
  const GENERATED_MARKER =
187
194
  "GENERATED by `agent-cortex install pi` — DO NOT EDIT; re-run the installer to update.";
@@ -242,6 +249,7 @@ const DEFAULT_LEGACY_SWEEP_RECORD = join(homedir(), ".agent-cortex", "legacy-swe
242
249
  * @param {(msg: string) => void} [options.warn] Warning sink, also collected in `warnings`
243
250
  * @returns {Promise<{ output: string, agents: {name: string, filePath: string}[],
244
251
  * skills: {skills: number, md: number, files: number, dir: string},
252
+ * docs: {files: string[], dir: string},
245
253
  * extensions: {files: number, dir: string},
246
254
  * packages: {dir: string, bundled: string[], staged: string[], resources: {extensions: string[], skills: string[]}},
247
255
  * manifest: {path: string, manifest: PiManifest},
@@ -285,6 +293,7 @@ export async function installPi(options = {}) {
285
293
 
286
294
  const agents = await installAgents({ root, agentsDir: join(output, "agents"), dryRun, pluginRoot, tokenMap, warn });
287
295
  const skills = await installSkills({ root, skillsDir: join(output, "skills"), dryRun, pluginRoot, tokenMap, warn });
296
+ const docs = await installDocs({ root, output, dryRun, tokenMap });
288
297
  const extensions = await installExtensions({ root, output, dryRun });
289
298
  const tokenMapFile = await installTokenMap({ output, tokenMap, dryRun });
290
299
  const packages = await stagePiPackages({ dependencies, output, resolvePackage, dryRun, warn });
@@ -301,6 +310,7 @@ export async function installPi(options = {}) {
301
310
  output,
302
311
  agents,
303
312
  skills,
313
+ docs,
304
314
  extensions,
305
315
  packages,
306
316
  manifest,