@jaybeeuu/agent-cortex 1.20.0 → 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 (103) hide show
  1. package/.agents/skills/style-versioning/SKILL.md +102 -0
  2. package/.changeset/README.md +8 -0
  3. package/.changeset/config.json +11 -0
  4. package/.github/workflows/ci.yml +227 -6
  5. package/AGENTS.md +29 -24
  6. package/CHANGELOG.md +119 -5
  7. package/README.md +132 -33
  8. package/agents/README.md +47 -13
  9. package/agents/plan/agent.md +51 -0
  10. package/agents/plan/claude/frontmatter.json +6 -0
  11. package/agents/plan/copilot/frontmatter.json +6 -0
  12. package/agents/plan/pi/frontmatter.json +6 -0
  13. package/agents/plan.agent.md +3 -0
  14. package/agents/ralph/agent.md +262 -0
  15. package/agents/ralph/claude/frontmatter.json +6 -0
  16. package/agents/ralph/claude/polling.md +31 -0
  17. package/agents/ralph/copilot/frontmatter.json +6 -0
  18. package/agents/ralph/copilot/polling.md +62 -0
  19. package/agents/ralph/pi/frontmatter.json +6 -0
  20. package/agents/ralph/pi/polling.md +74 -0
  21. package/agents/ralph-plan/agent.md +114 -0
  22. package/agents/ralph-plan/claude/frontmatter.json +6 -0
  23. package/agents/ralph-plan/copilot/frontmatter.json +6 -0
  24. package/agents/ralph-plan/pi/frontmatter.json +6 -0
  25. package/agents/ralph-plan.agent.md +5 -2
  26. package/agents/ralph.agent.md +83 -57
  27. package/agents/strategy/agent.md +104 -0
  28. package/agents/strategy/claude/frontmatter.json +6 -0
  29. package/agents/strategy/copilot/frontmatter.json +6 -0
  30. package/agents/strategy/pi/frontmatter.json +6 -0
  31. package/agents/strategy.agent.md +3 -0
  32. package/bin/agent-cortex.mjs +68 -1
  33. package/bin/installers/claude.mjs +262 -0
  34. package/bin/installers/copilot.mjs +129 -0
  35. package/bin/installers/pi.mjs +197 -0
  36. package/claude/.claude-plugin/plugin.json +1 -1
  37. package/claude/agents/plan.md +3 -1
  38. package/claude/agents/ralph-plan.md +5 -3
  39. package/claude/agents/strategy.md +3 -1
  40. package/claude/hooks/scripts/notify.mjs +54 -0
  41. package/claude/hooks.json +15 -0
  42. package/docs/claude-hooks.md +115 -0
  43. package/docs/ideas/unified-ci-pipeline.md +28 -0
  44. package/docs/skills/skill-anatomy.md +9 -0
  45. package/extensions/agent-modes/README.md +54 -0
  46. package/extensions/agent-modes/discover.test.ts +241 -0
  47. package/extensions/agent-modes/discover.ts +458 -0
  48. package/extensions/agent-modes/index.ts +13 -224
  49. package/extensions/agent-modes/node_modules/.bin/pi +21 -0
  50. package/extensions/agent-modes/node_modules/.bin/tsc +21 -0
  51. package/extensions/agent-modes/node_modules/.bin/tsserver +21 -0
  52. package/extensions/agent-modes/node_modules/.bin/tsx +21 -0
  53. package/extensions/agent-modes/package.json +20 -0
  54. package/extensions/agent-modes/tsconfig.json +12 -0
  55. package/extensions/session-start.ts +1 -1
  56. package/extensions/skill-stats/context-loader.test.ts +1 -1
  57. package/extensions/skill-stats/node_modules/.bin/pi +21 -0
  58. package/extensions/skill-stats/node_modules/.bin/tsc +21 -0
  59. package/extensions/skill-stats/node_modules/.bin/tsserver +21 -0
  60. package/extensions/skill-stats/node_modules/.bin/tsx +21 -0
  61. package/extensions/subagent/README.md +19 -0
  62. package/extensions/subagent/index.ts +131 -45
  63. package/extensions/subagent/node_modules/.bin/pi +21 -0
  64. package/extensions/subagent/node_modules/.bin/pi-ai +21 -0
  65. package/extensions/subagent/node_modules/.bin/tsc +21 -0
  66. package/extensions/subagent/node_modules/.bin/tsserver +21 -0
  67. package/extensions/subagent/node_modules/.bin/tsx +21 -0
  68. package/extensions/subagent/package.json +23 -0
  69. package/extensions/subagent/tsconfig.json +12 -0
  70. package/extensions/subagent/wait.test.ts +202 -0
  71. package/extensions/subagent/wait.ts +118 -0
  72. package/hooks/claude/hooks.json +38 -0
  73. package/hooks/claude/scripts/notify.mjs +54 -0
  74. package/lib/cli.mjs +62 -3
  75. package/package.json +10 -7
  76. package/plugin.json +1 -1
  77. package/pnpm-workspace.yaml +1 -0
  78. package/scripts/build-copilot-agents.mjs +30 -0
  79. package/scripts/lib/compose-agent.mjs +241 -0
  80. package/scripts/sync-plugin-version.sh +17 -0
  81. package/skills/engineering/design-an-interface/SKILL.md +1 -1
  82. package/skills/engineering/improve-codebase-architecture/SKILL.md +1 -1
  83. package/skills/workflow/ralph/REFERENCE.md +2 -2
  84. package/skills/workflow/ralph/SKILL.md +6 -6
  85. package/skills/workflow/run-pipeline-stage/scripts/generate-progress.ts +4 -2
  86. package/skills/workflow/using-agent-skills/SKILL.md +15 -2
  87. package/test/cli.test.mjs +115 -2
  88. package/test/copilot-installer.test.mjs +298 -0
  89. package/test/installer.test.mjs +179 -0
  90. package/test/notify-hook.test.mjs +63 -0
  91. package/test/pi-installer.test.mjs +371 -0
  92. package/token-map.README.md +128 -0
  93. package/token-map.json +106 -0
  94. package/.beads/README.md +0 -81
  95. package/.beads/config.yaml +0 -1
  96. package/.beads/issues.jsonl +0 -236
  97. package/.beads/metadata.json +0 -7
  98. package/.github/workflows/ci.test.ts +0 -217
  99. package/.github/workflows/publish.yml +0 -36
  100. package/scripts/build-claude-agents.mjs +0 -179
  101. package/skills/workflow/using-agent-skills/scripts/validate-skill-structure.test.ts +0 -73
  102. /package/extensions/{context-loader.md → lib/context-loader.md} +0 -0
  103. /package/extensions/{context-loader.ts → lib/context-loader.ts} +0 -0
