@jaybeeuu/agent-cortex 1.39.0 → 1.40.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/style-versioning/SKILL.md +1 -1
- package/.github/workflows/ci.yml +47 -14
- package/AGENTS.md +19 -5
- package/CHANGELOG.md +12 -0
- package/README.md +111 -74
- package/agents/README.md +12 -11
- package/bin/agent-cortex.mjs +23 -10
- package/bin/installers/claude.mjs +518 -168
- package/bin/installers/copilot.mjs +22 -12
- package/bin/installers/pi.mjs +37 -34
- package/docs/claude-hooks.md +3 -3
- package/extensions/agent-modes/README.md +3 -2
- package/extensions/agent-modes/discover.test.ts +30 -7
- package/extensions/agent-modes/discover.ts +1 -1
- package/extensions/skill-stats/index.test.ts +3 -2
- package/lib/cli.mjs +22 -10
- package/package.json +4 -4
- package/plugin.json +1 -1
- package/scripts/build-copilot-agents.mjs +1 -1
- package/scripts/check-no-sync.mjs +49 -0
- package/scripts/lib/compose-agent.mjs +32 -14
- package/skills/engineering/grill-with-docs/SKILL.md +1 -1
- package/skills/workflow/hitl-collab/SKILL.md +1 -1
- package/test/claude-installer.test.mjs +199 -139
- package/test/cli.test.mjs +40 -30
- package/test/copilot-installer.test.mjs +92 -82
- package/test/helpers/fake-claude.mjs +152 -0
- package/test/installer.test.mjs +309 -57
- package/test/pi-installer.test.mjs +115 -99
- package/token-map.README.md +21 -12
- package/token-map.json +7 -6
- package/.claude-plugin/marketplace.json +0 -16
- package/claude/.claude-plugin/plugin.json +0 -11
- package/claude/agents/plan.md +0 -58
- package/claude/agents/ralph-plan.md +0 -121
- package/claude/agents/ralph.md +0 -193
- package/claude/agents/strategy.md +0 -111
- package/claude/hooks/scripts/notify.mjs +0 -54
- package/claude/hooks.json +0 -38
- /package/{claude → claude-extras}/.mcp.json +0 -0
- /package/{claude → claude-extras}/scripts/statusline-command.sh +0 -0
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
//
|
|
31
31
|
// Zero dependencies so it runs on the CI Node and local Node alike.
|
|
32
32
|
|
|
33
|
-
import {
|
|
33
|
+
import { writeFile, mkdir, readdir, stat } from "node:fs/promises";
|
|
34
34
|
import { join, dirname } from "node:path";
|
|
35
35
|
import { fileURLToPath } from "node:url";
|
|
36
36
|
import { composeAgent } from "../../scripts/lib/compose-agent.mjs";
|
|
@@ -38,9 +38,17 @@ import { composeAgent } from "../../scripts/lib/compose-agent.mjs";
|
|
|
38
38
|
const MODULE_DIR = dirname(fileURLToPath(import.meta.url));
|
|
39
39
|
const DEFAULT_ROOT = join(MODULE_DIR, "..", "..");
|
|
40
40
|
|
|
41
|
-
function isDirectory(p) {
|
|
41
|
+
async function isDirectory(p) {
|
|
42
42
|
try {
|
|
43
|
-
return
|
|
43
|
+
return (await stat(p)).isDirectory();
|
|
44
|
+
} catch {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function isFile(p) {
|
|
50
|
+
try {
|
|
51
|
+
return (await stat(p)).isFile();
|
|
44
52
|
} catch {
|
|
45
53
|
return false;
|
|
46
54
|
}
|
|
@@ -71,19 +79,21 @@ function frontmatterYaml(name, fm) {
|
|
|
71
79
|
}
|
|
72
80
|
|
|
73
81
|
/** Compose agents from agents/<name>/; every composable dir must ship a copilot harness. */
|
|
74
|
-
function buildAgents(root) {
|
|
82
|
+
async function buildAgents(root) {
|
|
75
83
|
const files = [];
|
|
76
|
-
const entries =
|
|
84
|
+
const entries = (await readdir(join(root, "agents"), { withFileTypes: true })).sort((a, b) =>
|
|
85
|
+
byName(a.name, b.name),
|
|
86
|
+
);
|
|
77
87
|
|
|
78
88
|
for (const entry of entries) {
|
|
79
89
|
if (!entry.isDirectory()) continue; // skip generated *.agent.md flat files and README
|
|
80
90
|
const dir = join(root, "agents", entry.name);
|
|
81
|
-
if (!
|
|
82
|
-
if (!isDirectory(join(dir, "copilot")) || !
|
|
91
|
+
if (!(await isFile(join(dir, "agent.md")))) continue; // not a composable agent dir
|
|
92
|
+
if (!(await isDirectory(join(dir, "copilot"))) || !(await isFile(join(dir, "copilot", "frontmatter.json")))) {
|
|
83
93
|
throw new Error(`agent "${entry.name}": composable directory without copilot/frontmatter.json — define the copilot harness or exclude the agent`);
|
|
84
94
|
}
|
|
85
95
|
|
|
86
|
-
const fm = composeAgent(root, entry.name, "copilot");
|
|
96
|
+
const fm = await composeAgent(root, entry.name, "copilot");
|
|
87
97
|
files.push({
|
|
88
98
|
name: entry.name,
|
|
89
99
|
file: `${entry.name}.agent.md`,
|
|
@@ -105,9 +115,9 @@ function buildAgents(root) {
|
|
|
105
115
|
* @param {boolean} [options.dryRun] Plan and report without writing anything.
|
|
106
116
|
* @returns {{ output: string, agents: string[], dryRun: boolean }}
|
|
107
117
|
*/
|
|
108
|
-
export function installCopilot({ root = DEFAULT_ROOT, output, dryRun = false } = {}) {
|
|
118
|
+
export async function installCopilot({ root = DEFAULT_ROOT, output, dryRun = false } = {}) {
|
|
109
119
|
const out = output ?? join(root, "agents");
|
|
110
|
-
const files = buildAgents(root);
|
|
120
|
+
const files = await buildAgents(root);
|
|
111
121
|
const summary = {
|
|
112
122
|
output: out,
|
|
113
123
|
agents: files.map((f) => f.name),
|
|
@@ -120,9 +130,9 @@ export function installCopilot({ root = DEFAULT_ROOT, output, dryRun = false } =
|
|
|
120
130
|
return summary;
|
|
121
131
|
}
|
|
122
132
|
|
|
123
|
-
|
|
133
|
+
await mkdir(out, { recursive: true });
|
|
124
134
|
for (const { file, content } of files) {
|
|
125
|
-
|
|
135
|
+
await writeFile(join(out, file), content);
|
|
126
136
|
}
|
|
127
137
|
console.log(`Generated ${files.length} Copilot agent file(s): ${summary.agents.join(", ")}`);
|
|
128
138
|
return summary;
|
package/bin/installers/pi.mjs
CHANGED
|
@@ -8,8 +8,9 @@
|
|
|
8
8
|
// Composition per the token-map.json contract:
|
|
9
9
|
// 1. {{SECTION:name}} is resolved from the agent's pi/<name>.md section file
|
|
10
10
|
// (shared composer, scripts/lib/compose-agent.mjs).
|
|
11
|
-
// 2. {{TOOL:key}} is substituted with the pi column
|
|
12
|
-
//
|
|
11
|
+
// 2. {{TOOL:key}} is substituted with the pi column (pi maps ask_user →
|
|
12
|
+
// ask_questions and skill → read); any (future) null pi mapping drops the
|
|
13
|
+
// token from prose with a warning.
|
|
13
14
|
// 3. {{PATH:key}} named keys resolve through the paths table; bare relative
|
|
14
15
|
// paths resolve against the plugin root (default: token-map.json's pi value,
|
|
15
16
|
// overridable with --plugin-root for checkout/symlinked installs).
|
|
@@ -24,7 +25,7 @@
|
|
|
24
25
|
//
|
|
25
26
|
// Zero dependencies so it runs on the CI Node and local Node alike.
|
|
26
27
|
|
|
27
|
-
import {
|
|
28
|
+
import { readFile, writeFile, mkdir, copyFile, readdir, stat } from "node:fs/promises";
|
|
28
29
|
import { join, dirname } from "node:path";
|
|
29
30
|
import { fileURLToPath } from "node:url";
|
|
30
31
|
import { homedir } from "node:os";
|
|
@@ -37,22 +38,22 @@ const PI = "pi";
|
|
|
37
38
|
// extended before this installer can trust it.
|
|
38
39
|
const CONTRACT_VERSION = 1;
|
|
39
40
|
|
|
40
|
-
const
|
|
41
|
+
const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
41
42
|
const DEFAULT_OUTPUT = join(homedir(), ".pi", "agent");
|
|
42
43
|
|
|
43
44
|
const DEFAULT_WARN = (msg) => console.warn(`[pi-installer] ${msg}`);
|
|
44
45
|
|
|
45
|
-
function isDirectory(p) {
|
|
46
|
+
async function isDirectory(p) {
|
|
46
47
|
try {
|
|
47
|
-
return
|
|
48
|
+
return (await stat(p)).isDirectory();
|
|
48
49
|
} catch {
|
|
49
50
|
return false;
|
|
50
51
|
}
|
|
51
52
|
}
|
|
52
53
|
|
|
53
|
-
function isFile(p) {
|
|
54
|
+
async function isFile(p) {
|
|
54
55
|
try {
|
|
55
|
-
return
|
|
56
|
+
return (await stat(p)).isFile();
|
|
56
57
|
} catch {
|
|
57
58
|
return false;
|
|
58
59
|
}
|
|
@@ -73,8 +74,8 @@ function isFile(p) {
|
|
|
73
74
|
* @returns {{ agents: {name:string, filePath:string}[], skills: {skills:number, md:number, files:number, dir:string},
|
|
74
75
|
* warnings: string[], dryRun: boolean, agentsDir: string, skillsDir: string }}
|
|
75
76
|
*/
|
|
76
|
-
export function installPi(options = {}) {
|
|
77
|
-
const root = options.root ??
|
|
77
|
+
export async function installPi(options = {}) {
|
|
78
|
+
const root = options.root ?? PACKAGE_ROOT;
|
|
78
79
|
const output = options.output ?? DEFAULT_OUTPUT;
|
|
79
80
|
const dryRun = options.dryRun ?? false;
|
|
80
81
|
const warnings = [];
|
|
@@ -83,7 +84,7 @@ export function installPi(options = {}) {
|
|
|
83
84
|
(options.warn ?? DEFAULT_WARN)(msg);
|
|
84
85
|
};
|
|
85
86
|
|
|
86
|
-
const tokenMap = loadTokenMap(root);
|
|
87
|
+
const tokenMap = await loadTokenMap(root);
|
|
87
88
|
if (typeof tokenMap.version === "number" && tokenMap.version > CONTRACT_VERSION) {
|
|
88
89
|
throw new Error(
|
|
89
90
|
`token-map.json version ${tokenMap.version} is newer than the contract version ${CONTRACT_VERSION} this installer implements — upgrade agent-cortex`,
|
|
@@ -94,26 +95,28 @@ export function installPi(options = {}) {
|
|
|
94
95
|
const agentsDir = join(output, "agents");
|
|
95
96
|
const skillsDir = join(output, "skills");
|
|
96
97
|
|
|
97
|
-
const agents = installAgents({ root, agentsDir, dryRun, pluginRoot, tokenMap, warn });
|
|
98
|
-
const skills = installSkills({ root, skillsDir, dryRun, pluginRoot, tokenMap, warn });
|
|
98
|
+
const agents = await installAgents({ root, agentsDir, dryRun, pluginRoot, tokenMap, warn });
|
|
99
|
+
const skills = await installSkills({ root, skillsDir, dryRun, pluginRoot, tokenMap, warn });
|
|
99
100
|
|
|
100
101
|
return { agents, skills, warnings, dryRun, agentsDir, skillsDir };
|
|
101
102
|
}
|
|
102
103
|
|
|
103
104
|
// ─── Agents ──────────────────────────────────────────────────────────────────
|
|
104
105
|
|
|
105
|
-
function installAgents({ root, agentsDir, dryRun, pluginRoot, tokenMap, warn }) {
|
|
106
|
+
async function installAgents({ root, agentsDir, dryRun, pluginRoot, tokenMap, warn }) {
|
|
106
107
|
const written = [];
|
|
107
108
|
const agentsSrc = join(root, "agents");
|
|
108
|
-
for (const entry of
|
|
109
|
+
for (const entry of (await readdir(agentsSrc, { withFileTypes: true })).sort((a, b) =>
|
|
110
|
+
a.name.localeCompare(b.name),
|
|
111
|
+
)) {
|
|
109
112
|
if (!entry.isDirectory()) continue; // skip generated *.agent.md flat files
|
|
110
113
|
const dir = join(agentsSrc, entry.name);
|
|
111
|
-
if (!isFile(join(dir, "agent.md"))) continue; // not a composable agent dir
|
|
112
|
-
if (!isDirectory(join(dir, PI)) || !isFile(join(dir, PI, "frontmatter.json"))) {
|
|
114
|
+
if (!(await isFile(join(dir, "agent.md")))) continue; // not a composable agent dir
|
|
115
|
+
if (!(await isDirectory(join(dir, PI))) || !(await isFile(join(dir, PI, "frontmatter.json")))) {
|
|
113
116
|
throw new Error(`agent "${entry.name}": composable directory without pi/frontmatter.json — define the pi harness or exclude the agent`);
|
|
114
117
|
}
|
|
115
118
|
|
|
116
|
-
const fm = composeAgent(root, entry.name, PI, {
|
|
119
|
+
const fm = await composeAgent(root, entry.name, PI, {
|
|
117
120
|
dropNullTools: true,
|
|
118
121
|
pluginRoot,
|
|
119
122
|
resolveRelativePaths: true,
|
|
@@ -123,8 +126,8 @@ function installAgents({ root, agentsDir, dryRun, pluginRoot, tokenMap, warn })
|
|
|
123
126
|
const content = renderPiAgent(entry.name, fm, tools);
|
|
124
127
|
const filePath = join(agentsDir, `${entry.name}.agent.md`);
|
|
125
128
|
if (!dryRun) {
|
|
126
|
-
|
|
127
|
-
|
|
129
|
+
await mkdir(agentsDir, { recursive: true });
|
|
130
|
+
await writeFile(filePath, content);
|
|
128
131
|
}
|
|
129
132
|
written.push({ name: entry.name, filePath });
|
|
130
133
|
}
|
|
@@ -147,24 +150,24 @@ function renderPiAgent(name, fm, tools) {
|
|
|
147
150
|
|
|
148
151
|
// ─── Skills ──────────────────────────────────────────────────────────────────
|
|
149
152
|
|
|
150
|
-
function installSkills({ root, skillsDir, dryRun, pluginRoot, tokenMap, warn }) {
|
|
153
|
+
async function installSkills({ root, skillsDir, dryRun, pluginRoot, tokenMap, warn }) {
|
|
151
154
|
const skillsSrc = join(root, "skills");
|
|
152
|
-
if (!isDirectory(skillsSrc)) return { skills: 0, md: 0, files: 0, dir: skillsDir };
|
|
155
|
+
if (!(await isDirectory(skillsSrc))) return { skills: 0, md: 0, files: 0, dir: skillsDir };
|
|
153
156
|
|
|
154
157
|
const transform = (content) =>
|
|
155
158
|
substituteTokens(content, PI, tokenMap, { dropNullTools: true, pluginRoot, resolveRelativePaths: true, warn });
|
|
156
159
|
|
|
157
|
-
if (!dryRun)
|
|
160
|
+
if (!dryRun) await mkdir(skillsDir, { recursive: true });
|
|
158
161
|
let skills = 0;
|
|
159
162
|
let md = 0;
|
|
160
163
|
let files = 0;
|
|
161
|
-
for (const group of
|
|
164
|
+
for (const group of (await readdir(skillsSrc)).sort()) {
|
|
162
165
|
const groupDir = join(skillsSrc, group);
|
|
163
|
-
if (!isDirectory(groupDir)) continue;
|
|
164
|
-
for (const name of
|
|
165
|
-
if (!isFile(join(groupDir, name, "SKILL.md"))) continue;
|
|
166
|
+
if (!(await isDirectory(groupDir))) continue;
|
|
167
|
+
for (const name of (await readdir(groupDir)).sort()) {
|
|
168
|
+
if (!(await isFile(join(groupDir, name, "SKILL.md")))) continue;
|
|
166
169
|
skills += 1;
|
|
167
|
-
const stats = copyTree(join(groupDir, name), join(skillsDir, group, name), transform, dryRun);
|
|
170
|
+
const stats = await copyTree(join(groupDir, name), join(skillsDir, group, name), transform, dryRun);
|
|
168
171
|
md += stats.md;
|
|
169
172
|
files += stats.files;
|
|
170
173
|
}
|
|
@@ -173,24 +176,24 @@ function installSkills({ root, skillsDir, dryRun, pluginRoot, tokenMap, warn })
|
|
|
173
176
|
}
|
|
174
177
|
|
|
175
178
|
/** Recursively copy a tree; transform .md file contents, copy everything else verbatim. */
|
|
176
|
-
function copyTree(src, dest, transform, dryRun) {
|
|
179
|
+
async function copyTree(src, dest, transform, dryRun) {
|
|
177
180
|
let md = 0;
|
|
178
181
|
let files = 0;
|
|
179
|
-
const entries =
|
|
182
|
+
const entries = await readdir(src, { withFileTypes: true });
|
|
180
183
|
for (const entry of entries) {
|
|
181
184
|
const from = join(src, entry.name);
|
|
182
185
|
const to = join(dest, entry.name);
|
|
183
186
|
if (entry.isDirectory()) {
|
|
184
|
-
const stats = copyTree(from, to, transform, dryRun);
|
|
187
|
+
const stats = await copyTree(from, to, transform, dryRun);
|
|
185
188
|
md += stats.md;
|
|
186
189
|
files += stats.files;
|
|
187
190
|
} else if (entry.isFile()) {
|
|
188
191
|
files += 1;
|
|
189
192
|
if (entry.name.endsWith(".md")) md += 1;
|
|
190
193
|
if (dryRun) continue;
|
|
191
|
-
|
|
192
|
-
if (entry.name.endsWith(".md"))
|
|
193
|
-
else
|
|
194
|
+
await mkdir(dest, { recursive: true });
|
|
195
|
+
if (entry.name.endsWith(".md")) await writeFile(to, transform(await readFile(from, "utf-8")));
|
|
196
|
+
else await copyFile(from, to);
|
|
194
197
|
}
|
|
195
198
|
}
|
|
196
199
|
return { md, files };
|
package/docs/claude-hooks.md
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
The pi extensions in `extensions/` were audited for Claude Code equivalents.
|
|
4
4
|
Implemented ports live in `hooks/claude/hooks.json` (copied verbatim into the
|
|
5
|
-
|
|
6
|
-
`hooks/claude/scripts/` and are bundled into `
|
|
5
|
+
materialised plugin as its `hooks.json`); support scripts live in
|
|
6
|
+
`hooks/claude/scripts/` and are bundled into the plugin's `hooks/scripts/` by
|
|
7
7
|
`bin/installers/claude.mjs` (referenced from hooks.json via `$CLAUDE_PLUGIN_ROOT`).
|
|
8
8
|
|
|
9
9
|
Claude Code's hook model differs from PI's: hooks are shell commands fed a JSON
|
|
@@ -100,7 +100,7 @@ for little signal. Rejected; Claude Code ships its own usage analytics
|
|
|
100
100
|
## `subagent/` → Reject (native)
|
|
101
101
|
|
|
102
102
|
pi's `subagent` tool spawns isolated `pi --mode json` processes. Claude Code has
|
|
103
|
-
native subagents — the plugin already ships
|
|
103
|
+
native subagents — the plugin already ships `~/.agent-cortex/claude/agents/*.md` (including
|
|
104
104
|
`ralph`, `plan`, `strategy`, `ralph-plan`) executed via the built-in Agent/Task
|
|
105
105
|
tools with per-session context isolation — so a hook-based workaround would be
|
|
106
106
|
strictly worse.
|
|
@@ -34,8 +34,9 @@ contract:
|
|
|
34
34
|
1. `{{SECTION:name}}` tokens are replaced with the agent's `pi/<name>.md`.
|
|
35
35
|
2. `{{TOOL:key}}` / `{{PATH:key}}` tokens are substituted against the pi column
|
|
36
36
|
of `token-map.json`, with the plugin root resolved from the actual extension
|
|
37
|
-
location. Tools
|
|
38
|
-
|
|
37
|
+
location. Tools map through that column (e.g. `ask_user` → `ask_questions`,
|
|
38
|
+
`skill` → `read`); a tool with a null mapping — any future one — is omitted
|
|
39
|
+
from the tool set with a warning.
|
|
39
40
|
|
|
40
41
|
Flat `agents/*.agent.md` files are parsed as a fallback when an agent has no
|
|
41
42
|
composable directory — they are generated output (scripts/build-copilot-agents.mjs)
|
|
@@ -137,8 +137,8 @@ describe("discoverAgents (composable source format)", () => {
|
|
|
137
137
|
|
|
138
138
|
// {{TOOL:bash}} → bash.
|
|
139
139
|
assert.match(plan.prompt, /Run with bash\./);
|
|
140
|
-
// {{TOOL:ask_user}} →
|
|
141
|
-
|
|
140
|
+
// {{TOOL:ask_user}} → ask_questions (pi's native structured-questions tool).
|
|
141
|
+
assert.match(plan.prompt, /Ask the user with ask_questions\./);
|
|
142
142
|
assert.doesNotMatch(plan.prompt, /\{\{TOOL:ask_user\}\}/);
|
|
143
143
|
// Relative {{PATH:...}} resolves against the actual package root (dir of agents/).
|
|
144
144
|
assert.match(plan.prompt, new RegExp(`Read the plan skill at ${escapeRegExp(fx.dir)}/skills/workflow/plan/SKILL\\.md\\.`));
|
|
@@ -147,7 +147,7 @@ describe("discoverAgents (composable source format)", () => {
|
|
|
147
147
|
assert.doesNotMatch(plan.prompt, /\{\{/);
|
|
148
148
|
});
|
|
149
149
|
|
|
150
|
-
it("
|
|
150
|
+
it("maps ask_user → ask_questions and skill → read into the PI tool set", () => {
|
|
151
151
|
const fx = track(makeFixture());
|
|
152
152
|
writeComposableAgent(fx, "plan", {
|
|
153
153
|
frontmatter: {
|
|
@@ -159,8 +159,30 @@ describe("discoverAgents (composable source format)", () => {
|
|
|
159
159
|
|
|
160
160
|
const agents = discoverAgents(fx.agentsDir, REAL_TOKEN_MAP);
|
|
161
161
|
assert.equal(agents.length, 1);
|
|
162
|
-
// ask_user →
|
|
163
|
-
assert.deepEqual(agents[0].tools, ["bash", "fetch_content"]);
|
|
162
|
+
// ask_user → ask_questions, skill → read (pi reads skill files), web_fetch → fetch_content.
|
|
163
|
+
assert.deepEqual(agents[0].tools, ["ask_questions", "bash", "fetch_content", "read"]);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("warns and omits a tool whose pi mapping is null", () => {
|
|
167
|
+
const fx = track(makeFixture());
|
|
168
|
+
const NULL_MAP = {
|
|
169
|
+
tools: { ask_user: { copilot: "ask_user", claude: "AskUserQuestion", pi: null } },
|
|
170
|
+
paths: {},
|
|
171
|
+
} as TokenMap;
|
|
172
|
+
writeComposableAgent(fx, "plan", {
|
|
173
|
+
frontmatter: {
|
|
174
|
+
name: "agent-cortex:plan",
|
|
175
|
+
description: "Planner",
|
|
176
|
+
tools: ["ask_user", "bash"],
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
const agents = discoverAgents(fx.agentsDir, NULL_MAP);
|
|
181
|
+
assert.equal(agents.length, 1);
|
|
182
|
+
// With a null pi column the entry is omitted (and warned about), per the contract.
|
|
183
|
+
// (bash survives so the result is observable — an all-dropped list would be
|
|
184
|
+
// normalised to DEFAULT_TOOLS, which is "no restriction", not the omit result.)
|
|
185
|
+
assert.deepEqual(agents[0].tools, ["bash"]);
|
|
164
186
|
});
|
|
165
187
|
|
|
166
188
|
it("uses the built-in fallback map when token-map.json is unavailable", () => {
|
|
@@ -230,8 +252,9 @@ describe("loadTokenMap", () => {
|
|
|
230
252
|
it("reads the real token-map.json at the package root", () => {
|
|
231
253
|
assert.ok(REAL_TOKEN_MAP);
|
|
232
254
|
assert.equal(typeof REAL_TOKEN_MAP?.tools?.bash?.pi, "string");
|
|
233
|
-
// ask_user
|
|
234
|
-
assert.equal(REAL_TOKEN_MAP?.tools?.ask_user?.pi,
|
|
255
|
+
// ask_user → ask_questions (pi's native structured-questions tool); skill → read.
|
|
256
|
+
assert.equal(REAL_TOKEN_MAP?.tools?.ask_user?.pi, "ask_questions");
|
|
257
|
+
assert.equal(REAL_TOKEN_MAP?.tools?.skill?.pi, "read");
|
|
235
258
|
assert.ok(REAL_TOKEN_MAP?.paths?.agents_dir);
|
|
236
259
|
});
|
|
237
260
|
});
|
|
@@ -120,7 +120,7 @@ function prettifyId(id: string): string {
|
|
|
120
120
|
* Translate a frontmatter tool list into PI tool names.
|
|
121
121
|
*
|
|
122
122
|
* With token-map.json: canonical keys are translated via the pi column; a null
|
|
123
|
-
* mapping means PI has no equivalent
|
|
123
|
+
* mapping means PI has no equivalent for that tool and the entry is omitted;
|
|
124
124
|
* a name that is not a canonical key passes through untouched (it may be a
|
|
125
125
|
* native PI tool such as `fetch_content`). Without the map, the built-in
|
|
126
126
|
* COPILOT_TO_PI fallback applies and unknown names pass through.
|
|
@@ -331,17 +331,18 @@ describe("loadStore / saveStore", () => {
|
|
|
331
331
|
const file = join(tmpDir, "test-store.json");
|
|
332
332
|
const original = freshStore();
|
|
333
333
|
original.totalTurns = 42;
|
|
334
|
+
const recentTs = new Date(Date.now() - 60 * 24 * 60 * 60 * 1000).toISOString();
|
|
334
335
|
original.skills["test-skill"] = {
|
|
335
336
|
loadedCount: 5,
|
|
336
337
|
invokedCount: 2,
|
|
337
338
|
readCount: 3,
|
|
338
|
-
lastUsed:
|
|
339
|
+
lastUsed: recentTs,
|
|
339
340
|
byProject: {
|
|
340
341
|
"/project/a": {
|
|
341
342
|
loadedCount: 3,
|
|
342
343
|
invokedCount: 1,
|
|
343
344
|
readCount: 2,
|
|
344
|
-
lastUsed:
|
|
345
|
+
lastUsed: recentTs,
|
|
345
346
|
},
|
|
346
347
|
},
|
|
347
348
|
};
|
package/lib/cli.mjs
CHANGED
|
@@ -5,9 +5,10 @@ const SUPPORTED_HARNESSES = ["copilot", "claude", "pi"];
|
|
|
5
5
|
// malformed options surface as `optionError` on the parsed result; the CLI
|
|
6
6
|
// prints them and exits 1.
|
|
7
7
|
const INSTALL_OPTIONS = {
|
|
8
|
-
"--dry-run": { key: "dryRun" },
|
|
8
|
+
"--dry-run": { key: "dryRun", boolean: true },
|
|
9
9
|
"--output": { key: "output" },
|
|
10
10
|
"--plugin-root": { key: "pluginRoot" },
|
|
11
|
+
"--require-register": { key: "requireRegister", boolean: true },
|
|
11
12
|
};
|
|
12
13
|
|
|
13
14
|
export function parseArgs(argv) {
|
|
@@ -34,8 +35,8 @@ export function parseArgs(argv) {
|
|
|
34
35
|
if (!spec) {
|
|
35
36
|
return { command: "install", harness, optionError: `Unknown option "${arg}". Run "agent-cortex --help" for usage.` };
|
|
36
37
|
}
|
|
37
|
-
if (spec.
|
|
38
|
-
options.
|
|
38
|
+
if (spec.boolean) {
|
|
39
|
+
options[spec.key] = true;
|
|
39
40
|
continue;
|
|
40
41
|
}
|
|
41
42
|
if (inlineValue !== null) {
|
|
@@ -66,15 +67,26 @@ Commands:
|
|
|
66
67
|
install <harness> Install agent-cortex for a harness
|
|
67
68
|
Supported harnesses: ${SUPPORTED_HARNESSES.join(", ")}
|
|
68
69
|
Options (claude):
|
|
69
|
-
(no --output)
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
70
|
+
(no --output) Materialise the plugin into
|
|
71
|
+
~/.agent-cortex/claude, write the
|
|
72
|
+
marketplace manifest at
|
|
73
|
+
~/.agent-cortex/.claude-plugin/
|
|
74
|
+
marketplace.json, and register it with
|
|
75
|
+
Claude Code via the "claude plugin" CLI
|
|
76
|
+
(marketplace add → install → update,
|
|
77
|
+
idempotent by state: a re-run is the
|
|
78
|
+
update path, repeat install a no-op;
|
|
79
|
+
requires the plugin CLI, v2+)
|
|
80
|
+
--output <dir> Write the plugin subtree to <dir> only —
|
|
81
|
+
no marketplace manifest or registration
|
|
82
|
+
(default: ~/.agent-cortex/claude)
|
|
76
83
|
--dry-run Print what would be generated/registered
|
|
77
84
|
without writing
|
|
85
|
+
--require-register
|
|
86
|
+
Fail the install when Claude Code
|
|
87
|
+
registration cannot run (missing CLI,
|
|
88
|
+
pre-v2 build); default warns and prints
|
|
89
|
+
the manual registration commands
|
|
78
90
|
Options (copilot):
|
|
79
91
|
--output <dir> Write the flat *.agent.md files to <dir>
|
|
80
92
|
(default: <package root>/agents — the dir
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jaybeeuu/agent-cortex",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.40.0",
|
|
4
4
|
"description": "Personal PI package with custom agents, skills, and extensions",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package"
|
|
@@ -28,13 +28,13 @@
|
|
|
28
28
|
},
|
|
29
29
|
"scripts": {
|
|
30
30
|
"build": "echo 'Build complete — no compilation needed for PI package'",
|
|
31
|
-
"lint": "pnpm typecheck",
|
|
31
|
+
"lint": "pnpm typecheck && pnpm check:sync",
|
|
32
|
+
"check:sync": "node scripts/check-no-sync.mjs",
|
|
32
33
|
"test": "node --test test/*.test.mjs && pnpm -r test",
|
|
33
34
|
"typecheck": "pnpm -r typecheck",
|
|
34
|
-
"build:claude": "node bin/agent-cortex.mjs install claude --output claude",
|
|
35
35
|
"build:copilot": "node scripts/build-copilot-agents.mjs",
|
|
36
36
|
"changeset": "changeset",
|
|
37
|
-
"version-packages": "changeset version && bash scripts/sync-plugin-version.sh && pnpm build:
|
|
37
|
+
"version-packages": "changeset version && bash scripts/sync-plugin-version.sh && pnpm build:copilot",
|
|
38
38
|
"publish-package": "TARBALL=$(pnpm pack | tail -n 1) && npm publish \"$TARBALL\" --provenance --access public",
|
|
39
39
|
"release": "changeset publish",
|
|
40
40
|
"postinstall": "bash scripts/install-instructions.sh"
|
package/plugin.json
CHANGED
|
@@ -27,4 +27,4 @@ import { installCopilot } from "../bin/installers/copilot.mjs";
|
|
|
27
27
|
|
|
28
28
|
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
29
29
|
|
|
30
|
-
installCopilot({ root: ROOT, output: join(ROOT, "agents") });
|
|
30
|
+
await installCopilot({ root: ROOT, output: join(ROOT, "agents") });
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Repo convention: never block the main thread — no `*Sync` functions
|
|
2
|
+
// (fs/child_process/...) in tooling code. Runs via `pnpm lint` (CI) and
|
|
3
|
+
// locally. Scans the toolchain and test dirs: bin/, scripts/, lib/, hooks/,
|
|
4
|
+
// test/. Skill-owned scripts (skills/*/scripts) and extensions are separate
|
|
5
|
+
// follow-up surfaces tracked outside this check.
|
|
6
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
7
|
+
import { join, relative } from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
|
|
10
|
+
const ROOT = join(fileURLToPath(new URL(".", import.meta.url)), "..");
|
|
11
|
+
const SCAN_DIRS = ["bin", "scripts", "lib", "hooks", "test"];
|
|
12
|
+
const EXTENSIONS = new Set([".mjs", ".js", ".ts"]);
|
|
13
|
+
const SELF = "scripts/check-no-sync.mjs";
|
|
14
|
+
|
|
15
|
+
const SYNC_CALL = /\b\w*Sync\s*\(/;
|
|
16
|
+
|
|
17
|
+
/** Recursively collect files under dir, skipping this script and node_modules. */
|
|
18
|
+
async function collect(dir) {
|
|
19
|
+
const out = [];
|
|
20
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
21
|
+
for (const e of entries) {
|
|
22
|
+
if (e.name === "node_modules") continue;
|
|
23
|
+
const full = join(dir, e.name);
|
|
24
|
+
if (e.isDirectory()) out.push(...(await collect(full)));
|
|
25
|
+
else if (EXTENSIONS.has(e.name.slice(e.name.lastIndexOf(".")))) out.push(full);
|
|
26
|
+
}
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const offenders = [];
|
|
31
|
+
for (const dir of SCAN_DIRS) {
|
|
32
|
+
const abs = join(ROOT, dir);
|
|
33
|
+
for (const file of await collect(abs)) {
|
|
34
|
+
const rel = relative(ROOT, file);
|
|
35
|
+
if (rel === SELF) continue;
|
|
36
|
+
const src = await readFile(file, "utf-8");
|
|
37
|
+
const hits = src.split("\n").map((line, i) => ({ line: i + 1, text: line })).filter((l) => SYNC_CALL.test(l.text));
|
|
38
|
+
if (hits.length) offenders.push({ rel, hits });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (offenders.length) {
|
|
43
|
+
console.error("✗ Never block the main thread — *Sync function calls found:");
|
|
44
|
+
for (const { rel, hits } of offenders) {
|
|
45
|
+
for (const { line, text } of hits) console.error(` ${rel}:${line}: ${text.trim()}`);
|
|
46
|
+
}
|
|
47
|
+
process.exit(1);
|
|
48
|
+
}
|
|
49
|
+
console.log("✓ no *Sync calls in tooling code (bin/, scripts/, lib/, hooks/, test/)");
|
|
@@ -29,9 +29,19 @@
|
|
|
29
29
|
//
|
|
30
30
|
// Zero dependencies so it runs on the CI Node (20) and local Node alike.
|
|
31
31
|
|
|
32
|
-
import {
|
|
32
|
+
import { readFile, stat } from "node:fs/promises";
|
|
33
33
|
import { join } from "node:path";
|
|
34
34
|
|
|
35
|
+
/** True when a path exists (stat succeeds). */
|
|
36
|
+
async function pathExists(p) {
|
|
37
|
+
try {
|
|
38
|
+
await stat(p);
|
|
39
|
+
return true;
|
|
40
|
+
} catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
35
45
|
// ─── Token substitution ──────────────────────────────────────────────────────
|
|
36
46
|
|
|
37
47
|
const TOOL_RE = /\{\{TOOL:([^}]+)\}\}/g;
|
|
@@ -92,22 +102,30 @@ export function substituteTokens(content, harness, tokenMap, options = {}) {
|
|
|
92
102
|
* @param {string} harness Harness id: "copilot" | "claude" | "pi"
|
|
93
103
|
* @returns {{ name: string, description: string, tools: string[], model?: string, argumentHint?: string, body: string }}
|
|
94
104
|
*/
|
|
95
|
-
export function composeAgent(root, name, harness, options = {}) {
|
|
105
|
+
export async function composeAgent(root, name, harness, options = {}) {
|
|
96
106
|
const { dropNullTools = false, pluginRoot, resolveRelativePaths = false, warn = noop } = options;
|
|
97
107
|
const agentDir = join(root, "agents", name);
|
|
98
|
-
const frontmatter = parseFrontmatter(join(agentDir, harness, "frontmatter.json"), name, harness);
|
|
99
|
-
const tokenMap = loadTokenMap(root);
|
|
108
|
+
const frontmatter = await parseFrontmatter(join(agentDir, harness, "frontmatter.json"), name, harness);
|
|
109
|
+
const tokenMap = await loadTokenMap(root);
|
|
100
110
|
|
|
101
|
-
let body =
|
|
111
|
+
let body = await readFile(join(agentDir, "agent.md"), "utf-8");
|
|
102
112
|
|
|
103
|
-
// 1. {{SECTION:name}} → <harness>/<name>.md (may itself contain TOOL/PATH
|
|
104
|
-
|
|
113
|
+
// 1. {{SECTION:name}} → <harness>/<name>.md (may itself contain TOOL/PATH
|
|
114
|
+
// tokens). Single pass over the original body — inserted section content
|
|
115
|
+
// is never rescanned (same semantics as the sync replace()).
|
|
116
|
+
const sectionRe = /\{\{SECTION:([^}]+)\}\}/g;
|
|
117
|
+
let out = "";
|
|
118
|
+
let last = 0;
|
|
119
|
+
for (let m = sectionRe.exec(body); m; m = sectionRe.exec(body)) {
|
|
120
|
+
const section = m[1];
|
|
105
121
|
const sectionPath = join(agentDir, harness, `${section}.md`);
|
|
106
|
-
if (!
|
|
122
|
+
if (!(await pathExists(sectionPath))) {
|
|
107
123
|
throw new Error(`agent "${name}": missing section "${section}" for harness "${harness}" (${sectionPath})`);
|
|
108
124
|
}
|
|
109
|
-
|
|
110
|
-
|
|
125
|
+
out += body.slice(last, m.index) + (await readFile(sectionPath, "utf-8")).trim();
|
|
126
|
+
last = m.index + m[0].length;
|
|
127
|
+
}
|
|
128
|
+
body = out + body.slice(last);
|
|
111
129
|
|
|
112
130
|
// 2. + 3. TOOL/PATH substitution (contextualised for error messages)
|
|
113
131
|
body = substituteTokens(body, harness, tokenMap, {
|
|
@@ -127,8 +145,8 @@ const REQUIRED_FIELDS = ["name", "description", "tools"];
|
|
|
127
145
|
const KNOWN_OPTIONAL_FIELDS = ["model", "argumentHint"];
|
|
128
146
|
|
|
129
147
|
/** Parse and schema-check a harness frontmatter.json (strict, per agents/README.md). */
|
|
130
|
-
function parseFrontmatter(path, name, harness) {
|
|
131
|
-
const raw =
|
|
148
|
+
async function parseFrontmatter(path, name, harness) {
|
|
149
|
+
const raw = await readFile(path, "utf-8");
|
|
132
150
|
let fm;
|
|
133
151
|
try {
|
|
134
152
|
fm = JSON.parse(raw);
|
|
@@ -225,11 +243,11 @@ export function translateToolList(tools, tokenMap, harness, { warn = noop } = {}
|
|
|
225
243
|
// ─── Token map ───────────────────────────────────────────────────────────────
|
|
226
244
|
|
|
227
245
|
/** Load token-map.json from the package root; missing/incomplete maps hard-fail. */
|
|
228
|
-
export function loadTokenMap(root) {
|
|
246
|
+
export async function loadTokenMap(root) {
|
|
229
247
|
const path = join(root, "token-map.json");
|
|
230
248
|
let raw;
|
|
231
249
|
try {
|
|
232
|
-
raw =
|
|
250
|
+
raw = await readFile(path, "utf-8");
|
|
233
251
|
} catch {
|
|
234
252
|
throw new Error(`missing ${path} — the composer requires token-map.json`);
|
|
235
253
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: grill-with-docs
|
|
3
|
-
description: Grilling session that challenges your plan against the existing domain model, sharpens terminology, and updates documentation (CONTEXT.md, ADRs) inline as decisions crystallise. Use when the user wants to stress-test a plan against their project's language and documented decisions: "grill my plan", "check this against our docs".
|
|
3
|
+
description: "Grilling session that challenges your plan against the existing domain model, sharpens terminology, and updates documentation (CONTEXT.md, ADRs) inline as decisions crystallise. Use when the user wants to stress-test a plan against their project's language and documented decisions: \"grill my plan\", \"check this against our docs\"."
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Grill With Docs
|
|
@@ -80,7 +80,7 @@ Needs sign-off on the API contract change.
|
|
|
80
80
|
- [ ] Contract change approved by reviewer
|
|
81
81
|
## Manual steps
|
|
82
82
|
1. `pnpm install && pnpm test`
|
|
83
|
-
2. `
|
|
83
|
+
2. `agent-cortex install claude --dry-run` # materialiser smoke check (CI runs the full structural check)
|
|
84
84
|
## Verification checklist
|
|
85
85
|
- [ ] Tests pass locally
|
|
86
86
|
- [ ] Generated output up to date
|