@jaybeeuu/agent-cortex 1.41.1 → 1.43.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/.agents/skills/style-versioning/SKILL.md +73 -2
  2. package/.github/workflows/ci.yml +52 -32
  3. package/AGENTS.md +23 -2
  4. package/CHANGELOG.md +189 -0
  5. package/README.md +125 -73
  6. package/agents/README.md +17 -4
  7. package/agents/ralph-plan/agent.md +7 -3
  8. package/agents/ralph-plan.agent.md +7 -3
  9. package/bin/agent-cortex.mjs +48 -30
  10. package/bin/installers/pi.mjs +885 -237
  11. package/claude-extras/scripts/statusline-command.sh +0 -0
  12. package/docs/claude-hooks.md +5 -4
  13. package/docs/commit-signing.md +231 -0
  14. package/docs/ideas/ban-content-assertion-tests.md +3 -1
  15. package/docs/ideas/dedicated-review-tests-skill.md +71 -0
  16. package/docs/ideas/enable-signed-commit-enforcement-changesets-bot-commits-must-be-signed.md +10 -1
  17. package/docs/ideas/extension-manifests.md +5 -0
  18. package/docs/ideas/unify-the-ticket-and-task-decomposition-workflows.md +71 -0
  19. package/docs/research/disable-model-invocation.md +103 -0
  20. package/docs/research/skill-design-research.md +2 -2
  21. package/docs/skills/skill-anatomy.md +5 -1
  22. package/docs/technical-direction/harness-config.md +25 -1
  23. package/docs/technical-direction/migration-execution-labels.md +17 -9
  24. package/extensions/agent-modes/discover.test.ts +19 -0
  25. package/extensions/agent-modes/discover.ts +25 -3
  26. package/extensions/session-start/README.md +38 -0
  27. package/extensions/session-start/index.test.ts +122 -0
  28. package/extensions/session-start/index.ts +27 -0
  29. package/extensions/session-start/package.json +19 -0
  30. package/extensions/session-start/tsconfig.json +12 -0
  31. package/extensions/subagent/README.md +1 -1
  32. package/extensions/subagent/agents.test.ts +176 -0
  33. package/extensions/subagent/agents.ts +102 -11
  34. package/extensions/subagent/index.ts +2 -1
  35. package/extensions/subagent/package.json +1 -1
  36. package/lib/cli.mjs +35 -9
  37. package/package.json +10 -6
  38. package/pi/settings.json +1 -7
  39. package/plugin.json +1 -1
  40. package/pnpm-workspace.yaml +6 -0
  41. package/scripts/check-changeset.mjs +42 -0
  42. package/scripts/install-instructions.sh +0 -0
  43. package/scripts/lib/release-gates.mjs +61 -0
  44. package/scripts/lib/verify-worker-tree.mjs +107 -0
  45. package/scripts/resolve-publish-command.mjs +17 -0
  46. package/scripts/sync-plugin-version.sh +0 -0
  47. package/skills/engineering/grill-with-docs/SKILL.md +3 -2
  48. package/skills/engineering/improve-codebase-architecture/REFERENCE.md +5 -0
  49. package/skills/engineering/improve-codebase-architecture/SKILL.md +2 -1
  50. package/skills/planning/create-task/SKILL.md +8 -3
  51. package/skills/planning/create-task/scripts/create-chores.ts +4 -1
  52. package/skills/planning/prd-to-tasks/REFERENCE.md +2 -2
  53. package/skills/planning/prd-to-tasks/SKILL.md +9 -4
  54. package/skills/planning/request-refactor-plan/FORMAT.md +5 -0
  55. package/skills/planning/request-refactor-plan/SKILL.md +3 -2
  56. package/skills/planning/spec-to-tickets/REFERENCE.md +82 -0
  57. package/skills/planning/spec-to-tickets/SKILL.md +143 -0
  58. package/skills/planning/write-a-prd/FORMAT.md +6 -0
  59. package/skills/planning/write-a-prd/SKILL.md +9 -6
  60. package/skills/planning/write-a-ticket/EXAMPLES.md +133 -0
  61. package/skills/planning/write-a-ticket/SKILL.md +77 -59
  62. package/skills/productivity/bd-tool/SKILL.md +27 -2
  63. package/skills/productivity/grill-me/SKILL.md +11 -0
  64. package/skills/review/review-security/scripts/install-gitleaks.sh +0 -0
  65. package/skills/workflow/git-workflow/SKILL.md +35 -1
  66. package/skills/workflow/plan/SKILL.md +20 -8
  67. package/skills/workflow/ralph/REFERENCE.md +20 -2
  68. package/skills/workflow/ralph/SKILL.md +4 -1
  69. package/skills/workflow/run-pipeline-stage/SKILL.md +17 -5
  70. package/skills/workflow/run-pipeline-stage/playbooks/code.md +2 -0
  71. package/skills/workflow/run-pipeline-stage/playbooks/document.md +3 -0
  72. package/skills/workflow/run-pipeline-stage/playbooks/fix.md +2 -0
  73. package/skills/workflow/run-pipeline-stage/prompts/stage-runner.md +11 -0
  74. package/skills/workflow/using-agent-skills/SKILL.md +1 -0
  75. package/test/cli.test.mjs +26 -29
  76. package/test/create-chores.test.mjs +12 -0
  77. package/test/fake-bd.test.mjs +72 -0
  78. package/test/helpers/fake-bd.mjs +26 -6
  79. package/test/helpers/fake-pi.mjs +132 -0
  80. package/test/pi-installer.test.mjs +345 -286
  81. package/test/pi-register.test.mjs +342 -0
  82. package/test/pi-sweep.test.mjs +619 -0
  83. package/test/release-gates.test.mjs +190 -0
  84. package/test/verify-worker-tree.test.mjs +120 -0
  85. package/token-map.README.md +7 -6
  86. package/token-map.json +6 -5
  87. package/extensions/agent-guardrails/node_modules/.bin/pi +0 -21
  88. package/extensions/agent-guardrails/node_modules/.bin/tsc +0 -21
  89. package/extensions/agent-guardrails/node_modules/.bin/tsserver +0 -21
  90. package/extensions/agent-guardrails/node_modules/.bin/tsx +0 -21
  91. package/extensions/agent-modes/node_modules/.bin/pi +0 -21
  92. package/extensions/agent-modes/node_modules/.bin/tsc +0 -21
  93. package/extensions/agent-modes/node_modules/.bin/tsserver +0 -21
  94. package/extensions/agent-modes/node_modules/.bin/tsx +0 -21
  95. package/extensions/session-start.ts +0 -44
  96. package/extensions/skill-stats/node_modules/.bin/pi +0 -21
  97. package/extensions/skill-stats/node_modules/.bin/tsc +0 -21
  98. package/extensions/skill-stats/node_modules/.bin/tsserver +0 -21
  99. package/extensions/skill-stats/node_modules/.bin/tsx +0 -21
  100. package/extensions/subagent/node_modules/.bin/pi +0 -21
  101. package/extensions/subagent/node_modules/.bin/pi-ai +0 -21
  102. package/extensions/subagent/node_modules/.bin/tsc +0 -21
  103. package/extensions/subagent/node_modules/.bin/tsserver +0 -21
  104. package/extensions/subagent/node_modules/.bin/tsx +0 -21
  105. package/lib/pi-packages.mjs +0 -169
  106. package/skills/planning/create-task/scripts/node_modules/.bin/tsc +0 -21
  107. package/skills/planning/create-task/scripts/node_modules/.bin/tsserver +0 -21
  108. package/skills/planning/create-task/scripts/node_modules/.bin/tsx +0 -21
  109. package/skills/productivity/validate-agent-dir/scripts/node_modules/.bin/tsc +0 -21
  110. package/skills/productivity/validate-agent-dir/scripts/node_modules/.bin/tsserver +0 -21
  111. package/skills/productivity/validate-agent-dir/scripts/node_modules/.bin/tsx +0 -21
  112. package/skills/workflow/run-pipeline-stage/scripts/node_modules/.bin/tsc +0 -21
  113. package/skills/workflow/run-pipeline-stage/scripts/node_modules/.bin/tsserver +0 -21
  114. package/skills/workflow/run-pipeline-stage/scripts/node_modules/.bin/tsx +0 -21
  115. package/test/pi-packages.test.mjs +0 -295
@@ -1,45 +1,116 @@
1
1
  // Pi harness installer: agent-cortex install pi
2
2
  //
3
- // Materialises the agent-cortex package into the pi runtime's user scope:
3
+ // MATERIALISES agent-cortex as a self-contained pi package in a home-scoped
4
+ // directory (~/.agent-cortex/pi) instead of writing into pi's own user
5
+ // directories (~/.pi/agent/…) — the pi analogue of the Claude installer's
6
+ // ~/.agent-cortex/claude — and then REGISTERS it with pi through pi's own
7
+ // `pi install <path>` / `pi remove <source>` package commands. Staging only
8
+ // writes below the output directory; registration never writes pi's settings
9
+ // itself, it drives the CLI that owns them.
4
10
  //
