@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,179 @@
1
+ // Integration tests for `agent-cortex install claude`: the install-time
2
+ // generator for the Claude Code plugin subtree. All tests install into
3
+ // mkdtemp dirs so the real repo claude/ and ~/.claude are never touched.
4
+
5
+ import { describe, it } from "node:test";
6
+ import assert from "node:assert/strict";
7
+ import { execFile } from "node:child_process";
8
+ import { mkdtempSync, readFileSync, readdirSync, existsSync, lstatSync, readlinkSync, realpathSync } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+ import { fileURLToPath } from "node:url";
11
+ import { dirname, join, relative } from "node:path";
12
+
13
+ const __dirname = dirname(fileURLToPath(import.meta.url));
14
+ const ROOT = join(__dirname, "..");
15
+ const CLI_PATH = join(ROOT, "bin", "agent-cortex.mjs");
16
+
17
+ function runCli(args) {
18
+ return new Promise((resolve) => {
19
+ execFile(process.execPath, [CLI_PATH, ...args], (error, stdout, stderr) => {
20
+ resolve({ exitCode: error ? error.code ?? 1 : 0, stdout, stderr });
21
+ });
22
+ });
23
+ }
24
+
25
+ function makeTmp() {
26
+ return mkdtempSync(join(tmpdir(), "agent-cortex-installer-"));
27
+ }
28
+
29
+ /** Deterministic snapshot of a generated tree: relative paths, kind, and for
30
+ * files/symlinks their byte content / link target. */
31
+ function snapshot(dir) {
32
+ const entries = [];
33
+ const walk = (p) => {
34
+ for (const e of readdirSync(p, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
35
+ const full = join(p, e.name);
36
+ const rel = relative(dir, full);
37
+ if (e.isSymbolicLink()) entries.push([rel, "link", readlinkSync(full)]);
38
+ else if (e.isDirectory()) {
39
+ entries.push([rel, "dir"]);
40
+ walk(full);
41
+ } else entries.push([rel, "file", readFileSync(full, "utf-8")]);
42
+ }
43
+ };
44
+ walk(dir);
45
+ return entries;
46
+ }
47
+
48
+ describe("install claude", () => {
49
+ it("composes agents from the composable sources (claude names, no tokens)", async () => {
50
+ const out = makeTmp();
51
+ const { exitCode } = await runCli(["install", "claude", "--output", out]);
52
+ assert.equal(exitCode, 0);
53
+
54
+ const plan = readFileSync(join(out, "agents", "plan.md"), "utf-8");
55
+ assert.match(plan, /^---\n# GENERATED from agents\/plan\//);
56
+ // provenance names the shared installer (no build-time script), so the
57
+ // committed subtree can only come from the install-time generator.
58
+ assert.match(plan, /bin\/installers\/claude\.mjs/);
59
+ assert.doesNotMatch(plan, /build-claude-agents/);
60
+ assert.match(plan, /\nname: plan\n/);
61
+ assert.doesNotMatch(plan, /\{\{(TOOL|PATH|SECTION):/);
62
+
63
+ const ralphPlan = readFileSync(join(out, "agents", "ralph-plan.md"), "utf-8");
64
+ assert.match(ralphPlan, /^---\n# GENERATED from agents\/ralph-plan\//);
65
+ assert.match(ralphPlan, /\nname: ralph-plan\n/);
66
+ });
67
+
68
+ it("copies native agents verbatim (ralph stays native, no composed counterpart)", async () => {
69
+ const out = makeTmp();
70
+ await runCli(["install", "claude", "--output", out]);
71
+ const native = readFileSync(join(ROOT, "agents-native", "ralph.md"), "utf-8");
72
+ assert.equal(readFileSync(join(out, "agents", "ralph.md"), "utf-8"), native);
73
+ });
74
+
75
+ it("symlinks flattened skills and excludes ralph-coupled skills", async () => {
76
+ const out = makeTmp();
77
+ await runCli(["install", "claude", "--output", out]);
78
+ const skills = join(out, "skills");
79
+ const names = readdirSync(skills);
80
+ assert.ok(names.includes("tdd"));
81
+ assert.ok(names.includes("plan"));
82
+ assert.ok(!names.includes("ralph"));
83
+ assert.ok(!names.includes("run-pipeline-stage"));
84
+ assert.ok(lstatSync(join(skills, "tdd")).isSymbolicLink());
85
+ assert.equal(realpathSync(join(skills, "tdd")), realpathSync(join(ROOT, "skills", "engineering", "tdd")));
86
+ });
87
+
88
+ it("generates plugin.json tracking the package version and referencing hooks.json", async () => {
89
+ const out = makeTmp();
90
+ await runCli(["install", "claude", "--output", out]);
91
+ const pkgVersion = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf-8")).version;
92
+ const plugin = JSON.parse(readFileSync(join(out, ".claude-plugin", "plugin.json"), "utf-8"));
93
+ assert.equal(plugin.name, "agent-cortex");
94
+ assert.equal(plugin.version, pkgVersion);
95
+ assert.equal(plugin.hooks, "./hooks.json");
96
+ });
97
+
98
+ it("generates hooks.json from the canonical hooks/claude/ source", async () => {
99
+ const out = makeTmp();
100
+ await runCli(["install", "claude", "--output", out]);
101
+ const source = JSON.parse(readFileSync(join(ROOT, "hooks", "claude", "hooks.json"), "utf-8"));
102
+ const installed = JSON.parse(readFileSync(join(out, "hooks.json"), "utf-8"));
103
+ assert.deepStrictEqual(installed, source);
104
+ assert.ok(source.hooks.SessionStart);
105
+ });
106
+
107
+ it("matches the Notification hook only on documented notification types", async () => {
108
+ // The Notification matcher is an exact-string list over `notification_type` —
109
+ // there are no success/error types, so that matcher would never fire. Guard
110
+ // against it recurring (see docs/claude-hooks.md for the documented type list).
111
+ const out = makeTmp();
112
+ await runCli(["install", "claude", "--output", out]);
113
+ const installed = JSON.parse(readFileSync(join(out, "hooks.json"), "utf-8"));
114
+ const matcher = installed.hooks.Notification.flatMap((g) => g.hooks).length
115
+ ? installed.hooks.Notification[0].matcher
116
+ : "";
117
+ const documented = new Set([
118
+ "permission_prompt",
119
+ "idle_prompt",
120
+ "auth_success",
121
+ "elicitation_dialog",
122
+ "elicitation_url_dialog",
123
+ "elicitation_complete",
124
+ "elicitation_response",
125
+ "agent_needs_input",
126
+ "agent_completed",
127
+ "quota_auto_resume_fired",
128
+ "quota_auto_resume_stale",
129
+ "quota_auto_resume_disabled",
130
+ ]);
131
+ const types = matcher ? matcher.split("|") : [];
132
+ assert.ok(types.length > 0, "Notification hook should list at least one matcher type");
133
+ for (const t of types) {
134
+ assert.ok(documented.has(t), `Notification matcher references unknown type "${t}"`);
135
+ }
136
+ });
137
+
138
+ it("bundles hook support scripts into claude/hooks/ and references them via the plugin root", async () => {
139
+ const out = makeTmp();
140
+ await runCli(["install", "claude", "--output", out]);
141
+
142
+ const canonical = readFileSync(join(ROOT, "hooks", "claude", "scripts", "notify.mjs"), "utf-8");
143
+ const bundled = readFileSync(join(out, "hooks", "scripts", "notify.mjs"), "utf-8");
144
+ assert.equal(bundled, canonical);
145
+
146
+ const installed = JSON.parse(readFileSync(join(out, "hooks.json"), "utf-8"));
147
+ const notify = installed.hooks.Notification.flatMap((g) => g.hooks)
148
+ .filter((h) => h.command?.includes("notify.mjs"));
149
+ assert.ok(notify.length === 1, "hooks.json should reference the notification script");
150
+ assert.match(notify[0].command, /^node "\$\{CLAUDE_PLUGIN_ROOT\}\/hooks\/scripts\/notify\.mjs"$/);
151
+ });
152
+
153
+ it("is deterministic across repeated installs into the same output", async () => {
154
+ const out = makeTmp();
155
+ await runCli(["install", "claude", "--output", out]);
156
+ const first = snapshot(out);
157
+ await runCli(["install", "claude", "--output", out]);
158
+ assert.deepStrictEqual(snapshot(out), first);
159
+ });
160
+
161
+ it("--dry-run reports the plan without writing anything", async () => {
162
+ const out = makeTmp();
163
+ const { exitCode, stdout } = await runCli(["install", "claude", "--output", out, "--dry-run"]);
164
+ assert.equal(exitCode, 0);
165
+ assert.ok(/would/i.test(stdout));
166
+ assert.equal(existsSync(join(out, "agents")), false);
167
+ assert.equal(existsSync(join(out, "skills")), false);
168
+ assert.equal(existsSync(join(out, ".claude-plugin")), false);
169
+ });
170
+
171
+ it("leaves the committed claude/ subtree untouched when --output is given", async () => {
172
+ const out = makeTmp();
173
+ const beforeSkills = readdirSync(join(ROOT, "claude", "skills")).sort();
174
+ const beforePlugin = readFileSync(join(ROOT, "claude", ".claude-plugin", "plugin.json"), "utf-8");
175
+ await runCli(["install", "claude", "--output", out]);
176
+ assert.deepStrictEqual(readdirSync(join(ROOT, "claude", "skills")).sort(), beforeSkills);
177
+ assert.equal(readFileSync(join(ROOT, "claude", ".claude-plugin", "plugin.json"), "utf-8"), beforePlugin);
178
+ });
179
+ });
@@ -0,0 +1,63 @@
1
+ // Tests for the Claude Code Notification hook script (hooks/claude/scripts/notify.mjs).
2
+ // The script reads the Notification hook JSON payload from stdin and prints a
3
+ // `{ "terminalSequence": "..." }` JSON response that Claude Code emits for us
4
+ // (hooks run without a controlling terminal, so OSC sequences must go through
5
+ // terminalSequence — see the Claude Code hooks reference).
6
+
7
+ import { describe, it } from "node:test";
8
+ import assert from "node:assert/strict";
9
+ import { execFile } from "node:child_process";
10
+ import { fileURLToPath } from "node:url";
11
+ import { dirname, join } from "node:path";
12
+
13
+ const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
14
+ const SCRIPT = join(ROOT, "hooks", "claude", "scripts", "notify.mjs");
15
+
16
+ /** Run the hook script with the given payload on stdin and a controlled env. */
17
+ function runNotify(payload, env = {}) {
18
+ return new Promise((resolve) => {
19
+ const child = execFile(
20
+ process.execPath,
21
+ [SCRIPT],
22
+ { env, timeout: 10_000 },
23
+ (error, stdout, stderr) => {
24
+ resolve({ exitCode: error ? error.code ?? 1 : 0, stdout, stderr });
25
+ },
26
+ );
27
+ child.stdin.end(JSON.stringify(payload));
28
+ });
29
+ }
30
+
31
+ describe("notify.mjs (Claude Notification hook)", () => {
32
+ it("emits an OSC 777 desktop notification on a bare-terminal agent_completed payload", async () => {
33
+ const { exitCode, stdout } = await runNotify(
34
+ { notification_type: "agent_completed", title: "task done", message: "all tests pass" },
35
+ { PATH: process.env.PATH },
36
+ );
37
+ assert.equal(exitCode, 0);
38
+ const out = JSON.parse(stdout);
39
+ assert.ok(out.terminalSequence.startsWith("\u001b]777;notify;"));
40
+ assert.match(out.terminalSequence, /task done/);
41
+ assert.match(out.terminalSequence, /all tests pass/);
42
+ });
43
+
44
+ it("switches to OSC 99 when running inside Kitty", async () => {
45
+ const { exitCode, stdout } = await runNotify(
46
+ { notification_type: "agent_needs_input", title: "input needed", message: "build broke" },
47
+ { PATH: process.env.PATH, KITTY_WINDOW_ID: "0" },
48
+ );
49
+ assert.equal(exitCode, 0);
50
+ const out = JSON.parse(stdout);
51
+ assert.ok(out.terminalSequence.startsWith("\u001b]99;"));
52
+ assert.match(out.terminalSequence, /build broke/);
53
+ });
54
+
55
+ it("stays silent (no output, exit 0) when the payload has no message", async () => {
56
+ const { exitCode, stdout } = await runNotify(
57
+ { notification_type: "permission_prompt" },
58
+ { PATH: process.env.PATH },
59
+ );
60
+ assert.equal(exitCode, 0);
61
+ assert.equal(stdout, "");
62
+ });
63
+ });
@@ -0,0 +1,371 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync, existsSync, readdirSync } from "node:fs";
4
+ import { join, dirname } from "node:path";
5
+ import { tmpdir } from "node:os";
6
+ import { fileURLToPath } from "node:url";
7
+ import { installPi } from "../bin/installers/pi.mjs";
8
+
9
+ // Real repo root — used by the integration smoke test to prove the installer
10
+ // works against the canonical sources (agents/, skills/, token-map.json).
11
+ const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
12
+
13
+ // ─── Fixtures ────────────────────────────────────────────────────────────────
14
+
15
+ const TOOL_MAP = {
16
+ bash: { pi: "bash" },
17
+ view: { pi: "read" },
18
+ create: { pi: "write" },
19
+ glob: { pi: "find" },
20
+ ask_user: { pi: null },
21
+ skill: { pi: null },
22
+ task: { pi: "task" },
23
+ };
24
+
25
+ function tokenMap(extra = {}) {
26
+ return {
27
+ name: "fixture token map",
28
+ version: 1,
29
+ harnesses: ["pi"],
30
+ tools: { ...TOOL_MAP },
31
+ paths: {
32
+ plugin_root: { pi: "~/.pi/agent/npm/node_modules/@jaybeeuu/agent-cortex" },
33
+ skills_dir: { pi: { base: "plugin_root", relative: "skills" } },
34
+ agents_dir: { pi: { base: "plugin_root", relative: "agents" } },
35
+ },
36
+ ...extra,
37
+ };
38
+ }
39
+
40
+ function makeFixture() {
41
+ const root = mkdtempSync(join(tmpdir(), "pi-installer-"));
42
+ const output = join(root, "out");
43
+ return {
44
+ root,
45
+ output,
46
+ cleanup: () => rmSync(root, { recursive: true, force: true }),
47
+ };
48
+ }
49
+
50
+ function writeFixture(fx, relPath, content) {
51
+ const p = join(fx.root, relPath);
52
+ mkdirSync(dirname(p), { recursive: true });
53
+ writeFileSync(p, content);
54
+ }
55
+
56
+ /** Seed a minimal but realistic package tree (agents + token-map + skills). */
57
+ function seedPackage(fx, opts = {}) {
58
+ const { agents = {}, skills = {}, tokenMap: map, version } = opts;
59
+ writeFixture(fx, "token-map.json", JSON.stringify(map ?? tokenMap(version ? { version } : {})));
60
+ for (const [name, def] of Object.entries(agents)) {
61
+ writeFixture(fx, `agents/${name}/agent.md`, def.body ?? `# ${name}\nBody of ${name}.`);
62
+ writeFixture(
63
+ fx,
64
+ `agents/${name}/pi/frontmatter.json`,
65
+ JSON.stringify(
66
+ def.frontmatter ?? {
67
+ name: `agent-cortex:${name}`,
68
+ description: `Description of ${name}`,
69
+ tools: ["bash", "view"],
70
+ argumentHint: `Hint for ${name}`,
71
+ },
72
+ ),
73
+ );
74
+ for (const [section, content] of Object.entries(def.sections ?? {})) {
75
+ writeFixture(fx, `agents/${name}/pi/${section}.md`, content);
76
+ }
77
+ }
78
+ for (const [path, content] of Object.entries(skills)) {
79
+ writeFixture(fx, `skills/${path}`, content);
80
+ }
81
+ }
82
+
83
+ function listFiles(dir) {
84
+ if (!existsSync(dir)) return [];
85
+ const out = [];
86
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
87
+ const p = join(dir, entry.name);
88
+ if (entry.isDirectory()) out.push(...listFiles(p).map((f) => join(entry.name, f)));
89
+ else out.push(entry.name);
90
+ }
91
+ return out;
92
+ }
93
+
94
+ // ─── Agent composition ───────────────────────────────────────────────────────
95
+
96
+ describe("installPi — agent composition", () => {
97
+ it("writes one .agent.md per composable agent dir with pi frontmatter and no leftover tokens", () => {
98
+ const fx = makeFixture();
99
+ try {
100
+ seedPackage(fx, {
101
+ agents: {
102
+ alpha: {
103
+ body: "# alpha\nUse {{TOOL:view}} to read.\n{{SECTION:polling}}\nRead {{PATH:skills_dir}}.",
104
+ sections: { polling: "Spawn with {{TOOL:task}}." },
105
+ frontmatter: {
106
+ name: "agent-cortex:alpha",
107
+ description: "Reads things.",
108
+ tools: ["bash", "view", "glob", "ask_user"],
109
+ argumentHint: "Read something",
110
+ },
111
+ },
112
+ },
113
+ });
114
+
115
+ const result = installPi({ root: fx.root, output: fx.output });
116
+
117
+ const file = join(fx.output, "agents", "alpha.agent.md");
118
+ assert.ok(existsSync(file), "agent file written");
119
+ const content = readFileSync(file, "utf-8");
120
+ assert.ok(content.startsWith("---\n"), "yaml frontmatter");
121
+ assert.match(content, /name: "agent-cortex:alpha"/);
122
+ assert.match(content, /description: "Reads things\."/);
123
+ assert.match(content, /tools: "bash read find"/, "tools translated to pi names, ask_user omitted");
124
+ assert.match(content, /argument-hint: "Read something"/);
125
+ assert.ok(content.includes("Use read to read."), "{{TOOL:view}} substituted");
126
+ assert.ok(content.includes("Spawn with task."), "{{SECTION:polling}} composed from pi/polling.md");
127
+ assert.ok(!/{{/.test(content), "no literal tokens remain");
128
+ assert.equal(result.agents.length, 1);
129
+ assert.deepEqual(result.agents[0], { name: "alpha", filePath: file });
130
+ } finally {
131
+ fx.cleanup();
132
+ }
133
+ });
134
+
135
+ it("drops null-mapped tool tokens in prose and reports a warning", () => {
136
+ const fx = makeFixture();
137
+ try {
138
+ seedPackage(fx, {
139
+ agents: {
140
+ alpha: { body: "# alpha\nAsk the user with {{TOOL:ask_user}}, then use {{TOOL:bash}}." },
141
+ },
142
+ });
143
+
144
+ const result = installPi({ root: fx.root, output: fx.output });
145
+ const content = readFileSync(join(fx.output, "agents", "alpha.agent.md"), "utf-8");
146
+
147
+ assert.ok(!content.includes("ask_user"), "null-mapped token dropped from prose");
148
+ assert.ok(content.includes("use bash"), "mapped token substituted");
149
+ assert.ok(!/{{/.test(content));
150
+ assert.ok(result.warnings.some((w) => w.includes("{{TOOL:ask_user}}")), "drop reported as warning");
151
+ } finally {
152
+ fx.cleanup();
153
+ }
154
+ });
155
+
156
+ it("throws on an unknown tool token", () => {
157
+ const fx = makeFixture();
158
+ try {
159
+ seedPackage(fx, { agents: { alpha: { body: "# alpha\nUse {{TOOL:nope}} now." } } });
160
+ assert.throws(() => installPi({ root: fx.root, output: fx.output }), /unknown tool token {{TOOL:nope}}/);
161
+ } finally {
162
+ fx.cleanup();
163
+ }
164
+ });
165
+
166
+ it("throws when a section file referenced by {{SECTION:...}} is missing", () => {
167
+ const fx = makeFixture();
168
+ try {
169
+ seedPackage(fx, { agents: { alpha: { body: "# alpha\n{{SECTION:missing}}" } } });
170
+ assert.throws(() => installPi({ root: fx.root, output: fx.output }), /missing section "missing"/);
171
+ } finally {
172
+ fx.cleanup();
173
+ }
174
+ });
175
+
176
+ it("resolves named and relative {{PATH:...}} tokens against the plugin root", () => {
177
+ const fx = makeFixture();
178
+ try {
179
+ seedPackage(fx, {
180
+ agents: {
181
+ alpha: {
182
+ body: "# alpha\nNamed: {{PATH:skills_dir}}\nRelative: {{PATH:skills/x/SKILL.md}}",
183
+ },
184
+ },
185
+ });
186
+
187
+ const result = installPi({ root: fx.root, output: fx.output });
188
+ const content = readFileSync(join(fx.output, "agents", "alpha.agent.md"), "utf-8");
189
+
190
+ assert.ok(
191
+ content.includes("Named: ~/.pi/agent/npm/node_modules/@jaybeeuu/agent-cortex/skills"),
192
+ "named path resolves through token-map paths table",
193
+ );
194
+ assert.ok(
195
+ content.includes("Relative: ~/.pi/agent/npm/node_modules/@jaybeeuu/agent-cortex/skills/x/SKILL.md"),
196
+ "relative path resolves against plugin root",
197
+ );
198
+ assert.equal(result.warnings.length, 0);
199
+ } finally {
200
+ fx.cleanup();
201
+ }
202
+ });
203
+
204
+ it("honours the --plugin-root override for path resolution", () => {
205
+ const fx = makeFixture();
206
+ try {
207
+ seedPackage(fx, {
208
+ agents: {
209
+ alpha: { body: "# alpha\n{{PATH:skills/x/SKILL.md}}" },
210
+ },
211
+ });
212
+
213
+ const pluginRoot = join(fx.root, "checkout");
214
+ installPi({ root: fx.root, output: fx.output, pluginRoot });
215
+ const content = readFileSync(join(fx.output, "agents", "alpha.agent.md"), "utf-8");
216
+ assert.ok(content.includes(join(pluginRoot, "skills", "x", "SKILL.md")), "plugin root override used");
217
+ } finally {
218
+ fx.cleanup();
219
+ }
220
+ });
221
+
222
+ it("rejects token-map.json versions newer than the implemented contract", () => {
223
+ const fx = makeFixture();
224
+ try {
225
+ seedPackage(fx, {
226
+ version: 99,
227
+ agents: { alpha: { body: "# alpha" } },
228
+ });
229
+ assert.throws(() => installPi({ root: fx.root, output: fx.output }), /version 99/);
230
+ } finally {
231
+ fx.cleanup();
232
+ }
233
+ });
234
+ });
235
+
236
+ // ─── Skills ──────────────────────────────────────────────────────────────────
237
+
238
+ describe("installPi — skills", () => {
239
+ it("copies skills into <output>/skills preserving the grouped layout with substituted tokens", () => {
240
+ const fx = makeFixture();
241
+ try {
242
+ seedPackage(fx, {
243
+ agents: { alpha: { body: "# alpha" } },
244
+ skills: {
245
+ "engineering/echo/SKILL.md":
246
+ "---\nname: echo\ndescription: Echoes {{TOOL:view}}.\n---\n\nUse {{TOOL:view}} to read and {{TOOL:skill}} for help.\nOpen {{PATH:skills/echo/SKILL.md}}.",
247
+ "engineering/echo/scripts/run.sh": "#!/bin/sh\necho hi\n",
248
+ },
249
+ });
250
+
251
+ installPi({ root: fx.root, output: fx.output });
252
+
253
+ const skillFile = join(fx.output, "skills", "engineering", "echo", "SKILL.md");
254
+ assert.ok(existsSync(skillFile), "skill copied");
255
+ const content = readFileSync(skillFile, "utf-8");
256
+ assert.ok(content.includes("description: Echoes read."), "token substituted in skill frontmatter");
257
+ assert.ok(content.includes("Use read to read and for help."), "null-mapped {{TOOL:skill}} dropped");
258
+ assert.ok(
259
+ content.includes(
260
+ "Open ~/.pi/agent/npm/node_modules/@jaybeeuu/agent-cortex/skills/echo/SKILL.md.",
261
+ ),
262
+ "relative path resolved against plugin root",
263
+ );
264
+ assert.ok(!/{{/.test(content), "no literal tokens remain in skill");
265
+
266
+ const script = readFileSync(join(fx.output, "skills", "engineering", "echo", "scripts", "run.sh"), "utf-8");
267
+ assert.equal(script, "#!/bin/sh\necho hi\n", "non-md files copied verbatim");
268
+ } finally {
269
+ fx.cleanup();
270
+ }
271
+ });
272
+ });
273
+
274
+ // ─── Output / dry-run ────────────────────────────────────────────────────────
275
+
276
+ describe("installPi — output & dry-run", () => {
277
+ it("dry-run writes nothing but reports the planned output", () => {
278
+ const fx = makeFixture();
279
+ try {
280
+ seedPackage(fx, {
281
+ agents: { alpha: { body: "# alpha" } },
282
+ skills: { "engineering/echo/SKILL.md": "---\nname: echo\ndescription: d\n---\n\nBody." },
283
+ });
284
+
285
+ const result = installPi({ root: fx.root, output: fx.output, dryRun: true });
286
+
287
+ assert.equal(result.dryRun, true);
288
+ assert.equal(listFiles(fx.output).length, 0, "no files written in dry-run");
289
+ assert.equal(result.agents.length, 1);
290
+ assert.equal(result.agents[0].name, "alpha");
291
+ assert.equal(result.skills.skills, 1);
292
+ // Planned paths are still reported so the user can see what would happen.
293
+ assert.ok(result.agents[0].filePath.startsWith(fx.output));
294
+ } finally {
295
+ fx.cleanup();
296
+ }
297
+ });
298
+
299
+ it("creates the output directory hierarchy when needed", () => {
300
+ const fx = makeFixture();
301
+ try {
302
+ seedPackage(fx, { agents: { alpha: { body: "# alpha" } } });
303
+ const deep = join(fx.output, "nested", "deep");
304
+ installPi({ root: fx.root, output: deep });
305
+ assert.ok(existsSync(join(deep, "agents", "alpha.agent.md")));
306
+ } finally {
307
+ fx.cleanup();
308
+ }
309
+ });
310
+ });
311
+
312
+ // ─── Integration against the real repo ───────────────────────────────────────
313
+
314
+ describe("installPi — real repo integration", () => {
315
+ it("installs every canonical agent with pi tool names and no leftover tokens", () => {
316
+ const fx = makeFixture();
317
+ try {
318
+ const result = installPi({ root: REPO_ROOT, output: fx.output });
319
+
320
+ const names = result.agents.map((a) => a.name).sort();
321
+ assert.deepEqual(names, ["plan", "ralph", "ralph-plan", "strategy"]);
322
+
323
+ // ralph pi frontmatter: ["bash", "view", "rg", "glob", "task", "read_agent", "wait_for_agents"]
324
+ const ralph = readFileSync(join(fx.output, "agents", "ralph.agent.md"), "utf-8");
325
+ assert.match(ralph, /name: "agent-cortex:ralph"/);
326
+ assert.match(ralph, /tools: "bash read grep find task read_agent wait_for_agents"/);
327
+ assert.ok(ralph.includes("## Spawning subagents (PI)"), "{{SECTION:polling}} composed from pi/polling.md");
328
+ const bodyTokens = ralph.split("---\n").slice(2).join("---\n");
329
+ assert.ok(!/{{/.test(bodyTokens), "no literal tokens in ralph body");
330
+
331
+ // plan body carries the relative {{PATH:...}} resolved against the plugin root
332
+ const plan = readFileSync(join(fx.output, "agents", "plan.agent.md"), "utf-8");
333
+ assert.match(plan, /tools: "bash read edit write grep find task read_agent"/);
334
+ assert.ok(
335
+ plan.includes("~/.pi/agent/npm/node_modules/@jaybeeuu/agent-cortex/skills/workflow/plan/SKILL.md"),
336
+ "plan PATH token resolved against token-map plugin root",
337
+ );
338
+
339
+ // ralph-plan prose {{TOOL:ask_user}} is dropped (null for pi)
340
+ const ralphPlan = readFileSync(join(fx.output, "agents", "ralph-plan.agent.md"), "utf-8");
341
+ assert.ok(!ralphPlan.includes("ask_user"), "null-mapped tool omitted from ralph-plan body");
342
+ assert.ok(
343
+ result.warnings.some((w) => w.includes("ask_user")),
344
+ "ralph-plan null tool dropped with a warning",
345
+ );
346
+
347
+ // strategy: web_fetch → fetch_content; ask_user/skill omitted
348
+ const strategy = readFileSync(join(fx.output, "agents", "strategy.agent.md"), "utf-8");
349
+ assert.match(strategy, /tools: "bash read grep find fetch_content edit write"/);
350
+ } finally {
351
+ fx.cleanup();
352
+ }
353
+ });
354
+
355
+ it("substitutes tokens in every installed skill markdown", () => {
356
+ const fx = makeFixture();
357
+ try {
358
+ const result = installPi({ root: REPO_ROOT, output: fx.output });
359
+ assert.ok(result.skills.skills > 20, `expected the full skill tree, got ${result.skills.skills}`);
360
+
361
+ const files = listFiles(join(fx.output, "skills")).filter((f) => f.endsWith(".md"));
362
+ assert.ok(files.length > 0);
363
+ for (const f of files) {
364
+ const content = readFileSync(join(fx.output, "skills", f), "utf-8");
365
+ assert.ok(!/{{/.test(content), `no literal tokens in skill ${f}`);
366
+ }
367
+ } finally {
368
+ fx.cleanup();
369
+ }
370
+ });
371
+ });