@@ -0,0 +1,54 @@
1
+ #!/usr/bin/env node
2
+ // Claude Code Notification hook — desktop notification on task completion/error.
3
+ //
4
+ // Reads the Notification hook JSON payload from stdin and prints a
5
+ // `{ "terminalSequence": "..." }` response for Claude Code to emit. Hooks run
6
+ // without a controlling terminal, so OSC sequences must go through Claude Code
7
+ // itself (it handles tmux/screen re-wrapping); writing /dev/tty directly fails.
8
+ //
9
+ // Mapped from the pi `notify` extension (extensions/notify/): pi notifies on
10
+ // agent_end for multi-turn or long tasks. Claude Code's Notification matcher is an
11
+ // exact-string list over `notification_type` values (there are no `success`/
12
+ // `error` types) — hooks.json selects `agent_completed|agent_needs_input|permission_prompt`
13
+ // to mirror "task completed / failed / needs approval". The payload already carries
14
+ // a message + title, so no summarization LLM call is needed here.
15
+ //
16
+ // Zero dependencies; runs on the Node that ships with Claude Code.
17
+
18
+ let raw = "";
19
+ for await (const chunk of process.stdin) {
20
+ raw += chunk;
21
+ }
22
+
23
+ let payload = {};
24
+ try {
25
+ payload = raw.trim() ? JSON.parse(raw) : {};
26
+ } catch {
27
+ process.exit(0); // never fail a session on an unparsable payload
28
+ }
29
+
30
+ const message = typeof payload.message === "string" ? payload.message.trim() : "";
31
+ if (!message) process.exit(0); // nothing worth notifying (e.g. autocompact events)
32
+
33
+ const title =
34
+ typeof payload.title === "string" && payload.title.trim() ? payload.title.trim() : "Claude Code";
35
+ const type = typeof payload.notification_type === "string" ? payload.notification_type : "";
36
+
37
+ process.stdout.write(JSON.stringify({ terminalSequence: pickSequence(title, message, type) }) + "\n");
38
+
39
+ /** Pick an OSC notification escape sequence for the detected terminal. */
40
+ function pickSequence(title, message, type) {
41
+ const body = message.replace(/\u001b/g, "").replace(/\n/g, " ").trim();
42
+ const label = type ? `${type}: ${title}` : title;
43
+
44
+ if (process.env.KITTY_WINDOW_ID) {
45
+ // Kitty: two-part persistent notification (title + body) — same form as the pi notify extension.
46
+ return `\u001b]99;i=1:d=1;${title}\u001b\\\u001b]99;i=1:p=body;${body}\u001b\\`;
47
+ }
48
+ if (process.env.TERM_PROGRAM === "iTerm.app" || process.env.ITERM_SESSION_ID) {
49
+ // iTerm2 / ConEmu / Windows Terminal / WezTerm: OSC 9.
50
+ return `\u001b]9;${label}: ${body}\u0007`;
51
+ }
52
+ // urxvt / Ghostty / Warp, and default fallback: OSC 777.
53
+ return `\u001b]777;notify;${label};${body}\u0007`;
54
+ }
package/claude/hooks.json CHANGED
@@ -12,12 +12,27 @@
12
12
  "type": "command",