5
- // <output>/agents/<slug>.agent.md composed agents (default ~/.pi/agent/agents)
6
- // <output>/skills/<group>/<name>/ token-substituted skill copies (default ~/.pi/agent/skills)
7
- // <output>/settings.json merged pi settings — CLI owns `packages` (default ~/.pi/agent/settings.json)
8
- // <output>/keybindings.json generated pi keybindings (default ~/.pi/agent/keybindings.json)
11
+ // Generated tree:
12
+ //
13
+ // <output>/package.json pi manifest — keywords ["pi-package"],
14
+ // pi.extensions + pi.skills (conventional
15
+ // dirs plus the bundled third-party packages)
16
+ // <output>/agents/<slug>.agent.md composed flat agents (agents/<name>/
17
+ // agent.md + pi/ harness dirs, {{TOOL:...}} /
18
+ // {{PATH:...}} substituted against
19
+ // token-map.json's pi column). pi package
20
+ // rules have no agent resource type: the
21
+ // shipped agent-modes extension resolves
22
+ // <extension>/../../agents, i.e. this dir
23
+ // <output>/skills/<group>/<name>/ token-substituted skill copies — real
24
+ // files, never symlinks
25
+ // <output>/extensions/ copy of the repo extensions/ tree (loaded
26
+ // by pi's package discovery)
27
+ // <output>/token-map.json the map agent-modes reads from the package
28
+ // root when composing agents
29
+ // <output>/node_modules/<pkg>/ the required third-party pi packages
30
+ // (ask_questions from pi-questions,
31
+ // fetch_content from pi-web-access) and
32
+ // their transitive closure
9
33
  //
10
34
  // Composition per the token-map.json contract:
11
35
  // 1. {{SECTION:name}} is resolved from the agent's pi/<name>.md section file
12
36
  // (shared composer, scripts/lib/compose-agent.mjs).
13
- // 2. {{TOOL:key}} is substituted with the pi column (pi maps ask_user →
14
- // ask_questions and skill → read); any (future) null pi mapping drops the
15
- // token from prose with a warning.
37
+ // 2. {{TOOL:key}} is substituted with the pi column; a null pi mapping drops
38
+ // the token from prose with a warning.
16
39
  // 3. {{PATH:key}} named keys resolve through the paths table; bare relative
17
- // paths resolve against the plugin root (default: token-map.json's pi value,
18
- // overridable with --plugin-root for checkout/symlinked installs).
19
- // Frontmatter tool lists are translated the same way agent-modes does at runtime:
20
- // canonical → pi column, null → omitted, unknown (native pi tool) → pass through.
21
- // Skill markdown files get the same TOOL/PATH substitution so pi never serves
22
- // literal token syntax; other skill files are copied verbatim.
40
+ // paths resolve against the plugin root (default: token-map.json's pi
41
+ // value, which points at this staged package; overridable with
42
+ // --plugin-root for checkout/symlinked installs).
43
+ // Frontmatter tool lists are translated the same way agent-modes does at
44
+ // runtime: canonical → pi column, null → omitted, unknown (native pi tool) →
45
+ // pass through.
46
+ //
47
+ // Third-party packages: the agents need tools that live in other pi packages.
48
+ // They are ordinary runtime dependencies of this package, and the staged tree
49
+ // ships its own copy of each (plus its closure) under node_modules/ — pi loads
50
+ // a local directory and resolves nothing itself, so a staged package without
51
+ // them would hand the agents a model with no ask_questions/fetch_content.
52
+ // Resolution goes through Node (createRequire(import.meta.url).resolve), so it
53
+ // is hoist-agnostic: isolated, hoisted, nested, and global installs all work.
54
+ // A dependency that cannot be resolved fails the install.
55
+ //
56
+ // Re-staging is idempotent: the installer clears the children it owns before
57
+ // writing, so a re-run never accumulates stale copies.
58
+ //
59
+ // pi's own user settings (settings.json, keybindings.json) are NOT written by
60
+ // the installer: they are pi's config, not the package's, and `pi install` is
61
+ // the command that owns them. They are READ (never written) to decide
62
+ // install-vs-no-op and to find stale raw-repo sources; the committed pi/*.json
63
+ // templates remain reference copies.
23
64
  //
24
- // Skills install into ~/.pi/agent/skills (pi's user-global skill dir), which is
25
- // the sole pi skill source: the checkout's raw package skills are not loaded
26
- // locally because pi/settings.json registers agent-cortex with an object-form
27
- // packages filter { source, "skills": [] } (extensions stay enabled). The
28
- // filter exists so pi never sees the raw, un-substituted package skills and
29
- // reports a name collision per skill (pi warns on collisions, user dir wins).
30
- // Consumers who install the npm package without that filter still rely on pi's
31
- // shadowing order (user dir loads before package skills). Re-run this installer
32
- // after any skill edit so the substituted copy in the user dir stays in sync.
65
+ // LEGACY SWEEP (sweepLegacyPiUserDirs): the pre-staged-model installer rendered
66
+ // agents and skills into pi's own user directory (~/.pi/agent). Those copies are
67
+ // now a second source of the same resource — the collision diagnostic the staged
68
+ // model exists to remove — so once the staged package is registered, the
69
+ // installer deletes the copies it can attribute by name: `agents/<name>.agent.md`
70
+ // for a composable agent this package ships or one it once shipped (below), and
71
+ // `skills/<group>/<name>` for a skill it ships or once shipped.
72
+ //
73
+ // It is a ONE-SHOT migration, not a standing rule. Name attribution cannot tell
74
+ // a rendered copy from a file the user wrote under the same name, so the sweep
75
+ // records a finished migration (DEFAULT_LEGACY_SWEEP_RECORD, keyed by the pi
76
+ // user directory) and never attributes anything in that directory again — after
77
+ // the migration the directory is the user's. The record lives under the home
78
+ // install root, beside the staged package, never inside the directory it
79
+ // describes: ~/.pi/agent is left with no agent-cortex artifact in it. pi's
80
+ // extensions directory is never entered: no installer on this flow has ever
81
+ // written there, so everything in it is the user's.
82
+ //
83
+ // Registration (registerPi) is idempotent by STATE, not by exit code: the
84
+ // configured package sources decide install-vs-no-op, so a re-run after
85
+ // `git pull && agent-cortex install pi` is a true no-op (a local package source
86
+ // is read from disk on every pi start — there is nothing to refresh). Sources
87
+ // pointing at a raw agent-cortex checkout (this package's own repo name) are
88
+ // removed first, so exactly one agent-cortex source stays configured; a source
89
+ // that survives its removal fails the run, because two configured sources both
90
+ // load. If the `pi` CLI is missing, the installer warns and prints the exact
91
+ // commands, exiting non-zero only with --require-register.
92
+ //
93
+ // node bin/agent-cortex.mjs install pi
94
+ // materialises into ~/.agent-cortex/pi AND
95
+ // registers that directory with pi (user scope)
96
+ // then sweeps the artifacts the old installer
97
+ // left in ~/.pi/agent (see LEGACY SWEEP above)
98
+ // node bin/agent-cortex.mjs install pi --output <dir>
99
+ // stages into <dir> only — no registration, no
100
+ // sweep (tests/CI)
101
+ // node bin/agent-cortex.mjs install pi --dry-run
102
+ // reports the staging and registration plan
103
+ // without writing or spawning anything
33
104
  //
34
105
  // Zero dependencies so it runs on the CI Node and local Node alike.
35
106
 
36
- import { readFile, writeFile, mkdir, copyFile, readdir, lstat, rename, rm } from "node:fs/promises";
37
- import { createHash } from "node:crypto";
38
- import { join, dirname, basename, relative } from "node:path";
107
+ import { readFile, writeFile, mkdir, copyFile, readdir, rm, stat, lstat } from "node:fs/promises";
108
+ import { spawn } from "node:child_process";
109
+ import { createRequire } from "node:module";
110
+ import { join, dirname, resolve } from "node:path";
39
111
  import { fileURLToPath } from "node:url";
40
112
  import { homedir } from "node:os";
41
113
  import { composeAgent, loadTokenMap, substituteTokens, translateToolList } from "../../scripts/lib/compose-agent.mjs";
42
- import { loadRequiredPackages, provisionPiPackages } from "../../lib/pi-packages.mjs";
43
114
  import { isDirectory, isFile } from "../../scripts/lib/fs.mjs";
44
115
 
45
116
  const PI = "pi";
@@ -50,55 +121,100 @@ const PI = "pi";
50
121
  const CONTRACT_VERSION = 1;
51
122
 
52
123
  const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
53
- const DEFAULT_OUTPUT = join(homedir(), ".pi", "agent");
54
124
 
55
- const SETTINGS_TEMPLATE = "pi/settings.json";
56
- const KEYBINDINGS_TEMPLATE = "pi/keybindings.json";
125
+ // Home-scoped staged package. pi loads it by path once registered, so nothing
126
+ // is written into ~/.pi/agent. Overridable via --output (tests/CI) or HOME
127
+ // (node's os.homedir() honours $HOME on POSIX).
128
+ const DEFAULT_OUTPUT = join(homedir(), ".agent-cortex", "pi");
129
+
130
+ // Name of the generated package. `private` because it is a staged build
131
+ // artifact, never published.
132
+ const STAGED_NAME = "@jaybeeuu/agent-cortex-pi";
57
133
 
