@jaybeeuu/agent-cortex 1.37.1 → 1.39.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 (135) hide show
  1. package/.agents/skills/style-versioning/SKILL.md +9 -8
  2. package/.github/workflows/ci.yml +190 -8
  3. package/AGENTS.md +27 -6
  4. package/CHANGELOG.md +72 -0
  5. package/README.md +156 -39
  6. package/agents/README.md +49 -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 +6 -3
  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 +76 -1
  31. package/bin/installers/claude.mjs +376 -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 +6 -4
  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/extension-manifests.md +75 -0
  43. package/docs/ideas/improve-pi-system-prompt.md +34 -0
  44. package/docs/ideas/model-selector.md +40 -0
  45. package/docs/ideas/pi-task-memory.md +34 -0
  46. package/docs/ideas/qa-extension.md +43 -0
  47. package/docs/ideas/record-ideas-as-beads.md +115 -0
  48. package/docs/ideas/subagent-visibility.md +72 -0
  49. package/docs/ideas/task-session-context.md +89 -0
  50. package/docs/ideas/unified-ci-pipeline.md +28 -0
  51. package/docs/research/skill-design-research.md +4 -4
  52. package/docs/research/skill-improvements-analysis.md +1 -1
  53. package/docs/skills/skill-anatomy.md +47 -2
  54. package/docs/technical-direction/migration-execution-labels.md +132 -0
  55. package/extensions/agent-modes/README.md +54 -0
  56. package/extensions/agent-modes/discover.test.ts +241 -0
  57. package/extensions/agent-modes/discover.ts +458 -0
  58. package/extensions/agent-modes/index.ts +13 -224
  59. package/extensions/agent-modes/node_modules/.bin/pi +21 -0
  60. package/extensions/agent-modes/node_modules/.bin/tsc +21 -0
  61. package/extensions/agent-modes/node_modules/.bin/tsserver +21 -0
  62. package/extensions/agent-modes/node_modules/.bin/tsx +21 -0
  63. package/extensions/agent-modes/package.json +20 -0
  64. package/extensions/agent-modes/tsconfig.json +12 -0
  65. package/extensions/subagent/README.md +19 -0
  66. package/extensions/subagent/index.ts +131 -45
  67. package/extensions/subagent/node_modules/.bin/pi +21 -0
  68. package/extensions/subagent/node_modules/.bin/pi-ai +21 -0
  69. package/extensions/subagent/node_modules/.bin/tsc +21 -0
  70. package/extensions/subagent/node_modules/.bin/tsserver +21 -0
  71. package/extensions/subagent/node_modules/.bin/tsx +21 -0
  72. package/extensions/subagent/package.json +23 -0
  73. package/extensions/subagent/tsconfig.json +12 -0
  74. package/extensions/subagent/wait.test.ts +202 -0
  75. package/extensions/subagent/wait.ts +118 -0
  76. package/hooks/claude/hooks.json +38 -0
  77. package/hooks/claude/scripts/notify.mjs +54 -0
  78. package/lib/cli.mjs +68 -3
  79. package/package.json +6 -4
  80. package/plugin.json +1 -1
  81. package/pnpm-workspace.yaml +1 -1
  82. package/scripts/build-copilot-agents.mjs +30 -0
  83. package/scripts/lib/compose-agent.mjs +241 -0
  84. package/skills/engineering/design-an-interface/SKILL.md +78 -58
  85. package/skills/engineering/grill-with-docs/REFERENCE.md +78 -0
  86. package/skills/engineering/grill-with-docs/SKILL.md +66 -63
  87. package/skills/engineering/improve-codebase-architecture/SKILL.md +66 -48
  88. package/skills/engineering/technical-direction/FORMAT.md +38 -0
  89. package/skills/engineering/technical-direction/SKILL.md +70 -77
  90. package/skills/planning/classify-bead/SKILL.md +59 -35
  91. package/skills/planning/create-task/REFERENCE.md +40 -0
  92. package/skills/planning/create-task/SKILL.md +70 -66
  93. package/skills/planning/create-task/scripts/create-chores.ts +6 -1
  94. package/skills/planning/create-task/scripts/node_modules/.bin/tsc +21 -0
  95. package/skills/planning/create-task/scripts/node_modules/.bin/tsserver +21 -0
  96. package/skills/planning/create-task/scripts/node_modules/.bin/tsx +21 -0
  97. package/skills/planning/prd-to-tasks/REFERENCE.md +66 -0
  98. package/skills/planning/prd-to-tasks/SKILL.md +86 -170
  99. package/skills/planning/record-idea/FORMAT.md +44 -0
  100. package/skills/planning/record-idea/SKILL.md +70 -53
  101. package/skills/planning/request-refactor-plan/FORMAT.md +45 -0
  102. package/skills/planning/request-refactor-plan/SKILL.md +57 -46
  103. package/skills/planning/write-a-prd/FORMAT.md +54 -0
  104. package/skills/planning/write-a-prd/SKILL.md +49 -48
  105. package/skills/planning/write-a-ticket/SKILL.md +68 -53
  106. package/skills/productivity/handoff/SKILL.md +12 -0
  107. package/skills/productivity/init-beads/SKILL.md +48 -40
  108. package/skills/productivity/validate-agent-dir/scripts/node_modules/.bin/tsc +21 -0
  109. package/skills/productivity/validate-agent-dir/scripts/node_modules/.bin/tsserver +21 -0
  110. package/skills/productivity/validate-agent-dir/scripts/node_modules/.bin/tsx +21 -0
  111. package/skills/productivity/write-a-skill/SKILL.md +2 -1
  112. package/skills/review/maintain-agent-docs/SKILL.md +71 -51
  113. package/skills/workflow/hitl-collab/SKILL.md +86 -17
  114. package/skills/workflow/ralph/REFERENCE.md +24 -19
  115. package/skills/workflow/ralph/SKILL.md +88 -74
  116. package/skills/workflow/run-pipeline-stage/REFERENCE.md +22 -0
  117. package/skills/workflow/run-pipeline-stage/SKILL.md +91 -93
  118. package/skills/workflow/run-pipeline-stage/scripts/generate-progress.test.ts +6 -107
  119. package/skills/workflow/run-pipeline-stage/scripts/generate-progress.ts +4 -2
  120. package/skills/workflow/run-pipeline-stage/scripts/node_modules/.bin/tsc +21 -0
  121. package/skills/workflow/run-pipeline-stage/scripts/node_modules/.bin/tsserver +21 -0
  122. package/skills/workflow/run-pipeline-stage/scripts/node_modules/.bin/tsx +21 -0
  123. package/skills/workflow/using-agent-skills/SKILL.md +122 -0
  124. package/test/claude-installer.test.mjs +248 -0
  125. package/test/cli.test.mjs +115 -2
  126. package/test/copilot-installer.test.mjs +298 -0
  127. package/test/installer.test.mjs +179 -0
  128. package/test/notify-hook.test.mjs +63 -0
  129. package/test/pi-installer.test.mjs +371 -0
  130. package/token-map.README.md +130 -0
  131. package/token-map.json +106 -0
  132. package/.github/workflows/ci.test.ts +0 -217
  133. package/.github/workflows/release.yml +0 -58
  134. package/scripts/build-claude-agents.mjs +0 -179
  135. package/scripts/publish-release.sh +0 -32
@@ -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 };