13
13
  "command": "echo 'Style policy: this project ships style skills — prefer them over ad-hoc choices. Invoke style-code BEFORE writing or editing code (and when refactoring); style-tests BEFORE writing or changing tests; style-documentation BEFORE writing or updating docs; style-comms BEFORE composing commit messages, PR descriptions, tickets, or other written deliverables.'"
14
14
  },
15
+ {
16
+ "type": "command",
17
+ "command": "echo 'Skill policy: agent-cortex ships skills — prefer them over ad-hoc choices. using-agent-skills routes work to the right skill (check it first when unsure); bd-tool tracks beads tasks (run bd ready for available work); git-workflow enforces branch and PR discipline. Invoke skills via the Skill tool.'"
18
+ },
15
19
  {
16
20
  "type": "command",
17
21
  "command": "echo 'Be extremely concise. Sacrifice grammar for the sake of concision.'"
18
22
  }
19
23
  ]
20
24
  }
25
+ ],
26
+ "Notification": [
27
+ {
28
+ "matcher": "agent_completed|agent_needs_input|permission_prompt",
29
+ "hooks": [
30
+ {
31
+ "type": "command",
32
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/scripts/notify.mjs\""
33
+ }
34
+ ]
35
+ }
21
36
  ]
22
37
  }
23
38
  }
