@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.
- package/.agents/skills/style-versioning/SKILL.md +9 -8
- package/.github/workflows/ci.yml +190 -8
- package/AGENTS.md +25 -6
- package/CHANGELOG.md +42 -0
- package/README.md +132 -33
- package/agents/README.md +47 -13
- package/agents/plan/agent.md +51 -0
- package/agents/plan/claude/frontmatter.json +6 -0
- package/agents/plan/copilot/frontmatter.json +6 -0
- package/agents/plan/pi/frontmatter.json +6 -0
- package/agents/plan.agent.md +3 -0
- package/agents/ralph/agent.md +262 -0
- package/agents/ralph/claude/frontmatter.json +6 -0
- package/agents/ralph/claude/polling.md +31 -0
- package/agents/ralph/copilot/frontmatter.json +6 -0
- package/agents/ralph/copilot/polling.md +62 -0
- package/agents/ralph/pi/frontmatter.json +6 -0
- package/agents/ralph/pi/polling.md +74 -0
- package/agents/ralph-plan/agent.md +114 -0
- package/agents/ralph-plan/claude/frontmatter.json +6 -0
- package/agents/ralph-plan/copilot/frontmatter.json +6 -0
- package/agents/ralph-plan/pi/frontmatter.json +6 -0
- package/agents/ralph-plan.agent.md +5 -2
- package/agents/ralph.agent.md +83 -57
- package/agents/strategy/agent.md +104 -0
- package/agents/strategy/claude/frontmatter.json +6 -0
- package/agents/strategy/copilot/frontmatter.json +6 -0
- package/agents/strategy/pi/frontmatter.json +6 -0
- package/agents/strategy.agent.md +3 -0
- package/bin/agent-cortex.mjs +68 -1
- package/bin/installers/claude.mjs +262 -0
- package/bin/installers/copilot.mjs +129 -0
- package/bin/installers/pi.mjs +197 -0
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/agents/plan.md +3 -1
- package/claude/agents/ralph-plan.md +5 -3
- package/claude/agents/strategy.md +3 -1
- package/claude/hooks/scripts/notify.mjs +54 -0
- package/claude/hooks.json +15 -0
- package/docs/claude-hooks.md +115 -0
- package/docs/ideas/auto-model-selection.md +75 -0
- package/docs/ideas/improve-pi-system-prompt.md +34 -0
- package/docs/ideas/model-selector.md +40 -0
- package/docs/ideas/pi-task-memory.md +34 -0
- package/docs/ideas/qa-extension.md +43 -0
- package/docs/ideas/unified-ci-pipeline.md +28 -0
- package/docs/skills/skill-anatomy.md +9 -0
- package/extensions/agent-modes/README.md +54 -0
- package/extensions/agent-modes/discover.test.ts +241 -0
- package/extensions/agent-modes/discover.ts +458 -0
- package/extensions/agent-modes/index.ts +13 -224
- package/extensions/agent-modes/node_modules/.bin/pi +21 -0
- package/extensions/agent-modes/node_modules/.bin/tsc +21 -0
- package/extensions/agent-modes/node_modules/.bin/tsserver +21 -0
- package/extensions/agent-modes/node_modules/.bin/tsx +21 -0
- package/extensions/agent-modes/package.json +20 -0
- package/extensions/agent-modes/tsconfig.json +12 -0
- package/extensions/subagent/README.md +19 -0
- package/extensions/subagent/index.ts +131 -45
- package/extensions/subagent/node_modules/.bin/pi +21 -0
- package/extensions/subagent/node_modules/.bin/pi-ai +21 -0
- package/extensions/subagent/node_modules/.bin/tsc +21 -0
- package/extensions/subagent/node_modules/.bin/tsserver +21 -0
- package/extensions/subagent/node_modules/.bin/tsx +21 -0
- package/extensions/subagent/package.json +23 -0
- package/extensions/subagent/tsconfig.json +12 -0
- package/extensions/subagent/wait.test.ts +202 -0
- package/extensions/subagent/wait.ts +118 -0
- package/hooks/claude/hooks.json +38 -0
- package/hooks/claude/scripts/notify.mjs +54 -0
- package/lib/cli.mjs +62 -3
- package/package.json +6 -4
- package/plugin.json +1 -1
- package/scripts/build-copilot-agents.mjs +30 -0
- package/scripts/lib/compose-agent.mjs +241 -0
- package/skills/engineering/design-an-interface/SKILL.md +1 -1
- package/skills/engineering/improve-codebase-architecture/SKILL.md +1 -1
- package/skills/workflow/ralph/REFERENCE.md +2 -2
- package/skills/workflow/ralph/SKILL.md +6 -6
- package/skills/workflow/run-pipeline-stage/scripts/generate-progress.ts +4 -2
- package/skills/workflow/using-agent-skills/SKILL.md +122 -0
- package/test/cli.test.mjs +115 -2
- package/test/copilot-installer.test.mjs +298 -0
- package/test/installer.test.mjs +179 -0
- package/test/notify-hook.test.mjs +63 -0
- package/test/pi-installer.test.mjs +371 -0
- package/token-map.README.md +128 -0
- package/token-map.json +106 -0
- package/.github/workflows/ci.test.ts +0 -217
- package/.github/workflows/release.yml +0 -58
- package/scripts/build-claude-agents.mjs +0 -179
- package/scripts/publish-release.sh +0 -32
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# token-map.json — multi-harness token substitution
|
|
2
|
+
|
|
3
|
+
`token-map.json` is the central, single-source mapping of **tool names**, **paths**, and
|
|
4
|
+
**agent naming conventions** across the three harnesses this repo ships to. It is the
|
|
5
|
+
foundation for the install-time generation system: each harness installer (`agent-cortex
|
|
6
|
+
install <harness>`) reads the map and substitutes `{{TOOL:...}}` / `{{PATH:...}}` tokens in
|
|
7
|
+
the files it generates.
|
|
8
|
+
|
|
9
|
+
**The substitution contract lives inside `token-map.json`** (the `contract` section) — that
|
|
10
|
+
file is the single reference installers must implement. This README explains the design
|
|
11
|
+
decisions behind the map.
|
|
12
|
+
|
|
13
|
+
## Harnesses
|
|
14
|
+
|
|
15
|
+
| id | Platform | Agent format | Plugin root |
|
|
16
|
+
|---|---|---|---|
|
|
17
|
+
| `copilot` | GitHub Copilot CLI | `agents/<name>/` composable dirs → generated `agents/*.agent.md` | `~/.copilot/installed-plugins/_direct/agent-cortex` |
|
|
18
|
+
| `pi` | pi coding agent | `agents/<name>/` composable dirs (composed at runtime by agent-modes, or installed install-time by `agent-cortex install pi`) | `~/.pi/agent/npm/node_modules/@jaybeeuu/agent-cortex` |
|
|
19
|
+
| `claude` | Claude Code | `claude/agents/*.md` (generated) | `${CLAUDE_PLUGIN_ROOT}` (env var) |
|
|
20
|
+
|
|
21
|
+
The harness ids match the values accepted by `agent-cortex install <harness>` in
|
|
22
|
+
`lib/cli.mjs` (`SUPPORTED_HARNESSES`). The pi `plugin_root` row is the npm-layout
|
|
23
|
+
literal that `agent-cortex install pi` uses by default; local checkout and symlinked
|
|
24
|
+
installs pass `--plugin-root <dir>` to override it (see README "Pi harness agents &
|
|
25
|
+
skills").
|
|
26
|
+
|
|
27
|
+
## Design decisions
|
|
28
|
+
|
|
29
|
+
### 1. Token naming convention: `{{TOOL:name}}`, not `{{tool.name}}`
|
|
30
|
+
|
|
31
|
+
`agents/README.md` (ADR #63) already standardised `{{TOOL:name}}`, `{{PATH:name}}`, and
|
|
32
|
+
`{{SECTION:name}}` for the composable agent directory format. The map adopts that grammar
|
|
33
|
+
rather than inventing a dotted form:
|
|
34
|
+
|
|
35
|
+
- **The convention is established** — skill authors and the ADR already write tokens this
|
|
36
|
+
way; the map should not fork the language.
|
|
37
|
+
- **Upper-case type prefix disambiguates** — `{{TOOL:read}}` (a tool) can never collide with
|
|
38
|
+
`{{PATH:read}}` (a path) in the same document.
|
|
39
|
+
- **One namespace per type** — tool keys, path keys, and section names never share a lookup
|
|
40
|
+
table, so a tool called `skills_dir` would not shadow the path key.
|
|
41
|
+
- **Greppable and schema-friendly** — simple `{{TYPE:NAME}}` is trivially matched by a
|
|
42
|
+
regex; delimiters make unbalanced/typo'd tokens visible.
|
|
43
|
+
|
|
44
|
+
### 2. What is mapped
|
|
45
|
+
|
|
46
|
+
- **`tools`** — the twelve canonical tool names as authored in `agents/<name>/agent.md`
|
|
47
|
+
(copilot names are the authoring names). Each row gives the equivalent tool name per
|
|
48
|
+
harness. Source of truth for the copilot → claude half is `token-map.json`'s own claude
|
|
49
|
+
column, consumed by `bin/installers/claude.mjs` (the install-time generator — PR #91, #92); the pi column is grounded
|
|
50
|
+
in pi's documented built-in tool surface (`read`, `bash`, `edit`, `write`, `grep`, `find`,
|
|
51
|
+
`ls`) plus `task` / `read_agent` / `wait_for_agents` subagent tools.
|
|
52
|
+
- **`paths`** — `plugin_root`, `agents_dir`, and `skills_dir`. These are the install-time
|
|
53
|
+
variables that differ per harness. `plugin_root` is the anchor; the other two resolve
|
|
54
|
+
relative to it (`{base, relative}` specs) so a harness only overrides the single root it
|
|
55
|
+
actually installs into.
|
|
56
|
+
- **`agents`** — the `agent-cortex:` name-prefix convention per harness (claude strips the
|
|
57
|
+
prefix during conversion) and the deferred-agent exception (claude's `ralph` is authored
|
|
58
|
+
natively, never generated). Agent names themselves do not get per-row mappings because
|
|
59
|
+
the prefix rule covers all of them; the deferred list captures the only structural
|
|
60
|
+
difference.
|
|
61
|
+
- **Deliberately not mapped:** hook patterns, MCP servers, and tool descriptions. Those
|
|
62
|
+
differ *structurally* per harness (different hook event names, different config files),
|
|
63
|
+
not by a single substitutable token; each installer already hardcodes its own shape. The
|
|
64
|
+
map records them in `notes` where a tool differs in behaviour (`read_agent`, `skill`,
|
|
65
|
+
`ask_user`).
|
|
66
|
+
|
|
67
|
+
### 3. Null mappings
|
|
68
|
+
|
|
69
|
+
A null value means **"this harness has no equivalent"** — not "empty string" and not "leave
|
|
70
|
+
the token as-is". The contract defines the two contexts:
|
|
71
|
+
|
|
72
|
+
- **Prose** (e.g. `Use {{TOOL:read_agent}} to poll`): the token is dropped and the
|
|
73
|
+
installer warns. Dangling sentences after a drop are the author's responsibility — the
|
|
74
|
+
warning exists so the installer surfaces them.
|
|
75
|
+
- **Tool-list frontmatter** (e.g. `tools: [..., "read_agent"]`): the entry is omitted from
|
|
76
|
+
the array.
|
|
77
|
+
|
|
78
|
+
Unknown (unmapped) tokens are a **hard error**, not a null — a silently-passed-through
|
|
79
|
+
token is exactly the bug the map exists to prevent, and mirrors the build script's
|
|
80
|
+
`unknown tool "x"` throw.
|
|
81
|
+
|
|
82
|
+
The pi install-time installer (`bin/installers/pi.mjs`) follows the strict contract above.
|
|
83
|
+
One consumer deviates deliberately: the pi `agent-modes` extension reads the map at
|
|
84
|
+
**runtime** (not install time) to compose agent prompts from the composable agents
|
|
85
|
+
format. For it, an unmapped tool name passes through unchanged — it may be a
|
|
86
|
+
native PI tool — while a mapped-null tool (`ask_user`, `skill`) is omitted with a
|
|
87
|
+
warning. See `extensions/agent-modes/README.md`.
|
|
88
|
+
|
|
89
|
+
### 4. Token substitution contract
|
|
90
|
+
|
|
91
|
+
The full contract is embedded in `token-map.json` under `contract`. In summary, installers:
|
|
92
|
+
|
|
93
|
+
1. Bind a harness id (`copilot` | `claude` | `pi`).
|
|
94
|
+
2. Substitute in order: `SECTION` (left for the composer), then `TOOL`, then `PATH`.
|
|
95
|
+
3. For `TOOL`: look up the canonical key, replace with the harness name; null → drop/omit;
|
|
96
|
+
unknown → throw.
|
|
97
|
+
4. For `PATH`: named key → resolve from `paths`; anything else → resolve relative to
|
|
98
|
+
`plugin_root`; `${ENV_VAR}` values pass through verbatim for runtime expansion.
|
|
99
|
+
5. Reject a map `version` they do not implement.
|
|
100
|
+
|
|
101
|
+
## Authoring guidance
|
|
102
|
+
|
|
103
|
+
- Write tokens with the **canonical (copilot) names**: `Use {{TOOL:task}} to spawn
|
|
104
|
+
subagents`, `Read {{PATH:skills/workflow/plan/SKILL.md}}`.
|
|
105
|
+
- Never embed a harness-specific name in the canonical sources (`agents/<name>/agent.md`,
|
|
106
|
+
`skills/**/SKILL.md`) — that defeats the map. The generated `claude/` subtree, the
|
|
107
|
+
generated `agents/*.agent.md` files, and any installed copies are the only places
|
|
108
|
+
substituted names appear.
|
|
109
|
+
- When you add a new tool to an agent's frontmatter, add a row to `tools` in
|
|
110
|
+
`token-map.json` in the same change — the contract treats unknown tools as errors, so a
|
|
111
|
+
missing row breaks the install pipeline loudly instead of silently.
|
|
112
|
+
|
|
113
|
+
## Related
|
|
114
|
+
|
|
115
|
+
- `agents/README.md` — composable agent directory format (ADR #63), defines the token
|
|
116
|
+
grammar the map implements.
|
|
117
|
+
- `scripts/build-copilot-agents.mjs` / `bin/installers/copilot.mjs` — the copilot
|
|
118
|
+
harness token composer (scripts/lib/compose-agent.mjs) resolves `{{TOOL:...}}` /
|
|
119
|
+
`{{PATH:...}}` against the map and `{{SECTION:...}}` from the per-harness section
|
|
120
|
+
files; `bin/installers/copilot.mjs` is the shared generator both `pnpm build:copilot`
|
|
121
|
+
and `agent-cortex install copilot` run.
|
|
122
|
+
- `bin/installers/claude.mjs` — the single Claude generator, behind both
|
|
123
|
+
`agent-cortex install claude` and the `pnpm build:claude` alias (no build-time
|
|
124
|
+
counterpart; the committed `claude/` subtree is regenerated by it and drift-checked).
|
|
125
|
+
- `bin/installers/pi.mjs` — the pi harness installer (`agent-cortex install pi`), an
|
|
126
|
+
install-time consumer of the map's contract.
|
|
127
|
+
- `lib/cli.mjs` — `SUPPORTED_HARNESSES`, the install command surface the harness ids come
|
|
128
|
+
from.
|