58
- // pi parses settings.json / keybindings.json with a bare JSON.parse (see pi's
59
- // SettingsManager / KeybindingsManager), so JSONC comments are not an option —
60
- // a `//` line would make the file unreadable. The GENERATED marker therefore
61
- // lives in a top-level "//" key, the conventional JSON comment stand-in, which
62
- // pi ignores on load and preserves on write.
63
- const GENERATED_KEY = "//";
134
+ // Children of the output directory this installer owns. They are removed
135
+ // before every write so re-staging cannot leave stale copies behind (a skill
136
+ // deleted in the repo, a package dropped from the manifest). Anything else in
137
+ // the output directory is left alone.
138
+ const OWNED_CHILDREN = ["package.json", "token-map.json", "agents", "skills", "extensions", "node_modules"];
139
+
140
+ const GENERATED_MARKER =
141
+ "GENERATED by `agent-cortex install pi` — DO NOT EDIT; re-run the installer to update.";
142
+
143
+ // The `pi` CLI that owns package registration. Overridable in tests via piBin.
144
+ const DEFAULT_PI_BIN = "pi";
145
+
146
+ // pi's user settings file — READ ONLY here (registration writes through the
147
+ // CLI). Its directory is also the base pi resolves relative package sources
148
+ // against. Overridable in tests via settingsPath.
149
+ const DEFAULT_SETTINGS_PATH = join(homedir(), ".pi", "agent", "settings.json");
150
+
151
+ // Source prefixes pi does not treat as local directories (packages.md:
152
+ // npm:, git:, https/github URLs, and built-in extension names).
153
+ const NON_LOCAL_PREFIXES = ["npm:", "git:", "github:", "http:", "https:", "ssh:", "builtin:"];
64
154
 
65
155
  const DEFAULT_WARN = (msg) => console.warn(`[pi-installer] ${msg}`);
66
156
 
157
+ // Artifacts the pre-staged-model installer wrote into pi's own user directory
158
+ // that the package root can no longer name on its own: the orphaned
159
+ // `pipeline-worker` agent (an older render with no canonical source today) and
160
+ // the `migration` skill (authored on a branch this epic does not carry).
161
+ // Everything else is attributed from the package root — see
162
+ // sweepLegacyPiUserDirs. Drop an entry once it is either shipped again (the
163
+ // root-derived rules then cover it) or gone for good.
164
+ const LEGACY_ORPHAN_AGENTS = ["pipeline-worker"];
165
+ const LEGACY_ORPHAN_SKILLS = ["productivity/migration"];
166
+
167
+ // A path record some install flows leave in pi's user directory, listing the
168
+ // copies they wrote there. It describes the same tree the sweep removes, so it
169
+ // goes with the artifacts it lists — but only as part of the one-shot migration.
170
+ const LEGACY_INSTALL_MANIFEST = ".agent-cortex-install.json";
171
+
172
+ // Record of the one-shot migration, keyed by the resolved pi user directory:
173
+ // { "<piUserDir>": { "version": 1, "migratedAt": "<ISO>" } }
174
+ // It sits under the home install root (~/.agent-cortex), not in the directory it
175
+ // describes — after the migration that directory holds no agent-cortex artifact.
176
+ // An entry ends the sweep for its directory: from then on nothing there is
177
+ // attributed to agent-cortex, so an agent or skill the user writes under a
178
+ // shipped name survives every later install. Only a fully successful migration
179
+ // gets an entry, so a copy that could not be removed is retried next install.
180
+ const DEFAULT_LEGACY_SWEEP_RECORD = join(homedir(), ".agent-cortex", "legacy-sweep.json");
181
+
67
182
  /**
68
- * Install the agent-cortex pi harness into the pi runtime.
183
+ * Stage the agent-cortex pi package.
69
184
  *
70
185
  * @param {object} options
71
186
  * @param {string} [options.root] Package root (defaults to this file's repo)
72
- * @param {string} [options.output] Pi user-scope dir; agents go to
73
- * <output>/agents, skills to <output>/skills
74
- * (default ~/.pi/agent)
75
- * @param {boolean} [options.dryRun] Compute and report without writing anything
187
+ * @param {string} [options.output] Staged package dir (default ~/.agent-cortex/pi)
188
+ * @param {boolean} [options.dryRun] Report the plan without writing anything
76
189
  * @param {string} [options.pluginRoot] Override plugin_root used for {{PATH:...}}
77
190
  * resolution (default: token-map pi value)
78
- * @param {boolean} [options.provisionPackages] Install the third-party pi packages
79
- * declared in the package manifest (package.json
80
- * pi.packages) into the pi user scope — e.g.
81
- * pi-questions (ask_questions) and pi-web-access
82
- * (fetch_content). Off by default; `agent-cortex
83
- * install pi` turns it on for a real install.
84
- * @param {(source: string) => Promise<{ok: boolean, error: string | null}>} [options.runInstall]
85
- * Installer for one package source (default: pi install)
191
+ * @param {(name: string, fromDir?: string) => string | Promise<string>} [options.resolvePackage]
192
+ * Resolve an installed dependency to its
193
+ * directory (default: Node resolution from
194
+ * this package's own install)
86
195
  * @param {(msg: string) => void} [options.warn] Warning sink, also collected in `warnings`
87
- * @returns {{ agents: {name:string, filePath:string}[], skills: {skills:number, md:number, files:number, dir:string},
88
- * packages: {planned:string[], installed:string[], failed:{source:string, error:string}[]} | null,
89
- * settings: ManagedFileResult|null, keybindings: ManagedFileResult|null,
90
- * warnings: string[], dryRun: boolean, agentsDir: string, skillsDir: string }}
196
+ * @returns {Promise<{ output: string, agents: {name: string, filePath: string}[],
197
+ * skills: {skills: number, md: number, files: number, dir: string},
198
+ * extensions: {files: number, dir: string},
199
+ * packages: {dir: string, bundled: string[], staged: string[], resources: {extensions: string[], skills: string[]}},
200
+ * manifest: {path: string, manifest: object},
201
+ * tokenMap: string, warnings: string[], dryRun: boolean }>}
91
202
  */