@@ -0,0 +1,115 @@
1
+ # Claude Code hooks — extension port audit
2
+
3
+ The pi extensions in `extensions/` were audited for Claude Code equivalents.
4
+ Implemented ports live in `hooks/claude/hooks.json` (copied verbatim into the
5
+ generated plugin as `claude/hooks.json`); support scripts live in
6
+ `hooks/claude/scripts/` and are bundled into `claude/hooks/scripts/` by
7
+ `bin/installers/claude.mjs` (referenced from hooks.json via `$CLAUDE_PLUGIN_ROOT`).
8
+
9
+ Claude Code's hook model differs from PI's: hooks are shell commands fed a JSON
10
+ payload on stdin, with events like `SessionStart`, `SessionEnd`, `Stop`,
11
+ `SubagentStop`, and `Notification` — there is no `before_agent_start` /
12
+ `agent_end` / `tool_call` extension API, no runtime tool registration, and no
13
+ UI surface. Each decision below is grounded in the Claude Code hooks reference.
14
+
15
+ | pi extension | Claude port | Decision |
16
+ |---|---|---|
17
+ | `session-start.ts` | `SessionStart` hook commands | **Port** (extended) |
18
+ | `auto-name.ts` | — | **Reject** — built-in |
19
+ | `notify/` | `Notification` hook + `scripts/notify.mjs` | **Port** |
20
+ | `skill-stats/` | — | **Reject** — no equivalent |
21
+ | `subagent/` | — | **Reject** — native |
22
+ | `agent-modes/` | — | **Reject** — no equivalent |
23
+
24
+ ## `session-start.ts` → SessionStart (port)
25
+
26
+ pi injects skill-availability hints + project context files (AGENTS.md,
27
+ `docs/user-preferences.md`) into every session. Claude Code already loads
28
+ `AGENTS.md` natively and the existing SessionStart hook nudge ("Load AGENTS.md
29
+ and active beads context…") plus the style-skill policy (PR #57) cover the
30
+ context side. This bead closes the remaining gap: a skill-policy command listing
31
+ the meta-skills (`using-agent-skills` for routing, `bd-tool` for beads context,
32
+ `git-workflow` for branch/PR discipline). SessionStart command-hook stdout is
33
+ added to Claude's context, so plain `echo` commands are the right shape —
34
+ keep them fast and dependency-free. Skipped from the pi original: the dynamic
35
+ file read of AGENTS.md/user-preferences.md, since Claude Code loads AGENTS.md
36
+ itself and user-preferences.md is a pi-harness document.
37
+
38
+ ## `auto-name.ts` → Reject (built-in)
39
+
40
+ pi names sessions via a tiny side-model call on the first prompt. Claude Code
41
+ already auto-generates descriptive session titles (visible in `claude --resume`;
42
+ a background model request in interactive and headless/SDK sessions) and supports
43
+ explicit naming via `--name` / `/rename`. A hook-based title generator would
44
+ duplicate platform behaviour with an extra LLM call — rejected. (SessionStart
45
+ hooks can also set `sessionTitle` in JSON output for the rare case where a
46
+ custom title is genuinely needed.)
47
+
48
+ ## `notify/` → Notification hook (port)
49
+
50
+ pi notifies on `agent_end` for multi-turn/long tasks with a desktop notification
51
+ + bell. Claude Code's native `Notification` event fires at the equivalent
52
+ completion points and already carries `message`, `title`, and `notification_type`
53
+ in the payload — and hooks emit desktop notifications by returning a
54
+ `terminalSequence` JSON field (hooks have no controlling terminal; Claude Code
55
+ emits the sequence itself, including inside tmux/screen).
56
+
57
+ Implemented as a `Notification` hook with matcher
58
+ `agent_completed|agent_needs_input|permission_prompt` invoking `scripts/notify.mjs`,
59
+ which reads the payload from stdin and prints the OSC sequence for the detected
60
+ terminal (Kitty OSC 99, iTerm2/Windows Terminal OSC 9, default urxvt/Ghostty/Warp
61
+ OSC 777 — same detection as the pi `notify` extension). The Notification matcher
62
+ is an **exact-string list** over the `notification_type` field; there are no
63
+ `success`/`error` types (an earlier draft used that matcher, which silently never
64
+ fired). The documented types are `permission_prompt`, `idle_prompt`, `auth_success`,
65
+ `elicitation_dialog`, `elicitation_url_dialog`, `elicitation_complete`,
66
+ `elicitation_response`, `agent_needs_input`, `agent_completed`,
67
+ `quota_auto_resume_fired`, `quota_auto_resume_stale`, `quota_auto_resume_disabled`
68
+ (the `agent_*` types require Claude Code v2.1.198+, the `quota_auto_resume_*`
69
+ types v2.1.234+). The selection maps to pi's "task completed / failed / needs
70
+ approval" semantics: `agent_completed` covers completion and failure ("finishes
71
+ or fails"), `agent_needs_input` covers a background session waiting on input, and
72
+ `permission_prompt` covers an awaited approval. Explicitly excluded as noise:
73
+ `idle_prompt` (fires ~60s after every turn you don't answer within a minute — a
74
+ desktop ping on every pause), `auth_success` and `quota_auto_resume_*` (not task
75
+ events), and `elicitation_*` (dialog trivia). Skipped from the pi original: the LLM
76
+ response summary (Claude's notification message is already the summary) and the tmux
77
+ session:window.pane title (unavailable to hooks; the plugin title suffices).
78
+
79
+ ## `skill-stats/` → Reject (no equivalent)
80
+
81
+ pi tracks skills loaded into the system prompt, `/skill:` invocations, SKILL.md
82
+ reads, and turns, exposing a `/skill-stats` query. Claude Code exposes none of
83
+ the required observability to hooks:
84
+
85
+ - **No "loaded" event** — nothing fires when a skill enters context, and hooks
86
+ never see system-prompt contents, so the primary "loaded" metric has no source.
87
+ - **Partial invocation/read observability only** — `PreToolUse` on the `Skill`
88
+ tool and `UserPromptExpansion` on slash commands would approximate
89
+ invocations, and `PostToolUse` on `Read` would approximate skill reads, but
90
+ the signals are fuzzy (Read covers any file; direct `/skill` typing bypasses
91
+ `PreToolUse`).
92
+ - **No query surface** — Claude Code plugin commands are markdown files, not
93
+ scripts, so there is no equivalent of the pi `/skill-stats` command, and
94
+ persistent state would live outside the plugin model.
95
+
96
+ A half-observable port would diverge from the pi semantics and add moving parts
97
+ for little signal. Rejected; Claude Code ships its own usage analytics
98
+ (`/usage`, sessions list, statsig) if insights are needed.
99
+
100
+ ## `subagent/` → Reject (native)
101
+
102
+ pi's `subagent` tool spawns isolated `pi --mode json` processes. Claude Code has
103
+ native subagents — the plugin already ships `claude/agents/*.md` (including
104
+ `ralph`, `plan`, `strategy`, `ralph-plan`) executed via the built-in Agent/Task
105
+ tools with per-session context isolation — so a hook-based workaround would be
106
+ strictly worse.
107
+
108
+ ## `agent-modes/` → Reject (no equivalent)
109
+
110
+ pi switches system prompts + tool restrictions at runtime with a TUI mode
111
+ selector. Claude Code exposes no hook or settings surface for dynamic
112
+ tool-restriction/prompt-switching of the main agent; the closest equivalents are
113
+ native (permission modes like plan mode configured via settings, and subagents
114
+ — already covered above). The `--agent <name>` start flag is covered by
115
+ `claude --agent` in newer versions. Rejected.
@@ -0,0 +1,28 @@
1
+ # Idea: Unified CI Pipeline with Parallel Checks
2
+
3
+ ## Status
4
+ Backlog idea (not implementation-ready)
5
+
6
+ ## Why it might be useful
7
+ The current CI setup has two problems:
8
+ 1. **Slow feedback** — checks run serially, making the pipeline slower than it needs to be.
9
+ 2. **Broken releases** — the release job (version branch creation) is a separate pipeline, so it can fire even when checks fail. A broken commit can reach the version branch.
10
+
11
+ Combining them gives faster feedback (parallel checks) and a safety gate (release step only runs if all checks pass).
12
+
13
+ ## How we might do it
14
+ - Merge the check pipeline and release pipeline into a single pipeline.
15
+ - Fan out all checks to run in parallel.
16
+ - Make the version branch creation a downstream job/step that depends on all checks passing.
17
+
18
+ ## When to think about it
19
+ When CI pipeline speed or release safety becomes a recurring friction — e.g. after the changesets/release workflow is fully set up and seeing real traffic.
20
+
21
+ ## Priority
22
+ P2 — infrastructure quality-of-life. Not blocking current work, but a clear improvement once the release flow stabilises.
23
+
24
+ ## Notes
25
+ - Setup job with artifact infrastructure added (2026-08): install + build now happen once in a `setup` job, artifacts are shared with downstream jobs via upload/download-artifact. This is the first step toward the unified pipeline vision.
26
+ - Depends on the changesets release workflow being in place (P1 beads `agnt-ctx-d1ht`, `agnt-ctx-5bcu`).
27
+ - Should be straightforward once the pipeline shape is settled.
28
+ - **Release pipeline fires on every push to main** — this wastes CI minutes. It should only run when there are changesets to process (e.g. when the version packages PR is merged, or when new changesets land on main). Add a path filter or a pre-check that skips the job when no `.changeset/*.md` files changed.
@@ -29,6 +29,14 @@ Optional fields:
29
29
  - Add one rationale sentence for non-obvious rules so behavior generalizes in novel cases.
30
30
  - Avoid ALL-CAPS emphasis.
31
31
 
32
+ ## Tool references
33
+
34
+ Reference agent tools by `{{TOOL:name}}` token (canonical copilot name), never a
35
+ hardcoded harness-specific name such as `bash`, `task`, or `read_agent` — installers
36
+ substitute the per-harness name from `token-map.json` at install time. Add any new tool
37
+ to the map's `tools` section in the same change; unknown tokens are hard errors. See
38
+ `token-map.README.md` for the full authoring rules.
39
+
32
40
  ## Required sections
33
41
 
34
42
  Every skill must include:
@@ -81,6 +89,7 @@ Provide completion gates that are testable and stage-scoped.
81
89
 
82
90
  A skill passes anatomy review when:
83
91
  - [ ] Front matter uses required fields and valid description format.
92
+ - [ ] Tool references use `{{TOOL:name}}` tokens, not hardcoded tool names.
84
93
  - [ ] Required sections exist and follow canonical ordering.
85
94
  - [ ] Optional sections (if present) include what/when/example guidance.
86
95
  - [ ] Verification checklist contains concrete, machine-checkable items.
@@ -0,0 +1,54 @@
1
+ # Agent Modes Extension
2
+
3
+ Makes the agent-cortex agents (ralph, ralph-plan, plan, strategy) available as
4
+ switchable PI modes. Each mode injects the agent's full system prompt into the
5
+ running context, restricts tools to the agent's declared tool set, and shows a
6
+ status indicator with the active agent name.
7
+
8
+ ## Usage
9
+
10
+ | Command | Description |
11
+ | --- | --- |
12
+ | `/agent` | Show the agent mode selector |
13
+ | `/agent <name>` | Switch to an agent mode directly (e.g. `/agent ralph`) |
14
+ | `/agent default` | Return to default mode (full tool access) |
15
+ | `Ctrl+Shift+A` | Cycle through agent modes |
16
+ | `--agent <name>` | Start PI in a specific agent mode |
17
+
18
+ ## How agents are discovered
19
+
20
+ Agents are read from the **composable source format** in the package's
21
+ `agents/` directory:
22
+
23
+ ```
24
+ agents/<name>/
25
+ ├── agent.md # Shared body (harness-agnostic)
26
+ └── pi/
27
+ ├── frontmatter.json # PI metadata: name, description, tools
28
+ └── <section>.md # Optional sections referenced by {{SECTION:name}}
29
+ ```
30
+
31
+ The PI system prompt is composed from `agent.md` per the token-map.json
32
+ contract:
33
+
34
+ 1. `{{SECTION:name}}` tokens are replaced with the agent's `pi/<name>.md`.
35
+ 2. `{{TOOL:key}}` / `{{PATH:key}}` tokens are substituted against the pi column
36
+ of `token-map.json`, with the plugin root resolved from the actual extension
37
+ location. Tools without a PI equivalent (`ask_user`, `skill`) are omitted
38
+ from the tool set.
39
+
40
+ Flat `agents/*.agent.md` files are parsed as a fallback when an agent has no
41
+ composable directory — they are generated output (scripts/build-copilot-agents.mjs)
42
+ but may exist for non-migrated agents. A composable directory always wins over its
43
+ flat counterpart for the same agent id.
44
+
45
+ ## Development
46
+
47
+ Discovery and prompt composition live in `discover.ts` (unit-tested against
48
+ fixture directories in `discover.test.ts`); `index.ts` only wires discovery
49
+ into the extension API.
50
+
51
+ ```bash
52
+ pnpm test # run discover.test.ts
53
+ pnpm typecheck # tsc --noEmit
54
+ ```
@@ -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
+ }