@jaybeeuu/agent-cortex 1.37.1 → 1.38.1

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 (92) hide show
  1. package/.agents/skills/style-versioning/SKILL.md +9 -8
  2. package/.github/workflows/ci.yml +190 -8
  3. package/AGENTS.md +25 -6
  4. package/CHANGELOG.md +42 -0
  5. package/README.md +132 -33
  6. package/agents/README.md +47 -13
  7. package/agents/plan/agent.md +51 -0
  8. package/agents/plan/claude/frontmatter.json +6 -0
  9. package/agents/plan/copilot/frontmatter.json +6 -0
  10. package/agents/plan/pi/frontmatter.json +6 -0
  11. package/agents/plan.agent.md +3 -0
  12. package/agents/ralph/agent.md +262 -0
  13. package/agents/ralph/claude/frontmatter.json +6 -0
  14. package/agents/ralph/claude/polling.md +31 -0
  15. package/agents/ralph/copilot/frontmatter.json +6 -0
  16. package/agents/ralph/copilot/polling.md +62 -0
  17. package/agents/ralph/pi/frontmatter.json +6 -0
  18. package/agents/ralph/pi/polling.md +74 -0
  19. package/agents/ralph-plan/agent.md +114 -0
  20. package/agents/ralph-plan/claude/frontmatter.json +6 -0
  21. package/agents/ralph-plan/copilot/frontmatter.json +6 -0
  22. package/agents/ralph-plan/pi/frontmatter.json +6 -0
  23. package/agents/ralph-plan.agent.md +5 -2
  24. package/agents/ralph.agent.md +83 -57
  25. package/agents/strategy/agent.md +104 -0
  26. package/agents/strategy/claude/frontmatter.json +6 -0
  27. package/agents/strategy/copilot/frontmatter.json +6 -0
  28. package/agents/strategy/pi/frontmatter.json +6 -0
  29. package/agents/strategy.agent.md +3 -0
  30. package/bin/agent-cortex.mjs +68 -1
  31. package/bin/installers/claude.mjs +262 -0
  32. package/bin/installers/copilot.mjs +129 -0
  33. package/bin/installers/pi.mjs +197 -0
  34. package/claude/.claude-plugin/plugin.json +1 -1
  35. package/claude/agents/plan.md +3 -1
  36. package/claude/agents/ralph-plan.md +5 -3
  37. package/claude/agents/strategy.md +3 -1
  38. package/claude/hooks/scripts/notify.mjs +54 -0
  39. package/claude/hooks.json +15 -0
  40. package/docs/claude-hooks.md +115 -0
  41. package/docs/ideas/auto-model-selection.md +75 -0
  42. package/docs/ideas/improve-pi-system-prompt.md +34 -0
  43. package/docs/ideas/model-selector.md +40 -0
  44. package/docs/ideas/pi-task-memory.md +34 -0
  45. package/docs/ideas/qa-extension.md +43 -0
  46. package/docs/ideas/unified-ci-pipeline.md +28 -0
  47. package/docs/skills/skill-anatomy.md +9 -0
  48. package/extensions/agent-modes/README.md +54 -0
  49. package/extensions/agent-modes/discover.test.ts +241 -0
  50. package/extensions/agent-modes/discover.ts +458 -0
  51. package/extensions/agent-modes/index.ts +13 -224
  52. package/extensions/agent-modes/node_modules/.bin/pi +21 -0
  53. package/extensions/agent-modes/node_modules/.bin/tsc +21 -0
  54. package/extensions/agent-modes/node_modules/.bin/tsserver +21 -0
  55. package/extensions/agent-modes/node_modules/.bin/tsx +21 -0
  56. package/extensions/agent-modes/package.json +20 -0
  57. package/extensions/agent-modes/tsconfig.json +12 -0
  58. package/extensions/subagent/README.md +19 -0
  59. package/extensions/subagent/index.ts +131 -45
  60. package/extensions/subagent/node_modules/.bin/pi +21 -0
  61. package/extensions/subagent/node_modules/.bin/pi-ai +21 -0
  62. package/extensions/subagent/node_modules/.bin/tsc +21 -0
  63. package/extensions/subagent/node_modules/.bin/tsserver +21 -0
  64. package/extensions/subagent/node_modules/.bin/tsx +21 -0
  65. package/extensions/subagent/package.json +23 -0
  66. package/extensions/subagent/tsconfig.json +12 -0
  67. package/extensions/subagent/wait.test.ts +202 -0
  68. package/extensions/subagent/wait.ts +118 -0
  69. package/hooks/claude/hooks.json +38 -0
  70. package/hooks/claude/scripts/notify.mjs +54 -0
  71. package/lib/cli.mjs +62 -3
  72. package/package.json +6 -4
  73. package/plugin.json +1 -1
  74. package/scripts/build-copilot-agents.mjs +30 -0
  75. package/scripts/lib/compose-agent.mjs +241 -0
  76. package/skills/engineering/design-an-interface/SKILL.md +1 -1
  77. package/skills/engineering/improve-codebase-architecture/SKILL.md +1 -1
  78. package/skills/workflow/ralph/REFERENCE.md +2 -2
  79. package/skills/workflow/ralph/SKILL.md +6 -6
  80. package/skills/workflow/run-pipeline-stage/scripts/generate-progress.ts +4 -2
  81. package/skills/workflow/using-agent-skills/SKILL.md +122 -0
  82. package/test/cli.test.mjs +115 -2
  83. package/test/copilot-installer.test.mjs +298 -0
  84. package/test/installer.test.mjs +179 -0
  85. package/test/notify-hook.test.mjs +63 -0
  86. package/test/pi-installer.test.mjs +371 -0
  87. package/token-map.README.md +128 -0
  88. package/token-map.json +106 -0
  89. package/.github/workflows/ci.test.ts +0 -217
  90. package/.github/workflows/release.yml +0 -58
  91. package/scripts/build-claude-agents.mjs +0 -179
  92. package/scripts/publish-release.sh +0 -32
