@jaybeeuu/agent-cortex 1.39.0 → 1.41.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 (91) hide show
  1. package/.agents/skills/style-versioning/SKILL.md +1 -1
  2. package/.github/workflows/ci.yml +47 -14
  3. package/AGENTS.md +35 -8
  4. package/CHANGELOG.md +60 -0
  5. package/README.md +174 -97
  6. package/agents/README.md +14 -11
  7. package/bin/agent-cortex.mjs +55 -10
  8. package/bin/installers/claude.mjs +674 -174
  9. package/bin/installers/copilot.mjs +13 -18
  10. package/bin/installers/pi.mjs +293 -51
  11. package/claude/settings.json +4 -0
  12. package/{claude → claude-extras}/scripts/statusline-command.sh +32 -4
  13. package/docs/claude-hooks.md +15 -3
  14. package/docs/ideas/ban-content-assertion-tests.md +73 -0
  15. package/docs/ideas/bead-progress-notes-for-session-resumption.md +75 -0
  16. package/docs/ideas/classify-beads-via-deterministic-script-with-agent-fallback.md +70 -0
  17. package/docs/ideas/ctx-alias-version-and-update-commands.md +65 -0
  18. package/docs/ideas/ctx-feedback-command-for-capturing-ideas-from-any-machine.md +67 -0
  19. package/docs/ideas/deployment-beads-for-post-merge-pipelines.md +36 -0
  20. package/docs/ideas/design-and-delivery-as-separate-bead-trees/design-and-delivery-as-separate-bead-trees.md +99 -0
  21. package/docs/ideas/enable-signed-commit-enforcement-changesets-bot-commits-must-be-signed.md +33 -0
  22. package/docs/ideas/extension-manifests.md +55 -14
  23. package/docs/ideas/improve-pi-system-prompt.md +5 -2
  24. package/docs/ideas/map-every-cortex-tool-to-a-real-pi-equivalent.md +95 -0
  25. package/docs/ideas/record-idea-implementation-questions.md +68 -0
  26. package/docs/ideas/self-flushing-session.md +72 -0
  27. package/docs/ideas/state-in-beads-not-md.md +69 -0
  28. package/docs/ideas/task-session-context.local.md +95 -0
  29. package/docs/ideas/test-data-creation-helpers.md +92 -0
  30. package/docs/ideas/test-planning-stage-bead-in-feature-planning.md +68 -0
  31. package/docs/ideas/worktree-housekeeping-for-closed-beads.md +39 -0
  32. package/docs/technical-direction/harness-config.md +161 -0
  33. package/docs/technical-direction/migration-execution-labels.md +60 -23
  34. package/extensions/agent-guardrails/README.md +45 -0
  35. package/extensions/agent-guardrails/guardrails.test.ts +42 -0
  36. package/extensions/agent-guardrails/guardrails.ts +66 -0
  37. package/extensions/agent-guardrails/index.ts +22 -0
  38. package/extensions/agent-guardrails/node_modules/.bin/pi +21 -0
  39. package/extensions/agent-guardrails/node_modules/.bin/tsc +21 -0
  40. package/extensions/agent-guardrails/node_modules/.bin/tsserver +21 -0
  41. package/extensions/agent-guardrails/node_modules/.bin/tsx +21 -0
  42. package/extensions/agent-guardrails/package.json +19 -0
  43. package/extensions/agent-guardrails/tsconfig.json +12 -0
  44. package/extensions/agent-modes/README.md +12 -5
  45. package/extensions/agent-modes/discover.test.ts +30 -7
  46. package/extensions/agent-modes/discover.ts +1 -1
  47. package/extensions/agent-modes/index.test.ts +208 -0
  48. package/extensions/agent-modes/index.ts +23 -6
  49. package/extensions/agent-modes/package.json +1 -1
  50. package/extensions/skill-stats/index.test.ts +3 -2
  51. package/lib/cli.mjs +30 -12
  52. package/lib/pi-packages.mjs +169 -0
  53. package/package.json +8 -4
  54. package/pi/settings.json +23 -12
  55. package/plugin.json +1 -1
  56. package/scripts/build-copilot-agents.mjs +1 -1
  57. package/scripts/check-no-sync.mjs +49 -0
  58. package/scripts/lib/compose-agent.mjs +32 -14
  59. package/scripts/lib/fs.mjs +31 -0
  60. package/skills/engineering/grill-with-docs/SKILL.md +1 -1
  61. package/skills/planning/classify-bead/REFERENCE.md +85 -0
  62. package/skills/planning/classify-bead/SKILL.md +32 -36
  63. package/skills/planning/classify-bead/scripts/classify-bead.mjs +292 -0
  64. package/skills/planning/create-task/SKILL.md +19 -10
  65. package/skills/planning/write-a-prd/SKILL.md +2 -2
  66. package/skills/style/style-code/REFERENCE.md +32 -2
  67. package/skills/style/style-code/SKILL.md +5 -21
  68. package/skills/style/style-tests/SKILL.md +69 -17
  69. package/skills/workflow/hitl-collab/SKILL.md +1 -1
  70. package/skills/workflow/run-pipeline-stage/SKILL.md +13 -1
  71. package/skills/workflow/run-pipeline-stage/prompts/stage-runner.md +12 -0
  72. package/test/classify-bead.test.mjs +277 -0
  73. package/test/claude-installer.test.mjs +199 -139
  74. package/test/cli.test.mjs +95 -41
  75. package/test/copilot-installer.test.mjs +92 -82
  76. package/test/fs.test.mjs +76 -0
  77. package/test/helpers/fake-claude.mjs +152 -0
  78. package/test/installer.test.mjs +542 -57
  79. package/test/pi-installer.test.mjs +457 -102
  80. package/test/pi-packages.test.mjs +295 -0
  81. package/token-map.README.md +23 -12
  82. package/token-map.json +8 -7
  83. package/.claude-plugin/marketplace.json +0 -16
  84. package/claude/.claude-plugin/plugin.json +0 -11
  85. package/claude/agents/plan.md +0 -58
  86. package/claude/agents/ralph-plan.md +0 -121
  87. package/claude/agents/ralph.md +0 -193
  88. package/claude/agents/strategy.md +0 -111
  89. package/claude/hooks/scripts/notify.mjs +0 -54
  90. package/claude/hooks.json +0 -38
  91. /package/{claude → claude-extras}/.mcp.json +0 -0