92
203
  export async function installPi(options = {}) {
93
204
  const root = options.root ?? PACKAGE_ROOT;
94
205
  const output = options.output ?? DEFAULT_OUTPUT;
95
206
  const dryRun = options.dryRun ?? false;
207
+ const resolvePackage = options.resolvePackage ?? resolveFromNode;
96
208
  const warnings = [];
97
209
  const warn = (msg) => {
98
210
  warnings.push(msg);
99
211
  (options.warn ?? DEFAULT_WARN)(msg);
100
212
  };
101
213
 
214
+ if (resolve(output) === resolve(root)) {
215
+ throw new Error(`refusing to stage the pi package into the package root ${output} — the installer clears its own output tree`);
216
+ }
217
+
102
218
  const tokenMap = await loadTokenMap(root);
103
219
  if (typeof tokenMap.version === "number" && tokenMap.version > CONTRACT_VERSION) {
104
220
  throw new Error(
@@ -107,27 +223,43 @@ export async function installPi(options = {}) {
107
223
  }
108
224
 
109
225
  const pluginRoot = options.pluginRoot ?? tokenMap.paths.plugin_root?.[PI];
110
- const agentsDir = join(output, "agents");
111
- const skillsDir = join(output, "skills");
112
226
 
113
- const agents = await installAgents({ root, agentsDir, dryRun, pluginRoot, tokenMap, warn });
114
- const skills = await installSkills({ root, skillsDir, dryRun, pluginRoot, tokenMap, warn });
115
- const config = await installConfig({ root, output, dryRun, warn });
227
+ // Resolve the bundled packages before clearing the output: an unresolvable
228
+ // dependency must fail the install without destroying the previous staging.
229
+ const dependencies = await resolvePiPackages({ root, resolvePackage });
116
230
 
117
- // Third-party packages give the composed agents their pi tools (ask_questions
118
- // from pi-questions, fetch_content from pi-web-access). They are declared in
119
- // the package manifest and only provisioned on a real install.
120
- const packages = options.provisionPackages
121
- ? await provisionPiPackages({
122
- required: await loadRequiredPackages(root),
123
- piRoot: output,
124
- dryRun,
125
- runInstall: options.runInstall,
126
- warn,
127
- })
128
- : null;
231
+ if (!dryRun) {
232
+ for (const child of OWNED_CHILDREN) {
233
+ await rm(join(output, child), { recursive: true, force: true });
234
+ }
235
+ await mkdir(output, { recursive: true });
236
+ }
129
237
 
130
- return { agents, skills, packages, ...config, warnings, dryRun, agentsDir, skillsDir };
238
+ const agents = await installAgents({ root, agentsDir: join(output, "agents"), dryRun, pluginRoot, tokenMap, warn });
239
+ const skills = await installSkills({ root, skillsDir: join(output, "skills"), dryRun, pluginRoot, tokenMap, warn });
240
+ const extensions = await installExtensions({ root, output, dryRun });
241
+ const tokenMapFile = await installTokenMap({ output, tokenMap, dryRun });
242
+ const packages = await stagePiPackages({ dependencies, output, resolvePackage, dryRun, warn });
243
+ const manifestObject = await buildManifest({ root, dependencies });
244
+ // Verify before writing the manifest: a package whose declared resources are
245
+ // missing is not loadable, so it must never be reported as staged.
246
+ if (!dryRun) {
247
+ await verifyManifestResources({ output, manifest: manifestObject });
248
+ await writeManifest({ output, manifest: manifestObject });
249
+ }
250
+ const manifest = { path: join(output, "package.json"), manifest: manifestObject };
251
+
252
+ return {
253
+ output,
254
+ agents,
255
+ skills,
256
+ extensions,
257
+ packages,
258
+ manifest,
259
+ tokenMap: tokenMapFile,
260
+ warnings,
261
+ dryRun,
262
+ };
131
263
  }
132
264
 
133
265
  // ─── Agents ──────────────────────────────────────────────────────────────────
@@ -169,7 +301,12 @@ function renderPiAgent(name, fm, tools) {
169
301
  `# GENERATED from agents/${name}/ by bin/installers/pi.mjs — DO NOT EDIT.`,
170
302
  `name: ${JSON.stringify(fm.name)}`,
171
303
  `description: ${JSON.stringify(fm.description)}`,
172
- `tools: ${JSON.stringify(tools.join(" "))}`,
304
+ // pi's parseFrontmatter hands back the scalar as one string, so the tool
305
+ // list must use the separator its consumers split on. The subagent extension
306
+ // (extensions/subagent/agents.ts) turns this into `pi --tools`, whose
307
+ // parser is comma-separated — a space-separated list collapsed to a single
308
+ // bogus name and produced an allowlist matching no tool at all.
309
+ `tools: ${JSON.stringify(tools.join(", "))}`,
173
310
  ];
174
311
  if (fm.model) lines.push(`model: ${JSON.stringify(fm.model)}`);
175
312
  if (fm.argumentHint) lines.push(`argument-hint: ${JSON.stringify(fm.argumentHint)}`);
@@ -186,7 +323,6 @@ async function installSkills({ root, skillsDir, dryRun, pluginRoot, tokenMap, wa
186
323
  const transform = (content) =>
187
324
  substituteTokens(content, PI, tokenMap, { dropNullTools: true, pluginRoot, resolveRelativePaths: true, warn });
188
325
 
189
- if (!dryRun) await mkdir(skillsDir, { recursive: true });
190
326
  let skills = 0;
191
327
  let md = 0;
192
328
  let files = 0;
@@ -196,7 +332,7 @@ async function installSkills({ root, skillsDir, dryRun, pluginRoot, tokenMap, wa
196
332
  for (const name of (await readdir(groupDir)).sort()) {
197
333
  if (!(await isFile(join(groupDir, name, "SKILL.md")))) continue;
198
334
  skills += 1;
199
- const stats = await copyTree(join(groupDir, name), join(skillsDir, group, name), transform, dryRun);
335
+ const stats = await copyTree(join(groupDir, name), join(skillsDir, group, name), { dryRun, transform });
200
336
  md += stats.md;
201
337
  files += stats.files;
202
338
  }
@@ -204,235 +340,747 @@ async function installSkills({ root, skillsDir, dryRun, pluginRoot, tokenMap, wa
204
340
  return { skills, md, files, dir: skillsDir };
205
341
  }
206
342
 
207
- /** Recursively copy a tree; transform .md file contents, copy everything else verbatim. */
208
- async function copyTree(src, dest, transform, dryRun) {
209
- let md = 0;
210
- let files = 0;
211
- const entries = await readdir(src, { withFileTypes: true });
212
- for (const entry of entries) {
213
- const from = join(src, entry.name);
214
- const to = join(dest, entry.name);
215
- if (entry.isDirectory()) {
216
- const stats = await copyTree(from, to, transform, dryRun);
217
- md += stats.md;
218
- files += stats.files;
219
- } else if (entry.isFile()) {
220
- files += 1;
221
- if (entry.name.endsWith(".md")) md += 1;
222
- if (dryRun) continue;
223
- await mkdir(dest, { recursive: true });
224
- if (entry.name.endsWith(".md")) await writeFile(to, transform(await readFile(from, "utf-8")));
225
- else await copyFile(from, to);
343
+ // ─── Extensions ──────────────────────────────────────────────────────────────
344
+
345
+ /**
346
+ * Copy the repo's extensions/ tree into the staged package verbatim.
347
+ *
348
+ * The extensions import `@earendil-works/*`, which pi's loader supplies, so no
349
+ * dependency tree is needed. `node_modules` (pnpm workspace links) and symlinks
350
+ * are never copied: the staged package owns its dependencies and must contain
351
+ * real files only.
352
+ */
353
+ async function installExtensions({ root, output, dryRun }) {
354
+ const src = join(root, "extensions");
355
+ const dir = join(output, "extensions");
356
+ if (!(await isDirectory(src))) return { files: 0, dir };
357
+ const { files } = await copyTree(src, dir, { dryRun });
358
+ return { files, dir };
359
+ }
360
+
361
+ // ─── Token map ───────────────────────────────────────────────────────────────
362
+
363
+ /**
364
+ * Ship token-map.json alongside agents/ — agent-modes loads it from the package
365
+ * root, so the staged extensions keep the same source of truth as the checkout.
366
+ */
367
+ async function installTokenMap({ output, tokenMap, dryRun }) {
368
+ const path = join(output, "token-map.json");
369
+ if (!dryRun) await writeFile(path, `${JSON.stringify(tokenMap, null, 2)}\n`);
370
+ return path;
371
+ }
372
+
373
+ // ─── Third-party pi packages ─────────────────────────────────────────────────
374
+
375
+ /**
376
+ * Resolve the pi packages this package depends on.
377
+ *
378
+ * Every declared runtime dependency is resolved and inspected: one that ships
379
+ * pi resources (a `pi` manifest key) is bundled into the staged package, and
380
+ * its declared resource paths are rewritten relative to node_modules/ so the
381
+ * generated manifest loads them. Ordinary dependencies are left to the usual
382
+ * module resolution.
383
+ *
384
+ * @returns {Promise<{packages: {name: string, dir: string}[], resources: {extensions: string[], skills: string[]}}>}
385
+ */
386
+ async function resolvePiPackages({ root, resolvePackage }) {
387
+ const packages = [];
388
+ const resources = { extensions: [], skills: [] };
389
+
390
+ for (const name of await readDirectDependencies(root)) {
391
+ const dir = await resolvePackage(name);
392
+ const manifest = await readJsonObject(join(dir, "package.json"));
393
+ if (manifest === null || manifest.pi === undefined) continue;
394
+
395
+ packages.push({ name, dir });
396
+ for (const type of ["extensions", "skills"]) {
397
+ for (const entry of asStringList(manifest.pi[type])) {
398
+ resources[type].push(`node_modules/${name}/${stripRelative(entry)}`);
399
+ }
226
400
  }
227
401
  }
228
- return { md, files };
402
+
403
+ return { packages, resources };
229
404
  }
230
- // ─── Pi config files ─────────────────────────────────────────────────────────
231
405
 
232
406
  /**
233
- * Materialise the pi config files into the agent dir.
407
+ * Copy the resolved packages and their transitive closure into <output>/node_modules.
234
408
  *
235
- * `settings.json` is merged: the committed template supplies defaults for keys
236
- * the live file lacks, while every value already present in the live file wins,
237
- * so personal config (and anything pi itself wrote) survives re-install. The
238
- * one key the CLI owns outright is `packages`: it is rewritten from the
239
- * template with the repo path entry resolved against the target settings file.
240
- *
241
- * `keybindings.json` follows a checksum rule: a missing file or a legacy
242
- * symlink is materialised from the template, an unmodified install is refreshed
243
- * when the template changes, and a file edited after install is left untouched.
409
+ * The walk resolves every dependency from its dependent's own directory, so the
410
+ * source layout is irrelevant. Copies are real files (never symlinks) and stay
411
+ * flat, matching what pi loads from a local package; a package that resolves to
412
+ * two different versions keeps the first and warns.
244
413
  */
245
- async function installConfig({ root, output, dryRun, warn }) {
414
+ async function stagePiPackages({ dependencies, output, resolvePackage, dryRun, warn }) {
415
+ const staged = new Map();
416
+ const pending = dependencies.packages.map(({ name, dir }) => ({ name, dir }));
417
+
418
+ while (pending.length > 0) {
419
+ const { name, dir } = pending.shift();
420
+ const seen = staged.get(name);
421
+ if (seen !== undefined) {
422
+ if (seen !== dir) warn(`pi package "${name}" resolves to two directories (${seen}, ${dir}) — keeping the first`);
423
+ continue;
424
+ }
425
+ staged.set(name, dir);
426
+ for (const dependency of await readDependencyNames(dir)) {
427
+ pending.push({ name: dependency, dir: await resolvePackage(dependency, dir) });
428
+ }
429
+ }
430
+
431
+ const nodeModulesDir = join(output, "node_modules");
432
+ if (!dryRun) {
433
+ for (const [name, dir] of staged) await copyTree(dir, join(nodeModulesDir, name), {});
434
+ }
246
435
  return {
247
- settings: await installSettings({ root, output, dryRun, warn }),
248
- keybindings: await installKeybindings({ root, output, dryRun, warn }),
436
+ dir: nodeModulesDir,
437
+ bundled: dependencies.packages.map(({ name }) => name),
438
+ staged: [...staged.keys()],
439
+ resources: dependencies.resources,
249
440
  };
250
441
  }
251
442
 
252
- async function installSettings({ root, output, dryRun, warn }) {
253
- const template = await readTemplateJson(join(root, SETTINGS_TEMPLATE), warn);
254
- if (template === null) return null;
255
-
256
- const target = join(output, "settings.json");
257
- const merged = mergeSettings(template, await readJsonObject(target, warn));
258
- merged.packages = resolvePackages(template.packages, root, dirname(target));
259
- delete merged[GENERATED_KEY];
260
-
261
- const content = `${JSON.stringify(
262
- { [GENERATED_KEY]: generatedMarker(SETTINGS_TEMPLATE), ...merged },
263
- null,
264
- 2,
265
- )}\n`;
266
- return writeManagedJson({ target, content, dryRun });
267
- }
268
-
269
- async function installKeybindings({ root, output, dryRun, warn }) {
270
- const template = await readTemplateJson(join(root, KEYBINDINGS_TEMPLATE), warn);
271
- if (template === null) return null;
272
-
273
- const target = join(output, "keybindings.json");
274
- const content = `${JSON.stringify(
275
- { [GENERATED_KEY]: generatedMarker(KEYBINDINGS_TEMPLATE, checksum(template)), ...template },
276
- null,
277
- 2,
278
- )}\n`;
279
-
280
- const current = await readFileMaybe(target);
281
- if (current !== null && !(await isSymlink(target)) && current !== content) {
282
- const parsed = tryParseJsonObject(current);
283
- // An install we wrote carries the checksum it recorded; a body that no
284
- // longer matches was edited by hand and must not be clobbered. A body
285
- // byte-identical to the template is a legacy copy and safe to refresh.
286
- const recorded = parsed === null ? null : readRecordedChecksum(parsed[GENERATED_KEY]);
287
- const unmodified = parsed !== null && (checksum(parsed) === checksum(template) || recorded === checksum(parsed));
288
- if (!unmodified) {
289
- warn(`${target} was modified after install — leaving it untouched (delete it to re-adopt the template)`);
290
- return { path: target, action: "skipped", removedSymlink: false };
443
+ /**
444
+ * Node's own resolution from this package's install — hoist-agnostic by
445
+ * construction. A package's `package.json` is not always exported, so the
446
+ * package directory is found by resolving an entry point and walking up to the
447
+ * enclosing manifest that names it.
448
+ */
449
+ async function resolveFromNode(name, fromDir = PACKAGE_ROOT) {
450
+ const req = createRequire(join(fromDir, "package.json"));
451
+ let entry = null;
452
+ for (const specifier of [name, `${name}/package.json`]) {
453
+ try {
454
+ entry = req.resolve(specifier);
455
+ break;
456
+ } catch {
457
+ // try the next form — `exports` may hide either subpath
291
458
  }
292
459
  }
293
- return writeManagedJson({ target, content, dryRun });
294
- }
460
+ if (entry === null) {
461
+ throw new Error(`cannot resolve pi package "${name}" from ${fromDir} — run \`pnpm install\` first`);
462
+ }
295
463
 
296
- /** Human-readable marker written as the `//` key of every generated config file. */
297
- function generatedMarker(from, templateChecksum) {
298
- const base = `GENERATED from ${from} by \`agent-cortex install pi\` — DO NOT EDIT; re-run the installer to update.`;
299
- return templateChecksum ? `${base} template-checksum:${templateChecksum}` : base;
464
+ let dir = dirname(entry);
465
+ while (true) {
466
+ const manifest = await readJsonObject(join(dir, "package.json"));
467
+ if (manifest?.name === name) return dir;
468
+ const parent = dirname(dir);
469
+ if (parent === dir) throw new Error(`cannot locate the package directory of "${name}" resolved from ${fromDir}`);
470
+ dir = parent;
471
+ }
300
472
  }
301
473
 
302
- /** SHA-256 of a config body with the generated marker stripped, so it is stable across writes. */
303
- function checksum(obj) {
304
- const { [GENERATED_KEY]: _marker, ...body } = obj;
305
- return createHash("sha256").update(JSON.stringify(body, null, 2)).digest("hex");
474
+ async function readDirectDependencies(root) {
475
+ const manifest = await readJsonObject(join(root, "package.json"));
476
+ return manifest === null ? [] : Object.keys(manifest.dependencies ?? {});
306
477
  }
307
478
 
308
- function readRecordedChecksum(marker) {
309
- if (typeof marker !== "string") return null;
310
- const match = /template-checksum:([0-9a-f]{64})/.exec(marker);
311
- return match ? match[1] : null;
479
+ async function readDependencyNames(dir) {
480
+ const manifest = await readJsonObject(join(dir, "package.json"));
481
+ return manifest === null ? [] : Object.keys(manifest.dependencies ?? {});
312
482
  }
313
483
 
484
+ // ─── Registration ────────────────────────────────────────────────────────────
485
+
314
486
  /**
315
- * Merge the committed template over the live file. Template keys fill in
316
- * defaults, live values win (deeply), and keys the template does not know about
317
- * are preserved — re-installing never discards personal or pi-written config.
487
+ * Register the staged package with pi so pi loads its resources.
488
+ *
489
+ * Registration goes through pi's own package commands — `pi install <path>`
490
+ * appends a local-path entry to pi's user `settings.json` (user scope, no
491
+ * copying) and `pi remove <source>` drops one — because pi owns that file. The
492
+ * installer only READS it, to decide the plan by state:
493
+ *
494
+ * `packages` already holds the staged path → no install (local package
495
+ * sources are read from disk on every pi start, so a re-run after
496
+ * `git pull && agent-cortex install pi` is a true no-op)
497
+ * a source resolves to a raw agent-cortex checkout (this package's own name)
498
+ * → `pi remove` it first, so exactly one agent-cortex source stays
499
+ *
500
+ * A superseded source that survives its removal fails the run: pi would load
501
+ * both sources side by side, so reporting success would leave the very state
502
+ * this step exists to prevent. Nothing is installed on that path, and the
503
+ * error names the source still configured.
504
+ *
505
+ * When the `pi` CLI cannot be run, the staged package is left unregistered:
506
+ * the installer warns, prints the exact commands, and fails only with
507
+ * `requireRegister` (the file side of the install already succeeded).
508
+ *
509
+ * @param {object} options
510
+ * @param {string} [options.root] Package root whose package.json name
511
+ * identifies stale raw-repo sources
512
+ * @param {string} [options.output] Staged package dir (default ~/.agent-cortex/pi)
513
+ * @param {string} [options.settingsPath] pi user settings (read only)
514
+ * @param {string} [options.piBin] The `pi` executable (default: `pi` from PATH)
515
+ * @param {boolean} [options.dryRun] Plan and report without spawning anything
516
+ * @param {boolean} [options.requireRegister] Throw when registration cannot run
517
+ * @param {(msg: string) => void} [options.warn] Warning sink (default: console.warn)
518
+ * @returns {Promise<{ output: string, piBin: string, settingsPath: string, registered: boolean,
519
+ * removed: string[], commands: string[][], dryRun: boolean }>}
318
520
  */
319
- function mergeSettings(template, existing) {
320
- const merged = {};
321
- for (const key of Object.keys(template)) {
322
- const templateValue = template[key];
323
- const existingValue = existing[key];
324
- if (isPlainObject(templateValue) && isPlainObject(existingValue)) {
325
- merged[key] = mergeSettings(templateValue, existingValue);
326
- } else {
327
- merged[key] = key in existing ? existingValue : templateValue;
521
+ export async function registerPi({
522
+ root = PACKAGE_ROOT,
523
+ output = DEFAULT_OUTPUT,
524
+ settingsPath = DEFAULT_SETTINGS_PATH,
525
+ piBin = DEFAULT_PI_BIN,
526
+ dryRun = false,
527
+ requireRegister = false,
528
+ warn = DEFAULT_WARN,
529
+ } = {}) {
530
+ const stagedPath = resolve(output);
531
+ const rawPackageName = (await readJsonObject(join(root, "package.json")))?.name;
532
+ const sources = await readConfiguredPackages(settingsPath);
533
+ const { registered, stale } = await classifySources({ sources, settingsPath, stagedPath, rawPackageName });
534
+
535
+ // The same sequence a human would run by hand, in the order the installer
536
+ // runs it: drop the superseded sources, then register the staged package.
537
+ const manualCommands = [...stale.map(({ path }) => `${piBin} remove ${path}`), `${piBin} install ${stagedPath}`];
538
+
539
+ const summary = { output: stagedPath, piBin, settingsPath, registered: false, removed: [], commands: [], dryRun };
540
+ for (const { path } of stale) summary.commands.push(["remove", path]);
541
+ if (!registered) summary.commands.push(["install", stagedPath]);
542
+
543
+ console.log(`Registering the staged pi package with pi (via ${piBin})…`);
544
+
545
+ if (dryRun) {
546
+ console.log(` would read: ${settingsPath} (install-vs-no-op and stale raw-repo sources)`);
547
+ for (const { path } of stale) console.log(` would run: ${piBin} remove ${path}`);
548
+ if (registered) console.log(` already configured: ${stagedPath}`);
549
+ else console.log(` would run: ${piBin} install ${stagedPath}`);
550
+ console.log("(dry-run — no pi state written)");
551
+ summary.registered = registered;
552
+ return summary;
553
+ }
554
+
555
+ // Nothing to change: do not spawn the CLI at all (a re-run is a true no-op).
556
+ if (summary.commands.length === 0) {
557
+ console.log(` ✓ Already configured — ${stagedPath} (no-op)`);
558
+ summary.registered = true;
559
+ return summary;
560
+ }
561
+
562
+ const probe = await probePi(piBin);
563
+ if (!probe.ok) {
564
+ warn(
565
+ `pi CLI "${piBin}" ${probe.detail} — the package is staged at ${stagedPath} but NOT registered with pi. ` +
566
+ `Complete registration manually with:\n` +
567
+ manualCommands.map((command) => ` ${command}`).join("\n"),
568
+ );
569
+ if (requireRegister) {
570
+ throw new Error("registration required (--require-register) but the pi CLI cannot drive it — run the commands above and re-run");
328
571
  }
572
+ return summary;
329
573
  }
330
- for (const key of Object.keys(existing)) {
331
- if (!(key in merged)) merged[key] = existing[key];
574
+
575
+ for (const { path } of stale) {
576
+ try {
577
+ console.log(` ${piBin} remove ${path}`);
578
+ await runPiCommand(piBin, ["remove", path]);
579
+ summary.removed.push(path);
580
+ } catch (err) {
581
+ // Two configured sources both load, so a source we cannot drop means the
582
+ // registration failed — installing now would leave pi with a raw repo
583
+ // checkout and the staged package at once, and every re-run would repeat
584
+ // the same non-converging cycle. Fail instead of reporting success.
585
+ throw new Error(
586
+ `${err.message} — the superseded source is still configured: ${path}. ` +
587
+ `Remove it with "${piBin} remove ${path}" and re-run`,
588
+ );
589
+ }
332
590
  }
333
- return merged;
591
+
592
+ // Execute exactly the plan built above: an already-configured staged path is
593
+ // not re-installed (it may still reach here because a stale source had to be
594
+ // removed first), so the commands in `summary` are the commands that ran.
595
+ if (!registered) {
596
+ if (!(await isDirectory(stagedPath))) {
597
+ throw new Error(`nothing staged at ${stagedPath} — run "agent-cortex install pi" without --dry-run first`);
598
+ }
599
+ console.log(` ${piBin} install ${stagedPath}`);
600
+ await runPiCommand(piBin, ["install", stagedPath]);
601
+ }
602
+
603
+ summary.registered = true;
604
+ console.log(
605
+ registered
606
+ ? `Superseded source(s) removed — ${stagedPath} stays registered (restart pi to apply).`
607
+ : `Registered — verify with "${piBin} list" and "${piBin} config" (restart pi to apply).`,
608
+ );
609
+ return summary;
610
+ }
611
+
612
+ /**
613
+ * Local package sources configured in pi's settings file, in order. pi stores
614
+ * each entry as a source string or a `{ source, … }` filter object.
615
+ */
616
+ async function readConfiguredPackages(settingsPath) {
617
+ const settings = await readJsonObject(settingsPath);
618
+ const packages = settings?.packages;
619
+ if (!Array.isArray(packages)) return [];
620
+ return packages.map(packageSource).filter((source) => source !== undefined);
621
+ }
622
+
623
+ function packageSource(entry) {
624
+ if (typeof entry === "string") return entry;
625
+ return typeof entry?.source === "string" ? entry.source : undefined;
626
+ }
627
+
628
+ function isLocalSource(source) {
629
+ return !NON_LOCAL_PREFIXES.some((prefix) => source.trim().startsWith(prefix));
630
+ }
631
+
632
+ /**
633
+ * Resolve a configured source the way pi does: relative to the directory
634
+ * holding the settings file, with `~` expanded.
635
+ */
636
+ function resolveSourcePath(source, settingsDir) {
637
+ const trimmed = source.trim();
638
+ const expanded = trimmed === "~" ? homedir() : trimmed.startsWith("~/") ? join(homedir(), trimmed.slice(2)) : trimmed;
639
+ return resolve(settingsDir, expanded);
640
+ }
641
+
642
+ /**
643
+ * Split the configured local sources into "the staged package is already
644
+ * registered" and "a raw agent-cortex checkout superseded by it". A source
645
+ * whose directory is gone is left alone: a missing path cannot be told apart
646
+ * from a temporarily absent one, and removing it could drop a package the user
647
+ * still wants.
648
+ */
649
+ async function classifySources({ sources, settingsPath, stagedPath, rawPackageName }) {
650
+ const settingsDir = dirname(settingsPath);
651
+ const stale = [];
652
+ let registered = false;
653
+ for (const source of sources) {
654
+ if (!isLocalSource(source)) continue;
655
+ const path = resolveSourcePath(source, settingsDir);
656
+ if (path === stagedPath) {
657
+ registered = true;
658
+ continue;
659
+ }
660
+ if (rawPackageName === undefined) continue;
661
+ if ((await readJsonObject(join(path, "package.json")))?.name === rawPackageName) stale.push({ path });
662
+ }
663
+ return { registered, stale };
334
664
  }
335
665
 
336
666
  /**
337
- * Build the CLI-managed packages list: the template entries with the repo path
338
- * package resolved relative to the target settings file. pi resolves user-scope
339
- * local package paths against its agent dir, so this keeps the checkout (or npm
340
- * install) registered wherever the settings file lands.
667
+ * Probe whether the `pi` CLI can be run at all, with output suppressed.
668
+ * Resolves `{ ok: true }` or `{ ok: false, detail }` naming the failure.
341
669
  */
342
- function resolvePackages(packages, root, settingsDir) {
343
- if (!Array.isArray(packages)) return packages;
344
- const repoSource = relative(settingsDir, root) || ".";
345
- let resolved = false;
346
- const out = packages.map((entry) => {
347
- const source = typeof entry === "string" ? entry : entry?.source;
348
- if (resolved || typeof source !== "string" || source.startsWith("npm:")) return entry;
349
- resolved = true;
350
- return typeof entry === "string" ? repoSource : { ...entry, source: repoSource };
670
+ function probePi(piBin) {
671
+ return new Promise((resolve) => {
672
+ const child = spawn(piBin, ["--version"], { stdio: "ignore" });
673
+ child.on("error", (err) =>
674
+ resolve({ ok: false, detail: `cannot be run (${err.message}) — is pi installed and on PATH?` }),
675
+ );
676
+ child.on("exit", (code, signal) =>
677
+ resolve(
678
+ code === 0
679
+ ? { ok: true }
680
+ : { ok: false, detail: code === null ? `was terminated by ${signal}` : `"${piBin} --version" exited ${code}` },
681
+ ),
682
+ );
683
+ });
684
+ }
685
+
686
+ /** Run one pi package command, inheriting its output; rejects on failure. */
687
+ function runPiCommand(piBin, args) {
688
+ const label = `${piBin} ${args.join(" ")}`;
689
+ return new Promise((resolve, reject) => {
690
+ const child = spawn(piBin, args, { stdio: "inherit" });
691
+ child.on("error", (err) => reject(new Error(`failed to run "${label}": ${err.message}`)));
692
+ child.on("exit", (code, signal) => {
693
+ if (code === 0) resolve();
694
+ else if (code !== null) reject(new Error(`"${label}" exited ${code}`));
695
+ else reject(new Error(`"${label}" was terminated by ${signal}`));
696
+ });
351
697
  });
352
- if (!resolved) out.unshift({ source: repoSource, skills: [] });
353
- return out;
354
698
  }
355
699
 
700
+ // ─── Legacy sweep ────────────────────────────────────────────────────────────
701
+
356
702
  /**
357
- * Write a managed config file. A legacy symlink is replaced with a real file by
358
- * writing a sibling temp file and renaming it over the target — rename replaces
359
- * the link itself, so the repo template is never written through. Returns the
360
- * action taken so the CLI can report the plan (dry-run included).
703
+ * Delete the artifacts the pre-staged-model installer rendered into pi's own
704
+ * user directory (~/.pi/agent), now that the staged package is the single
705
+ * source.
706
+ *
707
+ * This is a one-shot migration: it runs once per pi user directory and records
708
+ * the finished migration in `recordPath` — outside that directory — after which
709
+ * it reports nothing and removes nothing. Only artifacts this package can name
710
+ * are removed, and only on that single run:
711
+ *
712
+ * agents/<name>.agent.md `<name>` is a composable agent the package
713
+ * root ships, or a LEGACY_ORPHAN_AGENTS entry
714
+ * skills/<group>/<name>/ the package root ships `skills/<group>/<name>`,
715
+ * or it is a LEGACY_ORPHAN_SKILLS entry
716
+ * skills/<group>/ a group dir this run emptied
717
+ * .agent-cortex-install.json a path record listing the copies removed
718
+ *
719
+ * pi's extensions directory is not touched at all: no installer on this flow
720
+ * wrote there, so its contents are the user's. Everything not removed — config
721
+ * files, the user's own agents and skills — is reported as `kept`.
722
+ *
723
+ * Nothing is deleted and no record is written when `dryRun` is set; the plan is
724
+ * reported instead. A pi user directory that does not exist is left uncreated.
725
+ *
726
+ * The migration is recorded only when every artifact was removed: an entry
727
+ * written over a partial migration would claim a directory is the user's while
728
+ * the copies it exists to delete are still in it, so the removal failures are
729
+ * warned about and the next install retries them. A directory the sweep cannot
730
+ * read is a failure of the same kind, not an empty one — it cannot attribute
731
+ * what it cannot see, so it warns and leaves the migration unrecorded rather
732
+ * than claiming a directory it never looked at. Recording is best-effort — a
733
+ * record that cannot be written warns rather than failing an install whose
734
+ * registration already succeeded.
735
+ *
736
+ * @param {object} options
737
+ * @param {string} [options.root] Package root that names the rendered copies
738
+ * @param {string} options.piUserDir pi's own user directory (~/.pi/agent)
739
+ * @param {string} [options.recordPath] Migration record (default ~/.agent-cortex/legacy-sweep.json)
740
+ * @param {boolean} [options.dryRun] Report the plan without deleting anything
741
+ * @param {(msg: string) => void} [options.warn] Warning sink
742
+ * @returns {Promise<{ removed: string[], kept: string[] }>} Absolute paths
361
743
  */
362
- async function writeManagedJson({ target, content, dryRun }) {
363
- const symlink = await isSymlink(target);
364
- const current = await readFileMaybe(target);
744
+ export async function sweepLegacyPiUserDirs({
745
+ root = PACKAGE_ROOT,
746
+ piUserDir,
747
+ recordPath = DEFAULT_LEGACY_SWEEP_RECORD,
748
+ dryRun = false,
749
+ warn = DEFAULT_WARN,
750
+ } = {}) {
751
+ const removed = [];
752
+ const kept = [];
753
+ const failed = [];
365
754
 
366
- if (dryRun) {
367
- return {
368
- path: target,
369
- action: symlink ? "would-remove-symlink" : current === content ? "unchanged" : "would-write",
370
- removedSymlink: symlink,
371
- };
755
+ if (!(await isDirectory(piUserDir))) return { removed, kept };
756
+ if (await hasMigrated({ recordPath, piUserDir, warn })) return { removed, kept };
757
+
758
+ // Commands that record what happened and either delete the path or keep it.
759
+ const sweep = async (path, isLegacy) => {
760
+ if (!isLegacy) {
761
+ kept.push(path);
762
+ return;
763
+ }
764
+ if (dryRun) {
765
+ removed.push(path);
766
+ return;
767
+ }
768
+ try {
769
+ await rm(path, { recursive: true, force: true });
770
+ removed.push(path);
771
+ } catch (err) {
772
+ warn(`could not remove the legacy artifact ${path}: ${err.message}`);
773
+ kept.push(path);
774
+ failed.push(path);
775
+ }
776
+ };
777
+
778
+ const shippedAgents = await shippedAgentNames(root);
779
+ const shippedSkills = await shippedSkillPaths(root);
780
+
781
+ // A directory this run cannot read is not an empty one. The sweep cannot
782
+ // attribute what it cannot see, so it warns and counts the directory as a
783
+ // failure — which is what keeps the migration unrecorded, so the next install
784
+ // retries once the permissions allow it.
785
+ const unreadable = (dir) => {
786
+ warn(
787
+ `could not list ${dir} — the legacy sweep cannot tell its contents from the user's, ` +
788
+ `so the migration is not recorded and runs again on the next install`,
789
+ );
790
+ failed.push(dir);
791
+ };
792
+
793
+ // Entries of a sweep site: none for a directory that is absent, and a recorded
794
+ // failure for one that exists but cannot be read.
795
+ const list = async (dir) => {
796
+ const entries = await listDir(dir);
797
+ if (entries === null) unreadable(dir);
798
+ return entries ?? [];
799
+ };
800
+
801
+ if (shippedAgents === null || shippedSkills === null) {
802
+ // Attribution resolves from the package root, so an unreadable tree there
803
+ // makes every copy look like a file this package does not ship: sweeping now
804
+ // would record a migration that removed nothing.
805
+ unreadable(join(root, shippedAgents === null ? "agents" : "skills"));
806
+ return { removed, kept };
807
+ }
808
+
809
+ const agentsDir = join(piUserDir, "agents");
810
+ const agentFileSuffix = ".agent.md";
811
+ for (const entry of await list(agentsDir)) {
812
+ const name = entry.endsWith(agentFileSuffix) ? entry.slice(0, -agentFileSuffix.length) : null;
813
+ const isLegacy = name !== null && (shippedAgents.has(name) || LEGACY_ORPHAN_AGENTS.includes(name));
814
+ await sweep(join(agentsDir, entry), isLegacy);
372
815
  }
373
- if (!symlink && current === content) {
374
- return { path: target, action: "unchanged", removedSymlink: false };
816
+
817
+ const skillsDir = join(piUserDir, "skills");
818
+ for (const group of await list(skillsDir)) {
819
+ const groupDir = join(skillsDir, group);
820
+ // A symlinked group is a door out of pi's user directory: walking through
821
+ // it would delete the link target's contents, which is not this sweep's.
822
+ if (!(await isRealDirectory(groupDir))) {
823
+ kept.push(groupDir);
824
+ continue;
825
+ }
826
+ const removedBefore = removed.length;
827
+ for (const entry of await list(groupDir)) {
828
+ const skill = `${group}/${entry}`;
829
+ await sweep(join(groupDir, entry), shippedSkills.has(skill) || LEGACY_ORPHAN_SKILLS.includes(skill));
830
+ }
831
+ // A group dir this run emptied held the old rendering's copies, so it is
832
+ // part of that rendering. One that was already empty is not: the user may
833
+ // have made it. One that cannot be re-listed is not empty either — removing
834
+ // it would delete whatever is inside it unseen.
835
+ if (removed.length > removedBefore) {
836
+ const remaining = await listDir(groupDir);
837
+ if (remaining === null) unreadable(groupDir);
838
+ else if (remaining.length === 0) await sweep(groupDir, true);
839
+ }
375
840
  }
376
841
 
377
- await mkdir(dirname(target), { recursive: true });
378
- const tmp = join(dirname(target), `.${basename(target)}.${process.pid}.tmp`);
842
+ await sweep(join(piUserDir, LEGACY_INSTALL_MANIFEST), await isFile(join(piUserDir, LEGACY_INSTALL_MANIFEST)));
843
+
844
+ if (!dryRun) {
845
+ if (failed.length === 0) {
846
+ await recordMigration({ recordPath, piUserDir, warn });
847
+ } else {
848
+ warn(
849
+ `the legacy sweep of ${piUserDir} is incomplete — ${failed.length} artifact(s) could not be removed, ` +
850
+ `so the migration is not recorded and runs again on the next install`,
851
+ );
852
+ }
853
+ }
854
+
855
+ return { removed, kept };
856
+ }
857
+
858
+ /**
859
+ * Whether this pi user directory has already been migrated.
860
+ *
861
+ * A record that exists but cannot be read is treated as "already migrated":
862
+ * the state is ambiguous and a deletion is not recoverable, so the sweep never
863
+ * runs on an unreadable record.
864
+ */
865
+ async function hasMigrated({ recordPath, piUserDir, warn }) {
866
+ let raw;
379
867
  try {
380
- await writeFile(tmp, content);
381
- await rename(tmp, target);
868
+ raw = await readFile(recordPath, "utf-8");
382
869
  } catch (err) {
383
- await rm(tmp, { force: true });
384
- throw err;
870
+ if (err.code === "ENOENT") return false;
871
+ warn(`could not read the legacy sweep record ${recordPath}: ${err.message} — the migration will not run again`);
872
+ return true;
385
873
  }
386
- return { path: target, action: symlink ? "removed-symlink" : "written", removedSymlink: symlink };
874
+ const entries = parseRecordEntries(raw);
875
+ if (entries === null) {
876
+ warn(`ignoring the unreadable legacy sweep record ${recordPath} — the migration will not run again`);
877
+ return true;
878
+ }
879
+ return entries[resolve(piUserDir)] !== undefined;
387
880
  }
388
881
 
389
- async function readTemplateJson(filePath, warn) {
390
- const raw = await readFileMaybe(filePath);
391
- if (raw === null) {
392
- warn(`no ${filePath} template in the package — skipping`);
393
- return null;
882
+ /**
883
+ * Add this pi user directory to the migration record. Existing entries are kept
884
+ * — another pi config may have migrated already and must not be re-run. A write
885
+ * failure warns instead of throwing: registration already succeeded, so an
886
+ * unwritable record is not a reason to fail the install.
887
+ */
888
+ async function recordMigration({ recordPath, piUserDir, warn }) {
889
+ const entries = await readRecordEntries(recordPath);
890
+ entries[resolve(piUserDir)] = { version: 1, migratedAt: new Date().toISOString() };
891
+ try {
892
+ await mkdir(dirname(recordPath), { recursive: true });
893
+ await writeFile(recordPath, `${JSON.stringify(entries, null, 2)}\n`);
894
+ } catch (err) {
895
+ warn(
896
+ `could not record the legacy sweep at ${recordPath}: ${err.message} — a later install may run the migration again`,
897
+ );
394
898
  }
395
- const parsed = tryParseJsonObject(raw);
396
- if (parsed === null) throw new Error(`${filePath} is not a JSON object`);
397
- return parsed;
398
899
  }
399
900
 
400
- async function readJsonObject(filePath, warn) {
401
- const raw = await readFileMaybe(filePath);
402
- if (raw === null) return {};
403
- const parsed = tryParseJsonObject(raw);
404
- if (parsed === null) {
405
- warn(`${filePath} is not a JSON object — ignoring its contents`);
901
+ /** Record entries keyed by pi user directory; `{}` when it is absent or unusable. */
902
+ async function readRecordEntries(recordPath) {
903
+ try {
904
+ return parseRecordEntries(await readFile(recordPath, "utf-8")) ?? {};
905
+ } catch {
406
906
  return {};
407
907
  }
408
- return parsed;
409
908
  }
410
909
 
411
- function tryParseJsonObject(raw) {
910
+ /** The record's `{ "<piUserDir>": entry }` object, or null when the text is not one. */
911
+ function parseRecordEntries(raw) {
912
+ let parsed;
412
913
  try {
413
- const parsed = JSON.parse(raw);
414
- return isPlainObject(parsed) ? parsed : null;
914
+ parsed = JSON.parse(raw);
915
+ } catch {
916
+ return null;
917
+ }
918
+ return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed : null;
919
+ }
920
+
921
+ /** Whether `path` is a directory without following a symlink to one. */
922
+ async function isRealDirectory(path) {
923
+ try {
924
+ return (await lstat(path)).isDirectory();
415
925
  } catch {
926
+ return false;
927
+ }
928
+ }
929
+
930
+ /** Composable agent names the package root ships (`agents/<name>/agent.md`), or null when it cannot be listed. */
931
+ async function shippedAgentNames(root) {
932
+ const entries = await listDir(join(root, "agents"));
933
+ if (entries === null) return null;
934
+ const names = new Set();
935
+ for (const entry of entries) {
936
+ if (await isFile(join(root, "agents", entry, "agent.md"))) names.add(entry);
937
+ }
938
+ return names;
939
+ }
940
+
941
+ /** Skill paths the package root ships (`skills/<group>/<name>/SKILL.md`), or null when it cannot be listed. */
942
+ async function shippedSkillPaths(root) {
943
+ const groups = await listDir(join(root, "skills"));
944
+ if (groups === null) return null;
945
+ const paths = new Set();
946
+ for (const group of groups) {
947
+ const names = await listDir(join(root, "skills", group));
948
+ if (names === null) return null;
949
+ for (const name of names) {
950
+ if (await isFile(join(root, "skills", group, name, "SKILL.md"))) paths.add(`${group}/${name}`);
951
+ }
952
+ }
953
+ return paths;
954
+ }
955
+
956
+ /**
957
+ * Sorted names in `dir`: none for a directory that does not exist, `null` for
958
+ * one that exists but cannot be read.
959
+ *
960
+ * The two must not collapse into one answer: the sweep records a finished
961
+ * migration, so a listing failure read as an empty directory would record one
962
+ * that removed nothing and never retry it.
963
+ */
964
+ async function listDir(dir) {
965
+ try {
966
+ return (await readdir(dir)).sort();
967
+ } catch (err) {
968
+ if (err.code === "ENOENT" || err.code === "ENOTDIR") return [];
416
969
  return null;
417
970
  }
418
971
  }
419
972
 
420
- function isPlainObject(value) {
421
- return typeof value === "object" && value !== null && !Array.isArray(value);
973
+ // ─── Manifest ────────────────────────────────────────────────────────────────
974
+
975
+ /**
976
+ * Build the staged package's pi manifest.
977
+ *
978
+ * pi's package rules do the loading: conventional dirs are declared explicitly
979
+ * so the bundled packages' resources are included alongside ./extensions and
980
+ * ./skills (one pi-visible source, no tokenised repo copies).
981
+ */
982
+ async function buildManifest({ root, dependencies }) {
983
+ const pkg = await readJsonObject(join(root, "package.json"));
984
+ return {
985
+ "//": GENERATED_MARKER,
986
+ name: STAGED_NAME,
987
+ version: typeof pkg?.version === "string" ? pkg.version : "0.0.0",
988
+ private: true,
989
+ description: "agent-cortex pi resources — generated by `agent-cortex install pi`",
990
+ type: "module",
991
+ keywords: ["pi-package"],
992
+ pi: {
993
+ extensions: ["./extensions", ...dependencies.resources.extensions],
994
+ skills: ["./skills", ...dependencies.resources.skills],
995
+ },
996
+ };
997
+ }
998
+
999
+ async function writeManifest({ output, manifest }) {
1000
+ const path = join(output, "package.json");
1001
+ await writeFile(path, `${JSON.stringify(manifest, null, 2)}\n`);
1002
+ return { path, manifest };
422
1003
  }
423
1004
 
424
- async function readFileMaybe(filePath) {
1005
+ /**
1006
+ * Prove the manifest references real resources before reporting a success: a
1007
+ * staged package whose declared extension or skill path does not exist would
1008
+ * load silently short of the tools the agents expect.
1009
+ */
1010
+ async function verifyManifestResources({ output, manifest }) {
1011
+ const declared = [...manifest.pi.extensions, ...manifest.pi.skills];
1012
+ const missing = [];
1013
+ for (const rel of declared) {
1014
+ if (!(await pathExists(join(output, rel)))) missing.push(rel);
1015
+ }
1016
+ if (missing.length > 0) {
1017
+ throw new Error(`staged pi manifest references missing resources: ${missing.join(", ")}`);
1018
+ }
1019
+ }
1020
+
1021
+ // ─── Copying ─────────────────────────────────────────────────────────────────
1022
+
1023
+ /**
1024
+ * Recursively copy a tree, optionally transforming file contents. `.md` files
1025
+ * go through `transform` when supplied; everything else is copied verbatim.
1026
+ * `node_modules` and symlinks are never copied.
1027
+ */
1028
+ async function copyTree(src, dest, { dryRun = false, transform = null } = {}) {
1029
+ let files = 0;
1030
+ let md = 0;
1031
+ for (const entry of await readdir(src, { withFileTypes: true })) {
1032
+ if (entry.name === "node_modules") continue;
1033
+ if (entry.isSymbolicLink()) continue;
1034
+ const from = join(src, entry.name);
1035
+ const to = join(dest, entry.name);
1036
+ if (entry.isDirectory()) {
1037
+ const stats = await copyTree(from, to, { dryRun, transform });
1038
+ files += stats.files;
1039
+ md += stats.md;
1040
+ } else if (entry.isFile()) {
1041
+ files += 1;
1042
+ const isMarkdown = entry.name.endsWith(".md");
1043
+ if (isMarkdown) md += 1;
1044
+ if (dryRun) continue;
1045
+ await mkdir(dest, { recursive: true });
1046
+ if (isMarkdown && transform !== null) await writeFile(to, transform(await readFile(from, "utf-8")));
1047
+ else await copyFile(from, to);
1048
+ }
1049
+ }
1050
+ return { files, md };
1051
+ }
1052
+
1053
+ // ─── Small helpers ───────────────────────────────────────────────────────────
1054
+
1055
+ /** `./skills/` → `skills` — a package resource path relative to its own root. */
1056
+ function stripRelative(entry) {
1057
+ return entry.replace(/^\.\//, "").replace(/\/$/, "");
1058
+ }
1059
+
1060
+ function asStringList(value) {
1061
+ return Array.isArray(value) ? value.filter((entry) => typeof entry === "string") : [];
1062
+ }
1063
+
1064
+ async function readJsonObject(filePath) {
1065
+ let raw;
425
1066
  try {
426
- return await readFile(filePath, "utf-8");
1067
+ raw = await readFile(filePath, "utf-8");
427
1068
  } catch (err) {
428
1069
  if (err.code === "ENOENT") return null;
429
1070
  throw err;
430
1071
  }
1072
+ try {
1073
+ const parsed = JSON.parse(raw);
1074
+ return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed : null;
1075
+ } catch {
1076
+ return null;
1077
+ }
431
1078
  }
432
1079
 
433
- async function isSymlink(filePath) {
1080
+ async function pathExists(filePath) {
434
1081
  try {
435
- return (await lstat(filePath)).isSymbolicLink();
1082
+ await stat(filePath);
1083
+ return true;
436
1084
  } catch {
437
1085
  return false;
438
1086
  }