@@ -0,0 +1,241 @@
1
+ import { describe, it, after } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
4
+ import { join, dirname } from "node:path";
5
+ import { tmpdir } from "node:os";
6
+ import { fileURLToPath } from "node:url";
7
+
8
+ import {
9
+ type AgentDef,
10
+ type TokenMap,
11
+ discoverAgents,
12
+ loadTokenMap,
13
+ } from "./discover.ts";
14
+
15
+ // Real token-map.json at the package root — the canonical tool/path map the
16
+ // extension ships with. Used to verify translation against the source of truth.
17
+ const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
18
+ const REAL_TOKEN_MAP = loadTokenMap(join(REPO_ROOT, "agents"));
19
+
20
+ // ─── Fixtures ─────────────────────────────────────────────────────────────────
21
+
22
+ interface Fixture {
23
+ dir: string;
24
+ agentsDir: string;
25
+ cleanup: () => void;
26
+ }
27
+
28
+ function makeFixture(): Fixture {
29
+ const dir = mkdtempSync(join(tmpdir(), "agent-modes-"));
30
+ return {
31
+ dir,
32
+ agentsDir: join(dir, "agents"),
33
+ cleanup: () => rmSync(dir, { recursive: true, force: true }),
34
+ };
35
+ }
36
+
37
+ function writeFixture(fx: Fixture, relPath: string, content: string): void {
38
+ const p = join(fx.dir, relPath);
39
+ mkdirSync(dirname(p), { recursive: true });
40
+ writeFileSync(p, content);
41
+ }
42
+
43
+ function writeComposableAgent(
44
+ fx: Fixture,
45
+ name: string,
46
+ opts: {
47
+ body?: string;
48
+ frontmatter?: Record<string, unknown>;
49
+ sections?: Record<string, string>;
50
+ } = {},
51
+ ): void {
52
+ const base = `agents/${name}`;
53
+ writeFixture(fx, `${base}/agent.md`, opts.body ?? `# ${name}\nBody of ${name}.`);
54
+ writeFixture(
55
+ fx,
56
+ `${base}/pi/frontmatter.json`,
57
+ JSON.stringify(
58
+ opts.frontmatter ?? {
59
+ name: `agent-cortex:${name}`,
60
+ description: `Description of ${name}`,
61
+ tools: ["bash", "view", "task"],
62
+ },
63
+ ),
64
+ );
65
+ for (const [section, body] of Object.entries(opts.sections ?? {})) {
66
+ writeFixture(fx, `${base}/pi/${section}.md`, body);
67
+ }
68
+ }
69
+
70
+ function writeFlatAgent(fx: Fixture, name: string, body: string): void {
71
+ writeFixture(fx, `agents/${name}.agent.md`, body);
72
+ }
73
+
74
+ const fixtures: Fixture[] = [];
75
+ function track(fx: Fixture): Fixture {
76
+ fixtures.push(fx);
77
+ return fx;
78
+ }
79
+
80
+ after(() => {
81
+ for (const fx of fixtures) fx.cleanup();
82
+ });
83
+
84
+ const FLAT_FRONTMATTER = (name: string, tools: string) =>
85
+ `---\ndescription: "Flat description of ${name}"\nname: "agent-cortex:${name}"\ntools: ${tools}\n---\n\nFlat body of ${name}.`;
86
+
87
+ // ─── Tokens ───────────────────────────────────────────────────────────────────
88
+
89
+ const TOKEN_PROSE =
90
+ "Run with {{TOOL:bash}}.\n" +
91
+ "Ask the user with {{TOOL:ask_user}}.\n" +
92
+ "Read the plan skill at {{PATH:skills/workflow/plan/SKILL.md}}.\n" +
93
+ "Agents live in {{PATH:agents_dir}}.";
94
+
95
+ // ─── Composable discovery ────────────────────────────────────────────────────
96
+
97
+ describe("discoverAgents (composable source format)", () => {
98
+ it("discovers agents from <name>/agent.md + <name>/pi/frontmatter.json", () => {
99
+ const fx = track(makeFixture());
100
+ writeComposableAgent(fx, "ralph", {
101
+ body: "# ralph\nUse {{TOOL:task}} to spawn subagents.\n\n{{SECTION:polling}}",
102
+ frontmatter: {
103
+ name: "agent-cortex:ralph",
104
+ description: "Runs beads end-to-end",
105
+ tools: ["bash", "view", "rg", "glob", "task", "read_agent"],
106
+ },
107
+ sections: {
108
+ polling: "## PI polling\nPoll with task + read_agent.",
109
+ },
110
+ });
111
+
112
+ const agents = discoverAgents(fx.agentsDir, REAL_TOKEN_MAP);
113
+
114
+ assert.equal(agents.length, 1);
115
+ const ralph = agents[0];
116
+ assert.equal(ralph.id, "ralph");
117
+ assert.equal(ralph.name, "agent-cortex:ralph");
118
+ assert.equal(ralph.description, "Runs beads end-to-end");
119
+ // Canonical tool names translated to PI names via token-map (glob→find, rg→grep, view→read).
120
+ assert.deepEqual(ralph.tools, ["bash", "find", "grep", "read", "read_agent", "task"]);
121
+ // SECTION token resolved from pi/<section>.md; TOOL token substituted.
122
+ assert.match(ralph.prompt, /Use task to spawn subagents\./);
123
+ assert.match(ralph.prompt, /Poll with task \+ read_agent\./);
124
+ assert.doesNotMatch(ralph.prompt, /\{\{/);
125
+ });
126
+
127
+ it("substitutes TOOL / PATH tokens against token-map.json using the real plugin root", () => {
128
+ const fx = track(makeFixture());
129
+ writeComposableAgent(fx, "plan", {
130
+ body: TOKEN_PROSE,
131
+ frontmatter: { name: "agent-cortex:plan", description: "Planner", tools: [] },
132
+ });
133
+
134
+ const agents = discoverAgents(fx.agentsDir, REAL_TOKEN_MAP);
135
+ assert.equal(agents.length, 1);
136
+ const plan = agents[0];
137
+
138
+ // {{TOOL:bash}} → bash.
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.
142
+ assert.doesNotMatch(plan.prompt, /\{\{TOOL:ask_user\}\}/);
143
+ // Relative {{PATH:...}} resolves against the actual package root (dir of agents/).
144
+ assert.match(plan.prompt, new RegExp(`Read the plan skill at ${escapeRegExp(fx.dir)}/skills/workflow/plan/SKILL\\.md\\.`));
145
+ // Named {{PATH:agents_dir}} resolves to <root>/agents.
146
+ assert.match(plan.prompt, new RegExp(`Agents live in ${escapeRegExp(fx.agentsDir)}\\.`));
147
+ assert.doesNotMatch(plan.prompt, /\{\{/);
148
+ });
149
+
150
+ it("drops null-mapped tools (ask_user, skill) from the PI tool set", () => {
151
+ const fx = track(makeFixture());
152
+ writeComposableAgent(fx, "plan", {
153
+ frontmatter: {
154
+ name: "agent-cortex:plan",
155
+ description: "Planner",
156
+ tools: ["bash", "ask_user", "skill", "web_fetch"],
157
+ },
158
+ });
159
+
160
+ const agents = discoverAgents(fx.agentsDir, REAL_TOKEN_MAP);
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"]);
164
+ });
165
+
166
+ it("uses the built-in fallback map when token-map.json is unavailable", () => {
167
+ const fx = track(makeFixture());
168
+ writeComposableAgent(fx, "legacy", {
169
+ frontmatter: {
170
+ name: "agent-cortex:legacy",
171
+ description: "No token map",
172
+ tools: ["view", "glob", "ask_user"],
173
+ },
174
+ });
175
+
176
+ const agents = discoverAgents(fx.agentsDir);
177
+ assert.equal(agents.length, 1);
178
+ // Copilot→PI fallback: view→read, glob→find+ls; unmapped ask_user passes through.
179
+ assert.deepEqual(agents[0].tools, ["ask_user", "find", "ls", "read"]);
180
+ });
181
+
182
+ it("returns an empty list for a missing agents directory", () => {
183
+ const fx = track(makeFixture());
184
+ assert.deepEqual(discoverAgents(join(fx.dir, "does-not-exist"), REAL_TOKEN_MAP), []);
185
+ });
186
+
187
+ it("returns an empty list when no agents are present", () => {
188
+ const fx = track(makeFixture());
189
+ mkdirSync(fx.agentsDir, { recursive: true });
190
+ assert.deepEqual(discoverAgents(fx.agentsDir, REAL_TOKEN_MAP), []);
191
+ });
192
+ });
193
+
194
+ // ─── Flat fallback ────────────────────────────────────────────────────────────
195
+
196
+ describe("discoverAgents (flat fallback)", () => {
197
+ it("prefers a composable directory over its flat file for the same agent", () => {
198
+ const fx = track(makeFixture());
199
+ writeComposableAgent(fx, "ralph", { body: "Composable body." });
200
+ writeFlatAgent(fx, "ralph", FLAT_FRONTMATTER("ralph", '["bash", "view"]'));
201
+
202
+ const agents = discoverAgents(fx.agentsDir, REAL_TOKEN_MAP);
203
+
204
+ assert.equal(agents.length, 1);
205
+ assert.equal(agents[0].id, "ralph");
206
+ assert.match(agents[0].prompt, /Composable body\./);
207
+ assert.doesNotMatch(agents[0].prompt, /Flat body/);
208
+ });
209
+
210
+ it("falls back to flat *.agent.md for agents without a composable directory", () => {
211
+ const fx = track(makeFixture());
212
+ writeFlatAgent(fx, "strategy", FLAT_FRONTMATTER("strategy", '["bash", "view", "web_fetch"]'));
213
+
214
+ const agents = discoverAgents(fx.agentsDir, REAL_TOKEN_MAP);
215
+
216
+ assert.equal(agents.length, 1);
217
+ const strategy = agents[0];
218
+ assert.equal(strategy.id, "strategy");
219
+ assert.equal(strategy.name, "agent-cortex:strategy");
220
+ assert.equal(strategy.description, "Flat description of strategy");
221
+ // view→read, web_fetch→fetch_content via token-map.
222
+ assert.deepEqual(strategy.tools, ["bash", "fetch_content", "read"]);
223
+ assert.match(strategy.prompt, /Flat body of strategy\./);
224
+ });
225
+ });
226
+
227
+ // ─── loadTokenMap ────────────────────────────────────────────────────────────
228
+
229
+ describe("loadTokenMap", () => {
230
+ it("reads the real token-map.json at the package root", () => {
231
+ assert.ok(REAL_TOKEN_MAP);
232
+ 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);
235
+ assert.ok(REAL_TOKEN_MAP?.paths?.agents_dir);
236
+ });
237
+ });
238
+
239
+ function escapeRegExp(s: string): string {
240
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
241
+ }
@@ -0,0 +1,458 @@
1
+ import { readFileSync, readdirSync, statSync, existsSync } from "node:fs";
2
+ import { join, basename } from "node:path";
3
+ import type { ThemeColor } from "@earendil-works/pi-coding-agent";
4
+
5
+ /**
6
+ * Agent discovery for the agent-modes extension.
7
+ *
8
+ * Agents are read from the composable source format (`agents/<name>/agent.md` +
9
+ * `agents/<name>/pi/frontmatter.json`) — the canonical layout defined in
10
+ * agents/README.md. The PI system prompt is composed per the token-map.json
11
+ * contract:
12
+ *
13
+ * 1. `{{SECTION:name}}` is resolved from the agent's `pi/<name>.md` section file.
14
+ * 2. `{{TOOL:key}}` and `{{PATH:key}}` are substituted against the pi column of
15
+ * token-map.json, with the plugin root resolved from the actual extension
16
+ * location (installers must compute it from the real resolution, per the
17
+ * token-map notes — a hardcoded `~/.pi/...` literal would break checkout
18
+ * and symlinked installs).
19
+ *
20
+ * Flat `agents/*.agent.md` files are parsed as a fallback for agents that have
21
+ * no composable directory (they are generated output for Copilot, so they may
22
+ * exist for any agent). A composable directory always wins over its flat
23
+ * counterpart.
24
+ */
25
+
26
+ // ─── Types ───────────────────────────────────────────────────────────────────
27
+
28
+ export interface AgentDef {
29
+ /** Lowercase id derived from the directory/file name (e.g. "ralph", "ralph-plan") */
30
+ id: string;
31
+ /** Display name from frontmatter or prettified id */
32
+ name: string;
33
+ /** Description from frontmatter */
34
+ description: string;
35
+ /** Colour for the status indicator */
36
+ color: ThemeColor;
37
+ /** PI tool names to enable (empty = all tools) */
38
+ tools: string[];
39
+ /** Full agent body (the system prompt to inject) */
40
+ prompt: string;
41
+ }
42
+
43
+ export type HarnessId = "copilot" | "claude" | "pi";
44
+
45
+ export interface PathSpec {
46
+ base: string;
47
+ relative: string;
48
+ }
49
+
50
+ /** Shape of token-map.json (see the file at the package root). */
51
+ export interface TokenMap {
52
+ tools: Record<string, Record<HarnessId, string | null>>;
53
+ paths: Record<string, Record<HarnessId, string | PathSpec>>;
54
+ }
55
+
56
+ const PI: HarnessId = "pi";
57
+
58
+ // ─── Constants ───────────────────────────────────────────────────────────────
59
+
60
+ /** Mapping from agent id → colour for the status indicator. */
61
+ const AGENT_COLORS: Record<string, ThemeColor> = {
62
+ ralph: "accent",
63
+ "ralph-plan": "warning",
64
+ strategy: "accent",
65
+ };
66
+
67
+ /** Modes with no tool restriction get the full default set. */
68
+ const DEFAULT_TOOLS = ["read", "bash", "edit", "write", "grep", "find", "ls"];
69
+
70
+ /**
71
+ * Copilot CLI tool names → PI built-in tool names. Fallback translation used
72
+ * when token-map.json is unavailable; token-map.json is the source of truth
73
+ * when present.
74
+ */
75
+ const COPILOT_TO_PI: Record<string, string[]> = {
76
+ bash: ["bash"],
77
+ view: ["read"],
78
+ read: ["read"],
79
+ edit: ["edit"],
80
+ create: ["write"],
81
+ write: ["write"],
82
+ grep: ["grep"],
83
+ rg: ["grep", "find"],
84
+ glob: ["find", "ls"],
85
+ find: ["find"],
86
+ ls: ["ls"],
87
+ };
88
+
89
+ // ─── Read helpers ────────────────────────────────────────────────────────────
90
+
91
+ function isFile(p: string): boolean {
92
+ try {
93
+ return statSync(p).isFile();
94
+ } catch {
95
+ return false;
96
+ }
97
+ }
98
+
99
+ /** Load token-map.json from the package root (sibling of the agents dir). */
100
+ export function loadTokenMap(agentsDir: string): TokenMap | null {
101
+ try {
102
+ const raw = readFileSync(join(agentsDir, "..", "token-map.json"), "utf-8");
103
+ const parsed = JSON.parse(raw) as Partial<TokenMap>;
104
+ return parsed.tools && parsed.paths ? (parsed as TokenMap) : null;
105
+ } catch {
106
+ return null;
107
+ }
108
+ }
109
+
110
+ function prettifyId(id: string): string {
111
+ return id
112
+ .split("-")
113
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
114
+ .join(" ");
115
+ }
116
+
117
+ // ─── Tool translation ────────────────────────────────────────────────────────
118
+
119
+ /**
120
+ * Translate a frontmatter tool list into PI tool names.
121
+ *
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;
124
+ * a name that is not a canonical key passes through untouched (it may be a
125
+ * native PI tool such as `fetch_content`). Without the map, the built-in
126
+ * COPILOT_TO_PI fallback applies and unknown names pass through.
127
+ */
128
+ function translateTools(raw: unknown, tokenMap: TokenMap | null): string[] {
129
+ if (!Array.isArray(raw)) return [];
130
+
131
+ const out = new Set<string>();
132
+ for (const t of raw) {
133
+ if (typeof t !== "string" || t === "") continue;
134
+
135
+ if (tokenMap) {
136
+ const mapping = tokenMap.tools[t];
137
+ if (mapping === undefined) {
138
+ out.add(t); // not a canonical key — pass through (may be a native PI tool)
139
+ } else if (mapping[PI] === null) {
140
+ console.warn(`[agent-modes] Tool "${t}" has no PI equivalent — omitted from the tool set`);
141
+ } else {
142
+ out.add(mapping[PI] as string);
143
+ }
144
+ } else {
145
+ const fallback = COPILOT_TO_PI[t];
146
+ if (fallback) {
147
+ for (const f of fallback) out.add(f);
148
+ } else {
149
+ out.add(t);
150
+ }
151
+ }
152
+ }
153
+ return [...out].sort();
154
+ }
155
+
156
+ // ─── PATH token resolution ───────────────────────────────────────────────────
157
+
158
+ /**
159
+ * Resolve a `{{PATH:...}}` argument for the pi harness.
160
+ *
161
+ * A named key resolves through token-map.json's `paths` table; the `plugin_root`
162
+ * base is always overridden with the actual package root (the directory
163
+ * containing `agents/`) because the map's literal only describes the default npm
164
+ * install layout. Anything that is not a named key is treated as a relative path
165
+ * under the package root.
166
+ */
167
+ function resolvePathToken(arg: string, tokenMap: TokenMap | null, pluginRoot: string): string {
168
+ const entry = tokenMap?.paths?.[arg];
169
+ if (!entry) return join(pluginRoot, arg);
170
+
171
+ const spec = entry[PI];
172
+ if (typeof spec === "string") {
173
+ return arg === "plugin_root" ? pluginRoot : spec; // ${ENV} literals left verbatim
174
+ }
175
+ if (spec && typeof spec === "object" && typeof spec.base === "string" && typeof spec.relative === "string") {
176
+ const base = resolvePathToken(spec.base, tokenMap, pluginRoot);
177
+ return join(base, spec.relative);
178
+ }
179
+ console.warn(`[agent-modes] Unresolvable {{PATH:${arg}}} — token dropped`);
180
+ return "";
181
+ }
182
+
183
+ // ─── Prompt composition ──────────────────────────────────────────────────────
184
+
185
+ /**
186
+ * Compose the PI system prompt for a composable agent directory.
187
+ *
188
+ * Substitution order follows the token-map contract: SECTION, then TOOL, then
189
+ * PATH. Section files resolve from `<agentDir>/pi/<name>.md`; missing sections
190
+ * and null-mapped tools drop the token with a warning so the prompt never ships
191
+ * literal token syntax.
192
+ */
193
+ function composePiPrompt(
194
+ agentDir: string,
195
+ id: string,
196
+ tokenMap: TokenMap | null,
197
+ pluginRoot: string,
198
+ ): string {
199
+ let body = readFileSync(join(agentDir, "agent.md"), "utf-8").trim();
200
+
201
+ // 1. {{SECTION:name}} → content of pi/<name>.md
202
+ body = body.replace(/\{\{SECTION:([^}]+)\}\}/g, (_token, name: string) => {
203
+ const sectionPath = join(agentDir, "pi", `${name}.md`);
204
+ if (!isFile(sectionPath)) {
205
+ console.warn(`[agent-modes] Missing section "${name}" for agent "${id}" — token dropped`);
206
+ return "";
207
+ }
208
+ return readFileSync(sectionPath, "utf-8").trim();
209
+ });
210
+
211
+ // 2. {{TOOL:key}} → tools[key].pi (null mapping drops the token)
212
+ body = body.replace(/\{\{TOOL:([^}]+)\}\}/g, (_token, key: string) => {
213
+ const mapping = tokenMap?.tools?.[key];
214
+ if (mapping === undefined) {
215
+ console.warn(`[agent-modes] Unknown tool token {{TOOL:${key}}} in agent "${id}" — token dropped`);
216
+ return "";
217
+ }
218
+ if (mapping[PI] === null) {
219
+ console.warn(`[agent-modes] Tool token {{TOOL:${key}}} has no PI equivalent in agent "${id}" — token dropped`);
220
+ return "";
221
+ }
222
+ return mapping[PI] as string;
223
+ });
224
+
225
+ // 3. {{PATH:key-or-relative}} → resolved against the actual plugin root
226
+ body = body.replace(/\{\{PATH:([^}]+)\}\}/g, (_token, arg: string) => resolvePathToken(arg, tokenMap, pluginRoot));
227
+
228
+ return body;
229
+ }
230
+
231
+ // ─── Composable directory reading ────────────────────────────────────────────
232
+
233
+ interface Frontmatter {
234
+ name?: string;
235
+ description?: string;
236
+ tools?: string[];
237
+ }
238
+
239
+ interface ComposableFrontmatter extends Frontmatter {
240
+ name: string;
241
+ description: string;
242
+ tools: string[];
243
+ }
244
+
245
+ function parseFrontmatterJson(raw: string, id: string): ComposableFrontmatter {
246
+ let fm: Record<string, unknown>;
247
+ try {
248
+ fm = JSON.parse(raw) as Record<string, unknown>;
249
+ } catch (err) {
250
+ console.warn(`[agent-modes] Invalid frontmatter.json for agent "${id}": ${err}`);
251
+ return { name: prettifyId(id), description: "", tools: [] };
252
+ }
253
+
254
+ return {
255
+ name: typeof fm.name === "string" && fm.name !== "" ? fm.name : prettifyId(id),
256
+ description: typeof fm.description === "string" ? fm.description : "",
257
+ tools: Array.isArray(fm.tools) ? (fm.tools.filter((t): t is string => typeof t === "string") as string[]) : [],
258
+ };
259
+ }
260
+
261
+ function readComposableAgent(
262
+ agentDir: string,
263
+ id: string,
264
+ tokenMap: TokenMap | null,
265
+ pluginRoot: string,
266
+ ): AgentDef | null {
267
+ try {
268
+ const fm = parseFrontmatterJson(readFileSync(join(agentDir, "pi", "frontmatter.json"), "utf-8"), id);
269
+ const tools = translateTools(fm.tools, tokenMap);
270
+ return {
271
+ id,
272
+ name: fm.name,
273
+ description: fm.description,
274
+ color: AGENT_COLORS[id] ?? "accent",
275
+ tools: tools.length > 0 ? tools : [...DEFAULT_TOOLS],
276
+ prompt: composePiPrompt(agentDir, id, tokenMap, pluginRoot),
277
+ };
278
+ } catch (err) {
279
+ console.warn(`[agent-modes] Failed to read composable agent "${id}": ${err}`);
280
+ return null;
281
+ }
282
+ }
283
+
284
+ // ─── Flat file parsing (transitional fallback) ───────────────────────────────
285
+
286
+ interface FlatParsed {
287
+ name?: string;
288
+ description?: string;
289
+ tools?: string[];
290
+ prompt: string;
291
+ }
292
+
293
+ function parseFlatFrontmatter(raw: string): FlatParsed {
294
+ const lines = raw.split(/\r?\n/);
295
+ if (lines[0] !== "---") return { prompt: raw };
296
+
297
+ // Find closing ---
298
+ let sep = -1;
299
+ for (let i = 1; i < lines.length; i++) {
300
+ if (lines[i].trim() === "---") {
301
+ sep = i;
302
+ break;
303
+ }
304
+ }
305
+ if (sep === -1) return { prompt: raw };
306
+
307
+ const yamlLines = lines.slice(1, sep);
308
+ const prompt = lines.slice(sep + 1).join("\n").trim();
309
+
310
+ let name: string | undefined;
311
+ let description: string | undefined;
312
+ let tools: string[] | undefined;
313
+
314
+ for (const line of yamlLines) {
315
+ const trimmed = line.trim();
316
+ const colonIdx = trimmed.indexOf(":");
317
+ if (colonIdx === -1) continue;
318
+
319
+ const key = trimmed.slice(0, colonIdx).trim().toLowerCase();
320
+ const val = trimmed.slice(colonIdx + 1).trim();
321
+
322
+ if (key === "name") {
323
+ name = val.replace(/^["']|["']$/g, "");
324
+ } else if (key === "description") {
325
+ description = val.replace(/^["']|["']$/g, "");
326
+ }
327
+ }
328
+
329
+ // Parse tools from the tools frontmatter.
330
+ // Supports two formats:
331
+ // tools: ["bash", "view"] (JSON array, single line)
332
+ // tools: (YAML list, subsequent lines)
333
+ // - bash
334
+ // - view
335
+ let inToolsBlock = false;
336
+ for (const line of yamlLines) {
337
+ const trimmed = line.trim();
338
+
339
+ // Check for inline JSON array: tools: ["foo", "bar"]
340
+ const colonIdx = trimmed.indexOf(":");
341
+ if (colonIdx !== -1) {
342
+ const key = trimmed.slice(0, colonIdx).trim().toLowerCase();
343
+ const val = trimmed.slice(colonIdx + 1).trim();
344
+ if (key === "tools") {
345
+ if (val.startsWith("[")) {
346
+ try {
347
+ tools = JSON.parse(val);
348
+ } catch {
349
+ // Malformed JSON — fall through
350
+ }
351
+ continue;
352
+ }
353
+ if (val === "[]" || val === "") {
354
+ tools = [];
355
+ continue;
356
+ }
357
+ }
358
+ }
359
+
360
+ // YAML block format: "tools:" on its own line, items on subsequent lines
361
+ if (trimmed === "tools:") {
362
+ inToolsBlock = true;
363
+ if (!tools) tools = [];
364
+ continue;
365
+ }
366
+ if (inToolsBlock) {
367
+ if (line.startsWith(" ") || line.startsWith("\t") || trimmed.startsWith("- ")) {
368
+ const item = trimmed.replace(/^- /, "").trim().replace(/^["']|["']$/g, "");
369
+ if (item) {
370
+ if (!tools) tools = [];
371
+ tools.push(item);
372
+ }
373
+ } else if (trimmed !== "") {
374
+ inToolsBlock = false;
375
+ }
376
+ }
377
+ }
378
+
379
+ return { name, description, tools, prompt };
380
+ }
381
+
382
+ function readFlatAgent(filePath: string, tokenMap: TokenMap | null): AgentDef | null {
383
+ let raw: string;
384
+ try {
385
+ raw = readFileSync(filePath, "utf-8");
386
+ } catch (err) {
387
+ console.warn(`[agent-modes] Failed to read ${basename(filePath)}: ${err}`);
388
+ return null;
389
+ }
390
+
391
+ const parsed = parseFlatFrontmatter(raw);
392
+ const id = basename(filePath).replace(/\.agent\.md$/i, "").toLowerCase();
393
+ const name = parsed.name || prettifyId(id);
394
+ const tools = translateTools(parsed.tools, tokenMap);
395
+
396
+ return {
397
+ id,
398
+ name,
399
+ description: parsed.description || "",
400
+ color: AGENT_COLORS[id] ?? "accent",
401
+ tools: tools.length > 0 ? tools : [...DEFAULT_TOOLS],
402
+ prompt: parsed.prompt || `You are ${name}. ${parsed.description || ""}`,
403
+ };
404
+ }
405
+
406
+ // ─── Discovery ───────────────────────────────────────────────────────────────
407
+
408
+ /**
409
+ * Discover agents from the agents directory.
410
+ *
411
+ * Composable directories (`<name>/agent.md` + `<name>/pi/frontmatter.json`) are
412
+ * the primary source. Flat `*.agent.md` files (generated output for Copilot) are
413
+ * read only when no composable directory exists for that agent id. When
414
+ * `tokenMap` is omitted, token-map.json is loaded from the package root;
415
+ * missing/unreadable maps fall back to the built-in copilot→pi translation.
416
+ */
417
+ export function discoverAgents(agentsDir: string, tokenMap: TokenMap | null = null): AgentDef[] {
418
+ if (!existsSync(agentsDir) || !statSync(agentsDir).isDirectory()) {
419
+ console.warn(`[agent-modes] Agents directory not found: ${agentsDir}`);
420
+ return [];
421
+ }
422
+
423
+ const map = tokenMap ?? loadTokenMap(agentsDir);
424
+ const pluginRoot = join(agentsDir, "..");
425
+
426
+ const entries = readdirSync(agentsDir, { withFileTypes: true }).sort((a, b) =>
427
+ a.name.localeCompare(b.name),
428
+ );
429
+
430
+ const agents: AgentDef[] = [];
431
+ const composableIds = new Set<string>();
432
+
433
+ // 1. Composable directory format
434
+ for (const entry of entries) {
435
+ if (!entry.isDirectory()) continue;
436
+ const dir = join(agentsDir, entry.name);
437
+ if (!isFile(join(dir, "agent.md")) || !isFile(join(dir, "pi", "frontmatter.json"))) continue;
438
+ const id = entry.name.toLowerCase();
439
+ const def = readComposableAgent(dir, id, map, pluginRoot);
440
+ if (def) {
441
+ composableIds.add(id);
442
+ agents.push(def);
443
+ }
444
+ }
445
+
446
+ // 2. Flat fallback for agents without a composable directory
447
+ for (const entry of entries) {
448
+ if (!entry.isFile() || !entry.name.endsWith(".agent.md")) continue;
449
+ const id = entry.name.replace(/\.agent\.md$/i, "").toLowerCase();
450
+ if (composableIds.has(id)) continue; // composable wins
451
+ const def = readFlatAgent(join(agentsDir, entry.name), map);
452
+ if (def) agents.push(def);
453
+ }
454
+
455
+ return agents;
456
+ }
457
+
458
+ export { DEFAULT_TOOLS };