@jaybeeuu/agent-cortex 1.20.0 → 1.38.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/.agents/skills/style-versioning/SKILL.md +102 -0
  2. package/.changeset/README.md +8 -0
  3. package/.changeset/config.json +11 -0
  4. package/.github/workflows/ci.yml +227 -6
  5. package/AGENTS.md +29 -24
  6. package/CHANGELOG.md +119 -5
  7. package/README.md +132 -33
  8. package/agents/README.md +47 -13
  9. package/agents/plan/agent.md +51 -0
  10. package/agents/plan/claude/frontmatter.json +6 -0
  11. package/agents/plan/copilot/frontmatter.json +6 -0
  12. package/agents/plan/pi/frontmatter.json +6 -0
  13. package/agents/plan.agent.md +3 -0
  14. package/agents/ralph/agent.md +262 -0
  15. package/agents/ralph/claude/frontmatter.json +6 -0
  16. package/agents/ralph/claude/polling.md +31 -0
  17. package/agents/ralph/copilot/frontmatter.json +6 -0
  18. package/agents/ralph/copilot/polling.md +62 -0
  19. package/agents/ralph/pi/frontmatter.json +6 -0
  20. package/agents/ralph/pi/polling.md +74 -0
  21. package/agents/ralph-plan/agent.md +114 -0
  22. package/agents/ralph-plan/claude/frontmatter.json +6 -0
  23. package/agents/ralph-plan/copilot/frontmatter.json +6 -0
  24. package/agents/ralph-plan/pi/frontmatter.json +6 -0
  25. package/agents/ralph-plan.agent.md +5 -2
  26. package/agents/ralph.agent.md +83 -57
  27. package/agents/strategy/agent.md +104 -0
  28. package/agents/strategy/claude/frontmatter.json +6 -0
  29. package/agents/strategy/copilot/frontmatter.json +6 -0
  30. package/agents/strategy/pi/frontmatter.json +6 -0
  31. package/agents/strategy.agent.md +3 -0
  32. package/bin/agent-cortex.mjs +68 -1
  33. package/bin/installers/claude.mjs +262 -0
  34. package/bin/installers/copilot.mjs +129 -0
  35. package/bin/installers/pi.mjs +197 -0
  36. package/claude/.claude-plugin/plugin.json +1 -1
  37. package/claude/agents/plan.md +3 -1
  38. package/claude/agents/ralph-plan.md +5 -3
  39. package/claude/agents/strategy.md +3 -1
  40. package/claude/hooks/scripts/notify.mjs +54 -0
  41. package/claude/hooks.json +15 -0
  42. package/docs/claude-hooks.md +115 -0
  43. package/docs/ideas/unified-ci-pipeline.md +28 -0
  44. package/docs/skills/skill-anatomy.md +9 -0
  45. package/extensions/agent-modes/README.md +54 -0
  46. package/extensions/agent-modes/discover.test.ts +241 -0
  47. package/extensions/agent-modes/discover.ts +458 -0
  48. package/extensions/agent-modes/index.ts +13 -224
  49. package/extensions/agent-modes/node_modules/.bin/pi +21 -0
  50. package/extensions/agent-modes/node_modules/.bin/tsc +21 -0
  51. package/extensions/agent-modes/node_modules/.bin/tsserver +21 -0
  52. package/extensions/agent-modes/node_modules/.bin/tsx +21 -0
  53. package/extensions/agent-modes/package.json +20 -0
  54. package/extensions/agent-modes/tsconfig.json +12 -0
  55. package/extensions/session-start.ts +1 -1
  56. package/extensions/skill-stats/context-loader.test.ts +1 -1
  57. package/extensions/skill-stats/node_modules/.bin/pi +21 -0
  58. package/extensions/skill-stats/node_modules/.bin/tsc +21 -0
  59. package/extensions/skill-stats/node_modules/.bin/tsserver +21 -0
  60. package/extensions/skill-stats/node_modules/.bin/tsx +21 -0
  61. package/extensions/subagent/README.md +19 -0
  62. package/extensions/subagent/index.ts +131 -45
  63. package/extensions/subagent/node_modules/.bin/pi +21 -0
  64. package/extensions/subagent/node_modules/.bin/pi-ai +21 -0
  65. package/extensions/subagent/node_modules/.bin/tsc +21 -0
  66. package/extensions/subagent/node_modules/.bin/tsserver +21 -0
  67. package/extensions/subagent/node_modules/.bin/tsx +21 -0
  68. package/extensions/subagent/package.json +23 -0
  69. package/extensions/subagent/tsconfig.json +12 -0
  70. package/extensions/subagent/wait.test.ts +202 -0
  71. package/extensions/subagent/wait.ts +118 -0
  72. package/hooks/claude/hooks.json +38 -0
  73. package/hooks/claude/scripts/notify.mjs +54 -0
  74. package/lib/cli.mjs +62 -3
  75. package/package.json +10 -7
  76. package/plugin.json +1 -1
  77. package/pnpm-workspace.yaml +1 -0
  78. package/scripts/build-copilot-agents.mjs +30 -0
  79. package/scripts/lib/compose-agent.mjs +241 -0
  80. package/scripts/sync-plugin-version.sh +17 -0
  81. package/skills/engineering/design-an-interface/SKILL.md +1 -1
  82. package/skills/engineering/improve-codebase-architecture/SKILL.md +1 -1
  83. package/skills/workflow/ralph/REFERENCE.md +2 -2
  84. package/skills/workflow/ralph/SKILL.md +6 -6
  85. package/skills/workflow/run-pipeline-stage/scripts/generate-progress.ts +4 -2
  86. package/skills/workflow/using-agent-skills/SKILL.md +15 -2
  87. package/test/cli.test.mjs +115 -2
  88. package/test/copilot-installer.test.mjs +298 -0
  89. package/test/installer.test.mjs +179 -0
  90. package/test/notify-hook.test.mjs +63 -0
  91. package/test/pi-installer.test.mjs +371 -0
  92. package/token-map.README.md +128 -0
  93. package/token-map.json +106 -0
  94. package/.beads/README.md +0 -81
  95. package/.beads/config.yaml +0 -1
  96. package/.beads/issues.jsonl +0 -236
  97. package/.beads/metadata.json +0 -7
  98. package/.github/workflows/ci.test.ts +0 -217
  99. package/.github/workflows/publish.yml +0 -36
  100. package/scripts/build-claude-agents.mjs +0 -179
  101. package/skills/workflow/using-agent-skills/scripts/validate-skill-structure.test.ts +0 -73
  102. /package/extensions/{context-loader.md → lib/context-loader.md} +0 -0
  103. /package/extensions/{context-loader.ts → lib/context-loader.ts} +0 -0
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Blocking wait over background subagent runs.
3
+ *
4
+ * Factored out of the extension entrypoint so the wait semantics can be tested
5
+ * without spawning real `pi` processes: the run store is injected as a lookup
6
+ * function and completion is driven by each entry's `promise` (which resolves
7
+ * exactly when the run's result is committed).
8
+ */
9
+
10
+ export interface BackgroundEntry<T> {
11
+ /** The run's result once it finishes; null while the agent is still working. */
12
+ result: T | null;
13
+ /** Resolves when the run finishes (success, failure, or abort). */
14
+ promise: Promise<unknown>;
15
+ }
16
+
17
+ export interface CompletedRun<T> {
18
+ agentId: string;
19
+ result: T;
20
+ }
21
+
22
+ export interface WaitResult<T> {
23
+ /** Requested agents whose result is now available, in requested order. */
24
+ completed: CompletedRun<T>[];
25
+ /** Requested agents still working when the wait ended. */
26
+ running: string[];
27
+ /** Requested IDs with no tracked run (never dispatched or expired). */
28
+ notFound: string[];
29
+ /** True when the wait ended because the timeout elapsed with agents still running. */
30
+ timedOut: boolean;
31
+ /** True when the wait ended because the abort signal fired. */
32
+ aborted: boolean;
33
+ }
34
+
35
+ export interface WaitOptions {
36
+ /** Block for at most this many milliseconds before returning (default 120_000). */
37
+ timeoutMs?: number;
38
+ /** Abort the wait early (e.g. user Ctrl+C). */
39
+ signal?: AbortSignal;
40
+ }
41
+
42
+ export const DEFAULT_WAIT_TIMEOUT_MS = 120_000;
43
+
44
+ type GetEntry<T> = (agentId: string) => BackgroundEntry<T> | undefined;
45
+
46
+ function scan<T>(getEntry: GetEntry<T>, agentIds: string[], timedOut: boolean, aborted: boolean): WaitResult<T> {
47
+ const completed: CompletedRun<T>[] = [];
48
+ const running: string[] = [];
49
+ const notFound: string[] = [];
50
+ for (const agentId of agentIds) {
51
+ const entry = getEntry(agentId);
52
+ if (!entry) notFound.push(agentId);
53
+ else if (entry.result !== null) completed.push({ agentId, result: entry.result });
54
+ else running.push(agentId);
55
+ }
56
+ return { completed, running, notFound, timedOut, aborted };
57
+ }
58
+
59
+ export async function waitForAgents<T>(
60
+ getEntry: GetEntry<T>,
61
+ agentIds: string[],
62
+ options: WaitOptions = {},
63
+ ): Promise<WaitResult<T>> {
64
+ const timeoutMs = options.timeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS;
65
+ const { signal } = options;
66
+
67
+ const hasResolution = (result: WaitResult<T>): boolean => result.completed.length > 0 || result.notFound.length > 0;
68
+
69
+ // Fast path: any requested agent that already resolved (completed or unknown)
70
+ // ends the wait — the caller dispatches replacements before waiting again.
71
+ const initial = scan(getEntry, agentIds, false, false);
72
+ if (hasResolution(initial)) return initial;
73
+
74
+ // Single shared abort racer so a fired signal always wins the next race.
75
+ const abortRacer: Promise<"aborted"> | undefined = signal
76
+ ? new Promise((resolve) => {
77
+ if (signal.aborted) resolve("aborted");
78
+ else signal.addEventListener("abort", () => resolve("aborted"), { once: true });
79
+ })
80
+ : undefined;
81
+
82
+ const deadline = Date.now() + timeoutMs;
83
+ let timedOut = false;
84
+ let aborted = false;
85
+
86
+ while (!timedOut && !aborted) {
87
+ const runningPromises: Promise<unknown>[] = agentIds
88
+ .map(getEntry)
89
+ .filter((entry): entry is BackgroundEntry<T> => entry !== undefined && entry.result === null)
90
+ .map((entry) => entry.promise);
91
+
92
+ const remaining = deadline - Date.now();
93
+ if (remaining <= 0) {
94
+ timedOut = true;
95
+ break;
96
+ }
97
+
98
+ const racers: Promise<"completed" | "timeout" | "aborted">[] = [];
99
+ if (runningPromises.length > 0) {
100
+ // A settled run promise means its result was committed before settling.
101
+ racers.push(Promise.race(runningPromises.map((p) => p.then(() => "completed" as const, () => "completed" as const))));
102
+ }
103
+ racers.push(new Promise((resolve) => setTimeout(() => resolve("timeout" as const), remaining)));
104
+ if (abortRacer) racers.push(abortRacer);
105
+
106
+ const outcome = await Promise.race(racers);
107
+ if (outcome === "timeout") timedOut = true;
108
+ else if (outcome === "aborted") aborted = true;
109
+
110
+ // "completed": at least one run promise settled — re-scan. Entries whose
111
+ // result is now available end the wait; everything else loops back under
112
+ // the same deadline.
113
+ const current = scan(getEntry, agentIds, timedOut, aborted);
114
+ if (hasResolution(current) || timedOut || aborted) return current;
115
+ }
116
+
117
+ return scan(getEntry, agentIds, timedOut, aborted);
118
+ }
@@ -0,0 +1,38 @@
1
+ {
2
+ "hooks": {
3
+ "SessionStart": [
4
+ {
5
+ "matcher": "",
6
+ "hooks": [
7
+ {
8
+ "type": "command",
9
+ "command": "echo 'Load AGENTS.md and active beads context before taking action.'"
10
+ },
11
+ {
12
+ "type": "command",
13
+ "command": "echo 'Style policy: this project ships style skills — prefer them over ad-hoc choices. Invoke style-code BEFORE writing or editing code (and when refactoring); style-tests BEFORE writing or changing tests; style-documentation BEFORE writing or updating docs; style-comms BEFORE composing commit messages, PR descriptions, tickets, or other written deliverables.'"
14
+ },
15
+ {
16
+ "type": "command",
17
+ "command": "echo 'Skill policy: agent-cortex ships skills — prefer them over ad-hoc choices. using-agent-skills routes work to the right skill (check it first when unsure); bd-tool tracks beads tasks (run bd ready for available work); git-workflow enforces branch and PR discipline. Invoke skills via the Skill tool.'"
18
+ },
19
+ {
20
+ "type": "command",
21
+ "command": "echo 'Be extremely concise. Sacrifice grammar for the sake of concision.'"
22
+ }
23
+ ]
24
+ }
25
+ ],
26
+ "Notification": [
27
+ {
28
+ "matcher": "agent_completed|agent_needs_input|permission_prompt",
29
+ "hooks": [
30
+ {
31
+ "type": "command",
32
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/scripts/notify.mjs\""
33
+ }
34
+ ]
35
+ }
36
+ ]
37
+ }
38
+ }
@@ -0,0 +1,54 @@
1
+ #!/usr/bin/env node
2
+ // Claude Code Notification hook — desktop notification on task completion/error.
3
+ //
4
+ // Reads the Notification hook JSON payload from stdin and prints a
5
+ // `{ "terminalSequence": "..." }` response for Claude Code to emit. Hooks run
6
+ // without a controlling terminal, so OSC sequences must go through Claude Code
7
+ // itself (it handles tmux/screen re-wrapping); writing /dev/tty directly fails.
8
+ //
9
+ // Mapped from the pi `notify` extension (extensions/notify/): pi notifies on
10
+ // agent_end for multi-turn or long tasks. Claude Code's Notification matcher is an
11
+ // exact-string list over `notification_type` values (there are no `success`/
12
+ // `error` types) — hooks.json selects `agent_completed|agent_needs_input|permission_prompt`
13
+ // to mirror "task completed / failed / needs approval". The payload already carries
14
+ // a message + title, so no summarization LLM call is needed here.
15
+ //
16
+ // Zero dependencies; runs on the Node that ships with Claude Code.
17
+
18
+ let raw = "";
19
+ for await (const chunk of process.stdin) {
20
+ raw += chunk;
21
+ }
22
+
23
+ let payload = {};
24
+ try {
25
+ payload = raw.trim() ? JSON.parse(raw) : {};
26
+ } catch {
27
+ process.exit(0); // never fail a session on an unparsable payload
28
+ }
29
+
30
+ const message = typeof payload.message === "string" ? payload.message.trim() : "";
31
+ if (!message) process.exit(0); // nothing worth notifying (e.g. autocompact events)
32
+
33
+ const title =
34
+ typeof payload.title === "string" && payload.title.trim() ? payload.title.trim() : "Claude Code";
35
+ const type = typeof payload.notification_type === "string" ? payload.notification_type : "";
36
+
37
+ process.stdout.write(JSON.stringify({ terminalSequence: pickSequence(title, message, type) }) + "\n");
38
+
39
+ /** Pick an OSC notification escape sequence for the detected terminal. */
40
+ function pickSequence(title, message, type) {
41
+ const body = message.replace(/\u001b/g, "").replace(/\n/g, " ").trim();
42
+ const label = type ? `${type}: ${title}` : title;
43
+
44
+ if (process.env.KITTY_WINDOW_ID) {
45
+ // Kitty: two-part persistent notification (title + body) — same form as the pi notify extension.
46
+ return `\u001b]99;i=1:d=1;${title}\u001b\\\u001b]99;i=1:p=body;${body}\u001b\\`;
47
+ }
48
+ if (process.env.TERM_PROGRAM === "iTerm.app" || process.env.ITERM_SESSION_ID) {
49
+ // iTerm2 / ConEmu / Windows Terminal / WezTerm: OSC 9.
50
+ return `\u001b]9;${label}: ${body}\u0007`;
51
+ }
52
+ // urxvt / Ghostty / Warp, and default fallback: OSC 777.
53
+ return `\u001b]777;notify;${label};${body}\u0007`;
54
+ }
package/lib/cli.mjs CHANGED
@@ -1,5 +1,15 @@
1
1
  const SUPPORTED_HARNESSES = ["copilot", "claude", "pi"];
