@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.
Files changed (41) hide show
  1. package/.agents/skills/style-versioning/SKILL.md +1 -1
  2. package/.github/workflows/ci.yml +47 -14
  3. package/AGENTS.md +19 -5
  4. package/CHANGELOG.md +12 -0
  5. package/README.md +111 -74
  6. package/agents/README.md +12 -11
  7. package/bin/agent-cortex.mjs +23 -10
  8. package/bin/installers/claude.mjs +518 -168
  9. package/bin/installers/copilot.mjs +22 -12
  10. package/bin/installers/pi.mjs +37 -34
  11. package/docs/claude-hooks.md +3 -3
  12. package/extensions/agent-modes/README.md +3 -2
  13. package/extensions/agent-modes/discover.test.ts +30 -7
  14. package/extensions/agent-modes/discover.ts +1 -1
  15. package/extensions/skill-stats/index.test.ts +3 -2
  16. package/lib/cli.mjs +22 -10
  17. package/package.json +4 -4
  18. package/plugin.json +1 -1
  19. package/scripts/build-copilot-agents.mjs +1 -1
  20. package/scripts/check-no-sync.mjs +49 -0
  21. package/scripts/lib/compose-agent.mjs +32 -14
  22. package/skills/engineering/grill-with-docs/SKILL.md +1 -1
  23. package/skills/workflow/hitl-collab/SKILL.md +1 -1
  24. package/test/claude-installer.test.mjs +199 -139
  25. package/test/cli.test.mjs +40 -30
  26. package/test/copilot-installer.test.mjs +92 -82
  27. package/test/helpers/fake-claude.mjs +152 -0
  28. package/test/installer.test.mjs +309 -57
  29. package/test/pi-installer.test.mjs +115 -99
  30. package/token-map.README.md +21 -12
  31. package/token-map.json +7 -6
  32. package/.claude-plugin/marketplace.json +0 -16
  33. package/claude/.claude-plugin/plugin.json +0 -11
  34. package/claude/agents/plan.md +0 -58
  35. package/claude/agents/ralph-plan.md +0 -121
  36. package/claude/agents/ralph.md +0 -193
  37. package/claude/agents/strategy.md +0 -111
  38. package/claude/hooks/scripts/notify.mjs +0 -54
  39. package/claude/hooks.json +0 -38
  40. /package/{claude → claude-extras}/.mcp.json +0 -0
  41. /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 { writeFileSync, mkdirSync, readdirSync, statSync, existsSync } from "node:fs";
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 statSync(p).isDirectory();
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 = readdirSync(join(root, "agents"), { withFileTypes: true }).sort((a, b) => byName(a.name, b.name));
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 (!existsSync(join(dir, "agent.md"))) continue; // not a composable agent dir
82
- if (!isDirectory(join(dir, "copilot")) || !existsSync(join(dir, "copilot", "frontmatter.json"))) {
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
- mkdirSync(out, { recursive: true });
133
+ await mkdir(out, { recursive: true });
124
134
  for (const { file, content } of files) {
125
- writeFileSync(join(out, file), content);
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;
@@ -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; a null mapping drops the
12
- // token from prose with a warning (pi has no ask_user/skill tool).
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 { readFileSync, writeFileSync, mkdirSync, copyFileSync, readdirSync, statSync } from "node:fs";
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 REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
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 statSync(p).isDirectory();
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 statSync(p).isFile();
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 ?? REPO_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 readdirSync(agentsSrc, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
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
- mkdirSync(agentsDir, { recursive: true });
127
- writeFileSync(filePath, content);
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) mkdirSync(skillsDir, { recursive: true });
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 readdirSync(skillsSrc).sort()) {
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 readdirSync(groupDir).sort()) {
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 = readdirSync(src, { withFileTypes: true });
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
- mkdirSync(dest, { recursive: true });
192
- if (entry.name.endsWith(".md")) writeFileSync(to, transform(readFileSync(from, "utf-8")));
193
- else copyFileSync(from, to);
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 };
@@ -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
- generated plugin as `claude/hooks.json`); support scripts live in
6
- `hooks/claude/scripts/` and are bundled into `claude/hooks/scripts/` by
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 `claude/agents/*.md` (including
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 without a PI equivalent (`ask_user`, `skill`) are omitted
38
- from the tool set.
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}} → null for pi — token dropped (dangling prose is the author's
141
- // responsibility per token-map.README), not left as literal token syntax.
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("drops null-mapped tools (ask_user, skill) from the PI tool set", () => {
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 → null (omitted), skill → null (omitted), web_fetch → fetch_content.
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 has no PI equivalent per the contract — must stay null.
234
- assert.equal(REAL_TOKEN_MAP?.tools?.ask_user?.pi, null);
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 (ask_user, skill) and the entry is omitted;
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: "2026-06-07T12:00:00.000Z",
339
+ lastUsed: recentTs,
339
340
  byProject: {
340
341
  "/project/a": {
341
342
  loadedCount: 3,
342
343
  invokedCount: 1,
343
344
  readCount: 2,
344
- lastUsed: "2026-06-07T12:00:00.000Z",
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.key === "dryRun") {
38
- options.dryRun = true;
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) Generate the plugin subtree AND register it
70
- with Claude Code via the "claude plugin" CLI
71
- (marketplace add → install → update) — requires
72
- the plugin CLI (Claude Code v2+)
73
- --output <dir> Write the plugin subtree to <dir> only — no
74
- runtime registration (default: <package
75
- root>/claude)
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.39.0",
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:claude && pnpm build:copilot",
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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "agent-cortex",
3
3
  "description": "Personal Copilot CLI plugin with custom agents and skills",
4
- "version": "1.39.0",
4
+ "version": "1.40.0",
5
5
  "author": {
6
6
  "name": "jaybeeuu"
7
7
  },
@@ -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 { readFileSync, existsSync } from "node:fs";
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 = readFileSync(join(agentDir, "agent.md"), "utf-8");
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 tokens)
104
- body = body.replace(/\{\{SECTION:([^}]+)\}\}/g, (_token, section) => {
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 (!existsSync(sectionPath)) {
122
+ if (!(await pathExists(sectionPath))) {
107
123
  throw new Error(`agent "${name}": missing section "${section}" for harness "${harness}" (${sectionPath})`);
108
124
  }
109
- return readFileSync(sectionPath, "utf-8").trim();
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 = readFileSync(path, "utf-8");
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 = readFileSync(path, "utf-8");
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. `pnpm build:claude && git diff --stat claude/`
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