@@ -137,8 +137,8 @@ describe("discoverAgents (composable source format)", () => {
137
137
 
138
138
  // {{TOOL:bash}} → bash.
139
139
  assert.match(plan.prompt, /Run with bash\./);
140
- // {{TOOL:ask_user}} → null for pi — token dropped (dangling prose is the author's
141
- // responsibility per token-map.README), not left as literal token syntax.
140
+ // {{TOOL:ask_user}} → ask_questions (pi's native structured-questions tool).
141
+ assert.match(plan.prompt, /Ask the user with ask_questions\./);
142
142
  assert.doesNotMatch(plan.prompt, /\{\{TOOL:ask_user\}\}/);
143
143
  // Relative {{PATH:...}} resolves against the actual package root (dir of agents/).
144
144
  assert.match(plan.prompt, new RegExp(`Read the plan skill at ${escapeRegExp(fx.dir)}/skills/workflow/plan/SKILL\\.md\\.`));
@@ -147,7 +147,7 @@ describe("discoverAgents (composable source format)", () => {
147
147
  assert.doesNotMatch(plan.prompt, /\{\{/);
148
148
  });
149
149
 
150
- it("drops null-mapped tools (ask_user, skill) from the PI tool set", () => {
150
+ it("maps ask_user → ask_questions and skill → read into the PI tool set", () => {
151
151
  const fx = track(makeFixture());
152
152
  writeComposableAgent(fx, "plan", {
153
153
  frontmatter: {
@@ -159,8 +159,30 @@ describe("discoverAgents (composable source format)", () => {
159
159
 
160
160
  const agents = discoverAgents(fx.agentsDir, REAL_TOKEN_MAP);
161
161
  assert.equal(agents.length, 1);
162
- // ask_user → null (omitted), skill → null (omitted), web_fetch → fetch_content.
163
- assert.deepEqual(agents[0].tools, ["bash", "fetch_content"]);
162
+ // ask_user → ask_questions, skill → read (pi reads skill files), web_fetch → fetch_content.
163
+ assert.deepEqual(agents[0].tools, ["ask_questions", "bash", "fetch_content", "read"]);
164
+ });
165
+
166
+ it("warns and omits a tool whose pi mapping is null", () => {
167
+ const fx = track(makeFixture());
168
+ const NULL_MAP = {
169
+ tools: { ask_user: { copilot: "ask_user", claude: "AskUserQuestion", pi: null } },
170
+ paths: {},
171
+ } as TokenMap;
172
+ writeComposableAgent(fx, "plan", {
173
+ frontmatter: {
174
+ name: "agent-cortex:plan",
175
+ description: "Planner",
176
+ tools: ["ask_user", "bash"],
177
+ },
178
+ });
179
+
180
+ const agents = discoverAgents(fx.agentsDir, NULL_MAP);
181
+ assert.equal(agents.length, 1);
182
+ // With a null pi column the entry is omitted (and warned about), per the contract.
183
+ // (bash survives so the result is observable — an all-dropped list would be
184
+ // normalised to DEFAULT_TOOLS, which is "no restriction", not the omit result.)
185
+ assert.deepEqual(agents[0].tools, ["bash"]);
164
186
  });
165
187
 
166
188
  it("uses the built-in fallback map when token-map.json is unavailable", () => {
@@ -230,8 +252,9 @@ describe("loadTokenMap", () => {
230
252
  it("reads the real token-map.json at the package root", () => {
231
253
  assert.ok(REAL_TOKEN_MAP);
232
254
  assert.equal(typeof REAL_TOKEN_MAP?.tools?.bash?.pi, "string");
233
- // ask_user has no PI equivalent per the contract — must stay null.
234
- assert.equal(REAL_TOKEN_MAP?.tools?.ask_user?.pi, null);
255
+ // ask_user → ask_questions (pi's native structured-questions tool); skill → read.
256
+ assert.equal(REAL_TOKEN_MAP?.tools?.ask_user?.pi, "ask_questions");
257
+ assert.equal(REAL_TOKEN_MAP?.tools?.skill?.pi, "read");
235
258
  assert.ok(REAL_TOKEN_MAP?.paths?.agents_dir);
236
259
  });
237
260
  });
@@ -120,7 +120,7 @@ function prettifyId(id: string): string {
120
120
  * Translate a frontmatter tool list into PI tool names.
121
121
  *
122
122
  * With token-map.json: canonical keys are translated via the pi column; a null
123
- * mapping means PI has no equivalent (ask_user, skill) and the entry is omitted;
123
+ * mapping means PI has no equivalent for that tool and the entry is omitted;
124
124
  * a name that is not a canonical key passes through untouched (it may be a
125
125
  * native PI tool such as `fetch_content`). Without the map, the built-in
126
126
  * COPILOT_TO_PI fallback applies and unknown names pass through.
@@ -0,0 +1,208 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { join, dirname } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
6
+
7
+ import { type AgentDef, discoverAgents } from "./discover.ts";
8
+ import agentModesExtension, { registerAgentFlag } from "./index.ts";
9
+
10
+ const AGENTS_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "agents");
11
+
12
+ // ─── Fixtures ─────────────────────────────────────────────────────────────────
13
+
14
+ function agent(id: string): AgentDef {
15
+ return {
16
+ id,
17
+ name: `agent-cortex:${id}`,
18
+ description: `${id} agent`,
19
+ color: "accent",
20
+ tools: [],
21
+ prompt: "",
22
+ };
23
+ }
24
+
25
+ type RegisterFlagArgs = Parameters<ExtensionAPI["registerFlag"]>;
26
+ /** A handler recorded from `pi.on`, invoked via Reflect.apply so its exact
27
+ * overload signature does not leak into the fake. */
28
+ type RecordedHandler = (...args: never[]) => unknown;
29
+
30
+ interface FakeContext {
31
+ ui: {
32
+ statuses: Array<{ id: string; text: string | undefined }>;
33
+ notifications: Array<{ message: string; level: string }>;
34
+ setStatus(id: string, text: string | undefined): void;
35
+ notify(message: string, level: string): void;
36
+ theme: { fg(color: string, text: string): string };
37
+ };
38
+ sessionManager: { getEntries(): unknown[] };
39
+ }
40
+
41
+ interface FakePi {
42
+ pi: ExtensionAPI;
43
+ flags: RegisterFlagArgs[];
44
+ handlers: Map<string, RecordedHandler[]>;
45
+ setActiveToolsCalls: string[][];
46
+ /** Simulate pi dispatching an event to the handlers registered for it. */
47
+ fire(event: string, ...args: unknown[]): void;
48
+ }
49
+
50
+ /**
51
+ * Build a full ExtensionAPI test double that records the calls agent-modes
52
+ * makes. `getFlag` resolves against `flagValues`, and `getActiveTools` returns
53
+ * a fixed baseline so setActiveTools calls are observable.
54
+ */
55
+ function createFakePi(flagValues: Record<string, boolean | string> = {}): FakePi {
56
+ const flags: RegisterFlagArgs[] = [];
57
+ const handlers = new Map<string, RecordedHandler[]>();
58
+ const setActiveToolsCalls: string[][] = [];
59
+ const values = new Map<string, boolean | string>(Object.entries(flagValues));
60
+ const baselineTools = ["read", "bash"];
61
+
62
+ const pi: ExtensionAPI = {
63
+ on: (event: string, handler: RecordedHandler) => {
64
+ const list = handlers.get(event) ?? [];
65
+ list.push(handler);
66
+ handlers.set(event, list);
67
+ },
68
+ registerFlag: (name, options) => {
69
+ flags.push([name, options]);
70
+ },
71
+ getFlag: (name) => values.get(name),
72
+ getActiveTools: () => [...baselineTools],
73
+ setActiveTools: (toolNames) => {
74
+ setActiveToolsCalls.push(toolNames);
75
+ },
76
+ registerCommand: () => {},
77
+ registerShortcut: () => {},
78
+ registerTool: () => {},
79
+ registerMessageRenderer: () => {},
80
+ registerProvider: () => {},
81
+ unregisterProvider: () => {},
82
+ sendMessage: () => {},
83
+ sendUserMessage: () => {},
84
+ appendEntry: () => {},
85
+ setSessionName: () => {},
86
+ getSessionName: () => undefined,
87
+ setLabel: () => {},
88
+ exec: async () => {
89
+ throw new Error("not implemented");
90
+ },
91
+ getAllTools: () => [],
92
+ getCommands: () => [],
93
+ setModel: async () => false,
94
+ getThinkingLevel: () => "off",
95
+ setThinkingLevel: () => {},
96
+ events: { on: () => () => {}, emit: () => {} },
97
+ };
98
+
99
+ return {
100
+ pi,
101
+ flags,
102
+ handlers,
103
+ setActiveToolsCalls,
104
+ fire(event, ...args) {
105
+ for (const handler of handlers.get(event) ?? []) {
106
+ Reflect.apply(handler, undefined, args);
107
+ }
108
+ },
109
+ };
110
+ }
111
+
112
+ function createFakeContext(): FakeContext {
113
+ return {
114
+ ui: {
115
+ statuses: [],
116
+ notifications: [],
117
+ setStatus(id, text) {
118
+ this.statuses.push({ id, text });
119
+ },
120
+ notify(message, level) {
121
+ this.notifications.push({ message, level });
122
+ },
123
+ theme: { fg: (_color, text) => text },
124
+ },
125
+ sessionManager: { getEntries: () => [] },
126
+ };
127
+ }
128
+
129
+ // ─── registerAgentFlag ───────────────────────────────────────────────────────
130
+
131
+ describe("registerAgentFlag", () => {
132
+ it("registers --agent as a string flag listing the discovered agent ids", () => {
133
+ const calls: RegisterFlagArgs[] = [];
134
+ const pi: Pick<ExtensionAPI, "registerFlag"> = {
135
+ registerFlag: (...args) => calls.push(args),
136
+ };
137
+
138
+ registerAgentFlag(pi, [
139
+ agent("ralph"),
140
+ agent("ralph-plan"),
141
+ agent("plan"),
142
+ agent("strategy"),
143
+ ]);
144
+
145
+ assert.equal(calls.length, 1);
146
+ const [name, options] = calls[0];
147
+ assert.equal(name, "agent");
148
+ assert.equal(options.type, "string");
149
+ assert.equal(
150
+ options.description,
151
+ "Start in a specific agent mode (ralph, ralph-plan, plan, strategy)",
152
+ );
153
+ });
154
+ });
155
+
156
+ // ─── Registration timing ─────────────────────────────────────────────────────
157
+
158
+ describe("agentModesExtension flag registration", () => {
159
+ it("registers --agent during load, before any session event fires", () => {
160
+ const fake = createFakePi();
161
+
162
+ agentModesExtension(fake.pi);
163
+
164
+ // The flag must exist straight after the factory returns: pi validates CLI
165
+ // flags against extensions loaded at startup, so registering it from the
166
+ // session_start handler would be too late and `pi --agent <name>` would fail.
167
+ assert.equal(fake.flags.length, 1);
168
+ const [name, options] = fake.flags[0];
169
+ assert.equal(name, "agent");
170
+ assert.equal(options.type, "string");
171
+ assert.match(options.description ?? "", /^Start in a specific agent mode \(/);
172
+ });
173
+ });
174
+
175
+ // ─── --agent flag contract ───────────────────────────────────────────────────
176
+
177
+ describe("agentModesExtension --agent flag", () => {
178
+ it("activates the named agent on session start", () => {
179
+ const fake = createFakePi({ agent: "plan" });
180
+ const ctx = createFakeContext();
181
+
182
+ agentModesExtension(fake.pi);
183
+ fake.fire("session_start", {}, ctx);
184
+
185
+ const plan = discoverAgents(AGENTS_DIR).find((a) => a.id === "plan");
186
+ assert.ok(plan, "the plan agent should be discoverable");
187
+ assert.deepEqual(fake.setActiveToolsCalls.at(-1), plan.tools);
188
+ assert.ok(
189
+ ctx.ui.statuses.some((s) => s.id === "agent-mode" && s.text?.includes("plan")),
190
+ "the status indicator should show the active agent",
191
+ );
192
+ });
193
+
194
+ it("falls back to default mode for an unknown agent and leaves tools untouched", () => {
195
+ const fake = createFakePi({ agent: "does-not-exist" });
196
+ const ctx = createFakeContext();
197
+
198
+ agentModesExtension(fake.pi);
199
+ fake.fire("session_start", {}, ctx);
200
+
201
+ // Unknown flag → default mode: baseline tools restored, no agent status.
202
+ assert.deepEqual(fake.setActiveToolsCalls.at(-1), ["read", "bash"]);
203
+ assert.ok(
204
+ ctx.ui.statuses.every((s) => !s.text?.includes("agent:")),
205
+ "no agent mode should be reported",
206
+ );
207
+ });
208
+ });
@@ -37,6 +37,27 @@ interface OriginalState {
37
37
 
38
38
  const PERSIST_KEY = "agent-modes-state";
39
39
 
40
+ // ─── CLI flag ────────────────────────────────────────────────────────────────
41
+
42
+ /**
43
+ * Register the `--agent <name>` CLI flag.
44
+ *
45
+ * This must run while the extension is loading (i.e. from the factory), not
46
+ * from a `session_start` handler: PI matches CLI flags against the extensions
47
+ * loaded at startup, so a flag registered during `session_start` arrives after
48
+ * the CLI has already been parsed and `pi --agent <name>` fails with
49
+ * `Unknown option: --agent`.
50
+ */
51
+ export function registerAgentFlag(
52
+ pi: Pick<ExtensionAPI, "registerFlag">,
53
+ agents: AgentDef[],
54
+ ): void {
55
+ pi.registerFlag("agent", {
56
+ description: `Start in a specific agent mode (${agents.map((a) => a.id).join(", ")})`,
57
+ type: "string",
58
+ });
59
+ }
60
+
40
61
  // ─── Extension ───────────────────────────────────────────────────────────────
41
62
 
42
63
  export default function agentModesExtension(pi: ExtensionAPI): void {
@@ -47,6 +68,8 @@ export default function agentModesExtension(pi: ExtensionAPI): void {
47
68
  return;
48
69
  }
49
70
 
71
+ registerAgentFlag(pi, agents);
72
+
50
73
  let activeAgentIndex = agents.length; // default mode is beyond the last agent
51
74
  let originalState: OriginalState | undefined;
52
75
  let baselineTools: string[] = [];
@@ -186,12 +209,6 @@ export default function agentModesExtension(pi: ExtensionAPI): void {
186
209
  },
187
210
  });
188
211
 
189
- // --agent CLI flag
190
- pi.registerFlag("agent", {
191
- description: `Start in a specific agent mode (${agents.map((a) => a.id).join(", ")})`,
192
- type: "string",
193
- });
194
-
195
212
  // inject agent prompt before each agent start
196
213
  pi.on("before_agent_start", async (event) => {
197
214
  const agent = getActiveAgent();
@@ -4,7 +4,7 @@
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "scripts": {
7
- "test": "node --test --import tsx/esm discover.test.ts",
7
+ "test": "node --test --import tsx/esm discover.test.ts index.test.ts",
8
8
  "typecheck": "tsc --noEmit"
9
9
  },
10
10
  "devDependencies": {
@@ -331,17 +331,18 @@ describe("loadStore / saveStore", () => {
331
331
  const file = join(tmpDir, "test-store.json");
332
332
  const original = freshStore();
333
333
  original.totalTurns = 42;
334
+ const recentTs = new Date(Date.now() - 60 * 24 * 60 * 60 * 1000).toISOString();
334
335
  original.skills["test-skill"] = {
335
336
  loadedCount: 5,
336
337
  invokedCount: 2,
337
338
  readCount: 3,
338
- lastUsed: "2026-06-07T12:00:00.000Z",
339
+ lastUsed: recentTs,
339
340
  byProject: {
340
341
  "/project/a": {
341
342
  loadedCount: 3,
342
343
  invokedCount: 1,
343
344
  readCount: 2,
344
- lastUsed: "2026-06-07T12:00:00.000Z",
345
+ lastUsed: recentTs,
345
346
  },
346
347
  },
347
348
  };
package/lib/cli.mjs CHANGED
@@ -5,9 +5,11 @@ const SUPPORTED_HARNESSES = ["copilot", "claude", "pi"];
5
5
  // malformed options surface as `optionError` on the parsed result; the CLI
6
6
  // prints them and exits 1.
7
7
  const INSTALL_OPTIONS = {
8
- "--dry-run": { key: "dryRun" },
8
+ "--dry-run": { key: "dryRun", boolean: true },
9
9
  "--output": { key: "output" },
10
10
  "--plugin-root": { key: "pluginRoot" },
11
+ "--require-register": { key: "requireRegister", boolean: true },
12
+ "--no-provision": { key: "noProvision", boolean: true },
11
13
  };
12
14
 
13
15
  export function parseArgs(argv) {
@@ -34,8 +36,8 @@ export function parseArgs(argv) {
34
36
  if (!spec) {
35
37
  return { command: "install", harness, optionError: `Unknown option "${arg}". Run "agent-cortex --help" for usage.` };
36
38
  }
37
- if (spec.key === "dryRun") {
38
- options.dryRun = true;
39
+ if (spec.boolean) {
40
+ options[spec.key] = true;
39
41
  continue;
40
42
  }
41
43
  if (inlineValue !== null) {
@@ -66,15 +68,26 @@ Commands:
66
68
  install <harness> Install agent-cortex for a harness
67
69
  Supported harnesses: ${SUPPORTED_HARNESSES.join(", ")}
68
70
  Options (claude):
69
- (no --output) Generate the plugin subtree AND register it
70
- with Claude Code via the "claude plugin" CLI
71
- (marketplace add → install → update) — requires
72
- the plugin CLI (Claude Code v2+)
73
- --output <dir> Write the plugin subtree to <dir> only — no
74
- runtime registration (default: <package
75
- root>/claude)
71
+ (no --output) Materialise the plugin into
72
+ ~/.agent-cortex/claude, write the
73
+ marketplace manifest at
74
+ ~/.agent-cortex/.claude-plugin/
75
+ marketplace.json, and register it with
76
+ Claude Code via the "claude plugin" CLI
77
+ (marketplace add → install → update,
78
+ idempotent by state: a re-run is the
79
+ update path, repeat install a no-op;
80
+ requires the plugin CLI, v2+)
81
+ --output <dir> Write the plugin subtree to <dir> only —
82
+ no marketplace manifest or registration
83
+ (default: ~/.agent-cortex/claude)
76
84
  --dry-run Print what would be generated/registered
77
85
  without writing
86
+ --require-register
87
+ Fail the install when Claude Code
88
+ registration cannot run (missing CLI,
89
+ pre-v2 build); default warns and prints
90
+ the manual registration commands
78
91
  Options (copilot):
79
92
  --output <dir> Write the flat *.agent.md files to <dir>
80
93
  (default: <package root>/agents — the dir
@@ -82,10 +95,15 @@ Commands:
82
95
  --dry-run Print what would be generated without writing
83
96
  Options (pi):
84
97
  --dry-run Show what would be installed without writing
85
- --output <dir> Install into <dir>/agents and <dir>/skills
86
- (default: ~/.pi/agent)
98
+ --output <dir> Install into <dir> — agents/, skills/,
99
+ settings.json (merged, packages managed)
100
+ and keybindings.json (default: ~/.pi/agent)
87
101
  --plugin-root <dir> Override the plugin root used for {{PATH:...}}
88
102
  tokens (default: token-map.json's pi value)
103
+ --no-provision Skip installing the third-party pi packages
104
+ declared in the package manifest (e.g.
105
+ pi-questions, pi-web-access). They are
106
+ installed by default on a real install.
89
107
  --help, -h Show this help text`;
90
108
  }
91
109
 
@@ -0,0 +1,169 @@
1
+ // Pi third-party package provisioning.
2
+ //
3
+ // agent-cortex's pi harness needs tools that live in third-party pi packages:
4
+ // ask_questions comes from pi-questions, fetch_content from pi-web-access (see
5
+ // token-map.json notes and docs/ideas/extension-manifests.md). Those packages
6
+ // are declared in the package manifest — package.json → pi.packages — and this
7
+ // module makes them real: `agent-cortex install pi` reads the declaration and
8
+ // installs any package the pi user scope is missing.
9
+ //
10
+ // Provisioning goes through the pi CLI (`pi install <source>`), which both
11
+ // installs the package under ~/.pi/agent/npm and declares it in
12
+ // ~/.pi/agent/settings.json — the same store pi loads packages from. It is
13
+ // opt-in (installPi option `provisionPackages`) and idempotent: a package that
14
+ // is already declared and installed is skipped. A failed install warns and the
15
+ // rest continue, so an offline machine never breaks the agent install.
16
+
17
+ import { readFile, readdir } from "node:fs/promises";
18
+ import { join } from "node:path";
19
+ import { spawn } from "node:child_process";
20
+
21
+ const DEFAULT_WARN = (msg) => console.warn(`[pi-packages] ${msg}`);
22
+
23
+ /**
24
+ * Read the pi package sources agent-cortex declares it needs.
25
+ *
26
+ * @param {string} root Package root containing package.json
27
+ * @returns {Promise<string[]>} Declared sources, e.g. ["npm:pi-questions"]
28
+ */
29
+ export async function loadRequiredPackages(root) {
30
+ const manifest = JSON.parse(await readFile(join(root, "package.json"), "utf-8"));
31
+ const packages = manifest?.pi?.packages;
32
+ return Array.isArray(packages) ? packages.filter((entry) => typeof entry === "string") : [];
33
+ }
34
+
35
+ /**
36
+ * Extract the npm package name from a pi source string.
37
+ *
38
+ * @param {string} source e.g. "npm:pi-web-access@0.10.7", "npm:@scope/pkg"
39
+ * @returns {string | null} The name, or null for sources we cannot inspect locally
40
+ */
41
+ export function npmSourceName(source) {
42
+ if (typeof source !== "string" || !source.startsWith("npm:")) return null;
43
+ const spec = source.slice("npm:".length);
44
+ const at = spec.lastIndexOf("@");
45
+ return at > 0 ? spec.slice(0, at) : spec;
46
+ }
47
+
48
+ /**
49
+ * Decide which required packages still need installing.
50
+ *
51
+ * A package is satisfied only when it is both declared in settings (so pi loads
52
+ * it) and present in the npm store (so it is installed) — otherwise it needs a
53
+ * `pi install` to reconcile.
54
+ *
55
+ * @param {string[]} required
56
+ * @param {{ declaredSources: string[], installedNames: string[] }} state
57
+ * @returns {string[]} The sources still to install
58
+ */
59
+ export function planPackageInstalls(required, { declaredSources, installedNames }) {
60
+ const declared = new Set(declaredSources.map(npmSourceName).filter(Boolean));
61
+ const installed = new Set(installedNames);
62
+ return required.filter((source) => {
63
+ const name = npmSourceName(source);
64
+ return name === null || !declared.has(name) || !installed.has(name);
65
+ });
66
+ }
67
+
68
+ /**
69
+ * Install every required package the pi user scope is missing.
70
+ *
71
+ * @param {object} options
72
+ * @param {string[]} options.required Declared pi sources (see loadRequiredPackages)
73
+ * @param {string} options.piRoot Pi user scope dir (default ~/.pi/agent)
74
+ * @param {boolean} [options.dryRun] Report the plan without installing
75
+ * @param {(source: string) => Promise<{ok: boolean, error: string | null}>} [options.runInstall]
76
+ * @param {(msg: string) => void} [options.warn]
77
+ * @returns {Promise<{planned: string[], installed: string[], failed: {source: string, error: string}[]}>}
78
+ */
79
+ export async function provisionPiPackages({
80
+ required,
81
+ piRoot,
82
+ dryRun = false,
83
+ runInstall = runPiInstall,
84
+ warn = DEFAULT_WARN,
85
+ }) {
86
+ const declaredSources = await readDeclaredSources(join(piRoot, "settings.json"));
87
+ const installedNames = await readInstalledNames(join(piRoot, "npm", "node_modules"));
88
+ const planned = planPackageInstalls(required, { declaredSources, installedNames });
89
+
90
+ if (dryRun) return { planned, installed: [], failed: [] };
91
+
92
+ const installed = [];
93
+ const failed = [];
94
+ for (const source of planned) {
95
+ const result = await runInstall(source);
96
+ if (result.ok) {
97
+ installed.push(source);
98
+ } else {
99
+ failed.push({ source, error: result.error });
100
+ warn(`could not install pi package ${source}: ${result.error}`);
101
+ }
102
+ }
103
+ return { planned, installed, failed };
104
+ }
105
+
106
+ /** Install one pi package source through the pi CLI. */
107
+ export function runPiInstall(source) {
108
+ return new Promise((resolve) => {
109
+ const child = spawn("pi", ["install", source], { stdio: ["ignore", "pipe", "pipe"] });
110
+ let stderr = "";
111
+ child.stdout.resume(); // drain; only failures are interesting
112
+ child.stderr.on("data", (chunk) => {
113
+ stderr += chunk;
114
+ });
115
+ child.on("error", (err) => {
116
+ resolve({
117
+ ok: false,
118
+ error: err.code === "ENOENT" ? "pi CLI not found on PATH" : err.message,
119
+ });
120
+ });
121
+ child.on("close", (code) => {
122
+ resolve({
123
+ ok: code === 0,
124
+ error: code === 0 ? null : stderr.trim() || `pi install exited with code ${code}`,
125
+ });
126
+ });
127
+ });
128
+ }
129
+
130
+ // ─── Pi user-scope inspection ────────────────────────────────────────────────
131
+
132
+ /** Read the package sources declared in a pi settings.json (missing or unreadable → none). */
133
+ async function readDeclaredSources(settingsPath) {
134
+ let settings;
135
+ try {
136
+ settings = JSON.parse(await readFile(settingsPath, "utf-8"));
137
+ } catch {
138
+ // Nothing declared yet, or a file pi itself owns and repairs. Provisioning is
139
+ // a non-essential step — it must never fail the agent install.
140
+ return [];
141
+ }
142
+ const packages = Array.isArray(settings?.packages) ? settings.packages : [];
143
+ return packages
144
+ .map((entry) => (typeof entry === "string" ? entry : entry?.source))
145
+ .filter((source) => typeof source === "string");
146
+ }
147
+
148
+ /** List installed package names in the pi npm store (missing dir → none). */
149
+ async function readInstalledNames(nodeModulesDir) {
150
+ let entries;
151
+ try {
152
+ entries = await readdir(nodeModulesDir, { withFileTypes: true });
153
+ } catch (err) {
154
+ if (err.code === "ENOENT") return [];
155
+ throw err;
156
+ }
157
+ const names = [];
158
+ for (const entry of entries) {
159
+ if (!entry.isDirectory()) continue;
160
+ if (!entry.name.startsWith("@")) {
161
+ names.push(entry.name);
162
+ continue;
163
+ }
164
+ for (const scoped of await readdir(join(nodeModulesDir, entry.name), { withFileTypes: true })) {
165
+ if (scoped.isDirectory()) names.push(`${entry.name}/${scoped.name}`);
166
+ }
167
+ }
168
+ return names;
169
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jaybeeuu/agent-cortex",
3
- "version": "1.39.0",
3
+ "version": "1.41.0",
4
4
  "description": "Personal PI package with custom agents, skills, and extensions",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -24,17 +24,21 @@
24
24
  ],
25
25
  "skills": [
26
26
  "./skills"
27
+ ],
28
+ "packages": [
29
+ "npm:pi-questions",
30
+ "npm:pi-web-access@0.10.7"
27
31
  ]
28
32
  },
29
33
  "scripts": {
30
34
  "build": "echo 'Build complete — no compilation needed for PI package'",
31
- "lint": "pnpm typecheck",
35
+ "lint": "pnpm typecheck && pnpm check:sync",
36
+ "check:sync": "node scripts/check-no-sync.mjs",
32
37
  "test": "node --test test/*.test.mjs && pnpm -r test",
33
38
  "typecheck": "pnpm -r typecheck",
34
- "build:claude": "node bin/agent-cortex.mjs install claude --output claude",
35
39
  "build:copilot": "node scripts/build-copilot-agents.mjs",
36
40
  "changeset": "changeset",
37
- "version-packages": "changeset version && bash scripts/sync-plugin-version.sh && pnpm build:claude && pnpm build:copilot",
41
+ "version-packages": "changeset version && bash scripts/sync-plugin-version.sh && pnpm build:copilot",
38
42
  "publish-package": "TARBALL=$(pnpm pack | tail -n 1) && npm publish \"$TARBALL\" --provenance --access public",
39
43
  "release": "changeset publish",
40
44
  "postinstall": "bash scripts/install-instructions.sh"