2
2
 
3
+ // Options accepted by `install <harness>`. `--output`/`--dry-run` are shared by
4
+ // the claude and pi installers; `--plugin-root` is pi-specific. Unknown or
5
+ // malformed options surface as `optionError` on the parsed result; the CLI
6
+ // prints them and exits 1.
7
+ const INSTALL_OPTIONS = {
8
+ "--dry-run": { key: "dryRun" },
9
+ "--output": { key: "output" },
10
+ "--plugin-root": { key: "pluginRoot" },
11
+ };
12
+
3
13
  export function parseArgs(argv) {
4
14
  if (argv.length === 0 || argv[0] === "--help" || argv[0] === "-h") {
5
15
  return { command: "help" };
@@ -8,8 +18,42 @@ export function parseArgs(argv) {
8
18
  const [command, ...rest] = argv;
9
19
 
10
20
  if (command === "install") {
11
- const harness = rest[0] ?? null;
12
- return { command: "install", harness };
21
+ let harness = null;
22
+ let i = 0;
23
+ if (rest.length > 0 && !rest[0].startsWith("--")) {
24
+ harness = rest[0];
25
+ i = 1;
26
+ }
27
+
28
+ const options = {};
29
+ for (; i < rest.length; i++) {
30
+ const arg = rest[i];
31
+ // Support both `--output=<dir>` and `--output <dir>`.
32
+ const inlineValue = arg.startsWith("--output=") ? arg.slice("--output=".length) : null;
33
+ const spec = inlineValue !== null ? INSTALL_OPTIONS["--output"] : INSTALL_OPTIONS[arg];
34
+ if (!spec) {
35
+ return { command: "install", harness, optionError: `Unknown option "${arg}". Run "agent-cortex --help" for usage.` };
36
+ }
37
+ if (spec.key === "dryRun") {
38
+ options.dryRun = true;
39
+ continue;
40
+ }
41
+ if (inlineValue !== null) {
42
+ options[spec.key] = inlineValue;
43
+ continue;
44
+ }
45
+ const value = rest[i + 1];
46
+ if (value == null || value.startsWith("--")) {
47
+ return { command: "install", harness, optionError: `Missing value for "${arg}". Run "agent-cortex --help" for usage.` };
48
+ }
49
+ options[spec.key] = value;
50
+ i += 1;
51
+ }
52
+
53
+ if (Object.keys(options).length === 0) {
54
+ return { command: "install", harness };
55
+ }
56
+ return { command: "install", harness, ...options };
13
57
  }
14
58
 
15
59
  return { command: "unknown", name: command };
@@ -21,6 +65,21 @@ export function buildHelpText() {
21
65
  Commands:
22
66
  install <harness> Install agent-cortex for a harness
23
67
  Supported harnesses: ${SUPPORTED_HARNESSES.join(", ")}
68
+ Options (claude):
69
+ --output <dir> Write the plugin subtree to <dir>
70
+ (default: <package root>/claude)
71
+ --dry-run Print what would be generated without writing
72
+ Options (copilot):
73
+ --output <dir> Write the flat *.agent.md files to <dir>
74
+ (default: <package root>/agents — the dir
75
+ plugin.json "agents" scans)
76
+ --dry-run Print what would be generated without writing
77
+ Options (pi):
78
+ --dry-run Show what would be installed without writing
79
+ --output <dir> Install into <dir>/agents and <dir>/skills
80
+ (default: ~/.pi/agent)
81
+ --plugin-root <dir> Override the plugin root used for {{PATH:...}}
82
+ tokens (default: token-map.json's pi value)
24
83
  --help, -h Show this help text`;
25
84
  }
26
85
 
@@ -34,4 +93,4 @@ export function validateHarness(harness) {
34
93
  return { ok: true };
35
94
  }
36
95
 
37
- export { SUPPORTED_HARNESSES };
96
+ export { SUPPORTED_HARNESSES };
package/package.json CHANGED
@@ -1,15 +1,12 @@
1
1
  {
2
2
  "name": "@jaybeeuu/agent-cortex",
3
- "version": "1.20.0",
3
+ "version": "1.38.1",
4
4
  "description": "Personal PI package with custom agents, skills, and extensions",
5
5
  "keywords": [
6
6
  "pi-package"
7
7
  ],
8
8
  "license": "MIT",
9
- "repository": {
10
- "type": "git",
11
- "url": "https://github.com/jaybeeuu/agent-cortex"
12
- },
9
+ "repository": "https://github.com/jaybeeuu/agent-cortex",
13
10
  "bin": {
14
11
  "agent-cortex": "./bin/agent-cortex.mjs"
15
12
  },
@@ -17,6 +14,7 @@
17
14
  "access": "public"
18
15
  },
19
16
  "devDependencies": {
17
+ "@changesets/cli": "^2.31.1",
20
18
  "tsx": "^4.0.0",
21
19
  "yaml": "^2.0.0"
22
20
  },
@@ -31,9 +29,14 @@
31
29
  "scripts": {
32
30
  "build": "echo 'Build complete — no compilation needed for PI package'",
33
31
  "lint": "pnpm typecheck",
34
- "test": "pnpm -r test",
32
+ "test": "node --test test/*.test.mjs && pnpm -r test",
35
33
  "typecheck": "pnpm -r typecheck",
36
- "build:claude": "node scripts/build-claude-agents.mjs",
34
+ "build:claude": "node bin/agent-cortex.mjs install claude",
35
+ "build:copilot": "node scripts/build-copilot-agents.mjs",
36
+ "changeset": "changeset",
37
+ "version-packages": "changeset version && bash scripts/sync-plugin-version.sh && pnpm build:claude && pnpm build:copilot",
38
+ "publish-package": "TARBALL=$(pnpm pack | tail -n 1) && npm publish \"$TARBALL\" --provenance --access public",
39
+ "release": "changeset publish",
37
40
  "postinstall": "bash scripts/install-instructions.sh"
38
41
  }
39
42
  }
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.36.0",
4
+ "version": "1.38.1",
5
5
  "author": {
6
6
  "name": "jaybeeuu"
7
7
  },
@@ -1,4 +1,5 @@
1
1
  packages:
2
+ - "."
2
3
  - "skills/*/scripts"
3
4
  - "extensions/*"
4
5
  allowBuilds:
@@ -0,0 +1,30 @@
1
+ // Regenerates the committed Copilot CLI flat agent files (agents/*.agent.md)
2
+ // from the canonical composable agent sources (agents/<name>/).
3
+ //
4
+ // This script is a thin wrapper over the shared install-time generator
5
+ // (bin/installers/copilot.mjs) — `agent-cortex install copilot` runs exactly the
6
+ // same code path, so install-time and build-time output can never diverge. CI
7
+ // runs this and checks `git diff --exit-code -- 'agents/*.agent.md'` to
8
+ // guarantee the committed flat files are never stale: a fresh clone (or a
9
+ // direct path plugin install) ships the Copilot plugin read-ready.
10
+ //
11
+ // agents/<name>.agent.md composed from agents/<name>/agent.md +
12
+ // agents/<name>/copilot/frontmatter.json +
13
+ // agents/<name>/copilot/<section>.md ({{SECTION:...}}),
14
+ // with {{TOOL:...}}/{{PATH:...}} substituted against
15
+ // the copilot column of token-map.json.
16
+ //
17
+ // The flat files live in the same agents/ directory the Copilot plugin scans
18
+ // (plugin.json "agents": "agents/"), next to the composable dirs they come
19
+ // from — the generator only writes *.agent.md files and never deletes.
20
+ //
21
+ // Run: pnpm build:copilot (node scripts/build-copilot-agents.mjs)
22
+ // Zero dependencies so it runs on the CI Node (20) and local Node alike.
23
+
24
+ import { join, dirname } from "node:path";
25
+ import { fileURLToPath } from "node:url";
26
+ import { installCopilot } from "../bin/installers/copilot.mjs";
27
+
28
+ const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
29
+
30
+ installCopilot({ root: ROOT, output: join(ROOT, "agents") });
@@ -0,0 +1,241 @@
1
+ // Shared composer: reads a composable agent directory (agents/<name>/) and emits
2
+ // the harness-specific agent content defined by agents/README.md. The Copilot
3
+ // generator (scripts/build-copilot-agents.mjs) and the Claude install-time generator
4
+ // (bin/installers/claude.mjs) compose from here, so the composable dirs are
5
+ // the single source of truth and the flat/generated files stay in lockstep.
6
+ //
7
+ // Composition per the agents/README.md contract:
8
+ // 1. {{SECTION:name}} is replaced with <dir>/<harness>/<name>.md content.
9
+ // 2. {{TOOL:key}} is replaced with the harness column of token-map.json.
10
+ // 3. {{PATH:arg}} with a named key resolves through token-map.json's paths
11
+ // table; anything else is a relative path and stays verbatim.
12
+ // Substitution order is SECTION → TOOL → PATH (matches extensions/agent-modes).
13
+ // Missing sections, unknown tool keys, or schema-invalid frontmatter are hard
14
+ // errors: a committed generated file must never ship a literal token or a
15
+ // silently dropped section.
16
+ //
17
+ // Options (the pi installer passes `dropNullTools`, `pluginRoot` and
18
+ // `resolveRelativePaths`; the copilot generator and claude installer use the strict defaults):
19
+ // dropNullTools replace null-mapped tool tokens with an empty string and
20
+ // warn instead of throwing (pi's prose rule per the
21
+ // token-map contract)
22
+ // pluginRoot override the plugin_root used for resolved paths
23
+ // (installers compute it from the actual install layout)
24
+ // resolveRelativePaths non-keyed {{PATH:...}} args resolve to pluginRoot/arg
25
+ // instead of staying verbatim (install time; the build
26
+ // scripts leave them bare for Copilot CLI resolution)
27
+ // warn callback for drop/omit warnings (default: no-op; the
28
+ // pi installer collects them into its result)
29
+ //
30
+ // Zero dependencies so it runs on the CI Node (20) and local Node alike.
31
+
32
+ import { readFileSync, existsSync } from "node:fs";
33
+ import { join } from "node:path";
34
+
35
+ // ─── Token substitution ──────────────────────────────────────────────────────
36
+
37
+ const TOOL_RE = /\{\{TOOL:([^}]+)\}\}/g;
38
+ const PATH_RE = /\{\{PATH:([^}]+)\}\}/g;
39
+
40
+ function noop() {}
41
+
42
+ /**
43
+ * Substitute {{TOOL:...}} and {{PATH:...}} tokens in arbitrary content (agent
44
+ * bodies, skill files) against one harness column of token-map.json.
45
+ *
46
+ * - TOOL: unknown key → throw (the map must stay complete); null mapping → drop
47
+ * with a warning when `dropNullTools`, otherwise throw.
48
+ * - PATH: named key → resolve through the paths table (plugin_root overridable);
49
+ * non-keyed → join(pluginRoot, arg) when `resolveRelativePaths`, else verbatim.
50
+ * - SECTION tokens are *not* handled here — the agent composer resolves them from
51
+ * the harness's section files before calling this.
52
+ *
53
+ * @param {string} content Source text containing {{TOOL:...}}/{{PATH:...}} tokens
54
+ * @param {string} harness Harness id: "copilot" | "claude" | "pi"
55
+ * @param {object} tokenMap Parsed token-map.json (from loadTokenMap)
56
+ * @param {object} options { dropNullTools, pluginRoot, resolveRelativePaths, warn, context }
57
+ * @returns {string}
58
+ */
59
+ export function substituteTokens(content, harness, tokenMap, options = {}) {
60
+ const { dropNullTools = false, pluginRoot, resolveRelativePaths = false, warn = noop, context = "" } = options;
61
+ const ctx = context ? `${context}: ` : "";
62
+
63
+ // 2. {{TOOL:key}} → token-map.json tools.<key>.<harness>
64
+ content = content.replace(TOOL_RE, (_token, key) => {
65
+ const entry = tokenMap.tools[key];
66
+ if (!entry) throw new Error(`${ctx}unknown tool token {{TOOL:${key}}} — extend token-map.json`);
67
+ const mapped = entry[harness];
68
+ if (mapped == null) {
69
+ if (dropNullTools) {
70
+ warn(`tool token {{TOOL:${key}}} has no ${harness} mapping — dropped`);
71
+ return "";
72
+ }
73
+ throw new Error(`${ctx}tool token {{TOOL:${key}}} has no ${harness} mapping (null) — drop the token in agent.md first`);
74
+ }
75
+ return mapped;
76
+ });
77
+
78
+ // 3. {{PATH:arg}} → named keys via token-map.json paths; relative paths verbatim
79
+ // (or resolved against pluginRoot when the installer option is set)
80
+ content = content.replace(PATH_RE, (_token, arg) =>
81
+ resolvePath(arg, harness, tokenMap, { pluginRoot, resolveRelativePaths }),
82
+ );
83
+
84
+ return content;
85
+ }
86
+
87
+ /**
88
+ * Compose the harness-specific agent content from a composable agent directory.
89
+ *
90
+ * @param {string} root Package root (contains agents/ and token-map.json)
91
+ * @param {string} name Agent directory name (e.g. "ralph", "plan")
92
+ * @param {string} harness Harness id: "copilot" | "claude" | "pi"
93
+ * @returns {{ name: string, description: string, tools: string[], model?: string, argumentHint?: string, body: string }}
94
+ */
95
+ export function composeAgent(root, name, harness, options = {}) {
96
+ const { dropNullTools = false, pluginRoot, resolveRelativePaths = false, warn = noop } = options;
97
+ const agentDir = join(root, "agents", name);
98
+ const frontmatter = parseFrontmatter(join(agentDir, harness, "frontmatter.json"), name, harness);
99
+ const tokenMap = loadTokenMap(root);
100
+
101
+ let body = readFileSync(join(agentDir, "agent.md"), "utf-8");
102
+
103
+ // 1. {{SECTION:name}} → <harness>/<name>.md (may itself contain TOOL/PATH tokens)
104
+ body = body.replace(/\{\{SECTION:([^}]+)\}\}/g, (_token, section) => {
105
+ const sectionPath = join(agentDir, harness, `${section}.md`);
106
+ if (!existsSync(sectionPath)) {
107
+ throw new Error(`agent "${name}": missing section "${section}" for harness "${harness}" (${sectionPath})`);
108
+ }
109
+ return readFileSync(sectionPath, "utf-8").trim();
110
+ });
111
+
112
+ // 2. + 3. TOOL/PATH substitution (contextualised for error messages)
113
+ body = substituteTokens(body, harness, tokenMap, {
114
+ dropNullTools,
115
+ pluginRoot,
116
+ resolveRelativePaths,
117
+ warn,
118
+ context: `agent "${name}"`,
119
+ });
120
+
121
+ return { ...frontmatter, body: body.trim() };
122
+ }
123
+
124
+ // ─── Frontmatter ─────────────────────────────────────────────────────────────
125
+
126
+ const REQUIRED_FIELDS = ["name", "description", "tools"];
127
+ const KNOWN_OPTIONAL_FIELDS = ["model", "argumentHint"];
128
+
129
+ /** 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");
132
+ let fm;
133
+ try {
134
+ fm = JSON.parse(raw);
135
+ } catch (err) {
136
+ throw new Error(`agent "${name}": invalid JSON in ${path}: ${err.message}`);
137
+ }
138
+ if (typeof fm !== "object" || fm === null) {
139
+ throw new Error(`agent "${name}": ${path} must be a JSON object`);
140
+ }
141
+ for (const field of REQUIRED_FIELDS) {
142
+ if (!(field in fm)) throw new Error(`agent "${name}": ${path} missing required field "${field}" (harness ${harness})`);
143
+ }
144
+ if (typeof fm.name !== "string" || fm.name.trim() === "") throw new Error(`agent "${name}": ${path} name must be a non-empty string`);
145
+ if (typeof fm.description !== "string" || fm.description.trim() === "") throw new Error(`agent "${name}": ${path} description must be a non-empty string`);
146
+ if (!Array.isArray(fm.tools) || fm.tools.length === 0 || !fm.tools.every((t) => typeof t === "string")) {
147
+ throw new Error(`agent "${name}": ${path} tools must be a non-empty array of strings`);
148
+ }
149
+ for (const key of Object.keys(fm)) {
150
+ if (![...REQUIRED_FIELDS, ...KNOWN_OPTIONAL_FIELDS].includes(key)) {
151
+ throw new Error(`agent "${name}": ${path} unknown field "${key}" — strict schema per agents/README.md`);
152
+ }
153
+ }
154
+ return {
155
+ name: fm.name,
156
+ description: fm.description,
157
+ tools: fm.tools,
158
+ ...(typeof fm.model === "string" ? { model: fm.model } : {}),
159
+ ...(typeof fm.argumentHint === "string" ? { argumentHint: fm.argumentHint } : {}),
160
+ };
161
+ }
162
+
163
+ // ─── Path resolution ─────────────────────────────────────────────────────────
164
+
165
+ /**
166
+ * Resolve a {{PATH:...}} argument for a harness's generated output.
167
+ * Named keys resolve through token-map.json's paths table (recursively for
168
+ * {base, relative} specs); non-keyed args are relative paths, kept verbatim —
169
+ * the installer beads own precise runtime path resolution (see token-map.README.md).
170
+ * With the installer options (`pluginRoot`, `resolveRelativePaths`) the
171
+ * plugin_root base and bare relative paths resolve against the actual install
172
+ * layout instead.
173
+ */
174
+ function resolvePath(arg, harness, tokenMap, opts = {}) {
175
+ const entry = tokenMap.paths?.[arg];
176
+ if (!entry) {
177
+ if (opts.resolveRelativePaths && opts.pluginRoot) return join(opts.pluginRoot, arg);
178
+ return arg;
179
+ }
180
+
181
+ const spec = entry[harness];
182
+ if (typeof spec === "string") {
183
+ if (arg === "plugin_root" && opts.pluginRoot) return opts.pluginRoot;
184
+ return spec;
185
+ }
186
+ if (spec && typeof spec === "object" && typeof spec.base === "string" && typeof spec.relative === "string") {
187
+ return join(resolvePath(spec.base, harness, tokenMap, opts), spec.relative);
188
+ }
189
+ throw new Error(`unresolvable {{PATH:${arg}}} for harness "${harness}" — extend token-map.json`);
190
+ }
191
+
192
+ /**
193
+ * Translate a frontmatter tool list into one harness's tool names.
194
+ *
195
+ * Canonical keys resolve through token-map.json's tools table; a null mapping
196
+ * means the harness has no equivalent and the entry is omitted (the contract's
197
+ * tool-list rule); a name that is not a canonical key passes through untouched
198
+ * (it may be a native tool, e.g. pi's fetch_content). Mirrors the runtime
199
+ * translation in extensions/agent-modes/discover.ts.
200
+ *
201
+ * @param {string[]} tools Canonical tool names from a harness frontmatter.json
202
+ * @param {object} tokenMap Parsed token-map.json (from loadTokenMap)
203
+ * @param {string} harness Harness id: "copilot" | "claude" | "pi"
204
+ * @param {object} options { warn }
205
+ * @returns {string[]}
206
+ */
207
+ export function translateToolList(tools, tokenMap, harness, { warn = noop } = {}) {
208
+ const out = [];
209
+ for (const tool of tools) {
210
+ const mapping = tokenMap.tools[tool];
211
+ if (mapping === undefined) {
212
+ out.push(tool);
213
+ continue;
214
+ }
215
+ const mapped = mapping[harness];
216
+ if (mapped == null) {
217
+ warn(`tool "${tool}" has no ${harness} mapping — omitted from the tool list`);
218
+ continue;
219
+ }
220
+ out.push(mapped);
221
+ }
222
+ return out;
223
+ }
224
+
225
+ // ─── Token map ───────────────────────────────────────────────────────────────
226
+
227
+ /** Load token-map.json from the package root; missing/incomplete maps hard-fail. */
228
+ export function loadTokenMap(root) {
229
+ const path = join(root, "token-map.json");
230
+ let raw;
231
+ try {
232
+ raw = readFileSync(path, "utf-8");
233
+ } catch {
234
+ throw new Error(`missing ${path} — the composer requires token-map.json`);
235
+ }
236
+ const parsed = JSON.parse(raw);
237
+ if (!parsed.tools || !parsed.paths) {
238
+ throw new Error(`${path}: expected "tools" and "paths" tables`);
239
+ }
240
+ return parsed;
241
+ }
@@ -0,0 +1,17 @@
1
+ #!/usr/bin/env bash
2
+ # Sync plugin.json version with package.json
3
+ # Called by changesets after versioning to keep them in lockstep
4
+
5
+ set -euo pipefail
6
+
7
+ VERSION=$(node -p "require('./package.json').version")
8
+
9
+ # Update plugin.json version field
10
+ node -e "
11
+ const fs = require('fs');
12
+ const plugin = JSON.parse(fs.readFileSync('plugin.json', 'utf8'));
13
+ plugin.version = '$VERSION';
14
+ fs.writeFileSync('plugin.json', JSON.stringify(plugin, null, 2) + '\n');
15
+ "
16
+
17
+ echo "✓ Synced plugin.json to version $VERSION"
@@ -23,7 +23,7 @@ Ask: "What does this module need to do? Who will use it?"
23
23
 
24
24
  ### 2. Generate Designs (Parallel Sub-Agents)
25
25
 
26
- Spawn 3+ sub-agents simultaneously using Task tool. Each must produce a **radically different** approach.
26
+ Spawn 3+ sub-agents simultaneously using {{TOOL:task}}. Each must produce a **radically different** approach.
27
27
 
28
28
  ```
29
29
  Prompt template for each sub-agent:
@@ -13,7 +13,7 @@ A **deep module** (John Ousterhout, "A Philosophy of Software Design") has a sma
13
13
 
14
14
  ### 1. Explore the codebase
15
15
 
16
- Explore the codebase using your available tools (bash, rg, view, glob, find). Navigate organically and note where you experience friction:
16
+ Explore the codebase using your available tools ({{TOOL:bash}}, {{TOOL:rg}}, {{TOOL:view}}, {{TOOL:glob}}). Navigate organically and note where you experience friction:
17
17
 
18
18
  - Where does understanding one concept require bouncing between many small files?
19
19
  - Where are modules so shallow that the interface is nearly as complex as the implementation?