@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
@@ -10,7 +10,7 @@ Detailed procedures for the fleet orchestration workflow. See [SKILL.md](./SKILL
10
10
  2. **Read its `stage:*` label** from `bd show <id>`.
11
11
  3. **Load the universal stage runner prompt** from `skills/workflow/run-pipeline-stage/prompts/stage-runner.md`.
12
12
  4. **Read the parent task context**: follow the `parent-child` dependency to the parent task bead, run `bd show <parent-id>` to get the full task description.
13
- 5. **Ensure branch + worktree exist for this parent task** (see _Feature branches and worktrees_ below), then note the worktree path — you'll pass it as `cwd` to `task`.
13
+ 5. **Ensure branch + worktree exist for this parent task** (see _Feature branches and worktrees_ below), then note the worktree path — you'll pass it as `cwd` to {{TOOL:task}}.
14
14
  6. **Fill in the prompt** — replace placeholders with:
15
15
  - Stage from bead label (`stage:<stage>`) for `<stage>`
16
16
  - `bd prime` output (held in memory from initialization)
@@ -18,7 +18,7 @@ Detailed procedures for the fleet orchestration workflow. See [SKILL.md](./SKILL
18
18
  - For `fix` stage: `FILES_CHANGED` from the preceding stage's report (the required changes come from the fix bead's own description — read via `bd show <fix-id>`)
19
19
  - For `verify`, `review`, `document` stages: `SUMMARY` and `FILES_CHANGED` from the preceding stage's report
20
20
  - Bead ID and log file path (`.agent-cortex/ralph/ralph-<bead-id>.log`)
21
- 7. **Call the `task` tool** with the filled prompt as `prompt` and the worktree path as `cwd`. Store the returned agentId — you need it for step 8 and for log polling.
21
+ 7. **Call the {{TOOL:task}} tool** with the filled prompt as `prompt` and the worktree path as `cwd`. Store the returned agentId — you need it for step 8 and for log polling.
22
22
  8. **Map** the agent ID to the bead ID in memory for lookup on completion.
23
23
 
24
24
  ---
@@ -35,7 +35,7 @@ Run once at startup:
35
35
  - **Task or other type without an `implementation-type:*` label**: unexpected here — classification is a planning-stage concern already applied by `create-task`. Do not invoke `classify-bead`; treat as needs-refinement and note for the shutdown summary.
36
36
  - **Task labelled `implementation-type:hitl`**: skip — record the bead ID for the **Pending Human Action** summary at shutdown.
37
37
  7. For each ready chore bead (up to 5), **dispatch** it (see _Dispatching a chore bead_ in REFERENCE.md). Chores for a parent bead must run from that parent's worktree.
38
- 8. **If** any chore beads were dispatched in step 7, start the **poll timer**: run `sleep 120` as a background bash process and hold its shellId in memory. **Otherwise**, if HITL gate beads are pending (check `bd list -l lifecycle:feature-pr -l implementation-type:hitl` and `bd list -l awaiting-epic-pr-merge`), proceed to **HITL Pause** (see REFERENCE.md) immediately.
38
+ 8. **If** any chore beads were dispatched in step 7, start the **poll timer** (Copilot only): run `sleep 120` as a background bash process and hold its shellId in memory. PI has no timer — keep a `wait_for_agents` call outstanding over every in-flight agent ID instead (see the Event Loop note). **Otherwise**, if HITL gate beads are pending (check `bd list -l lifecycle:feature-pr -l implementation-type:hitl` and `bd list -l awaiting-epic-pr-merge`), proceed to **HITL Pause** (see REFERENCE.md) immediately.
39
39
  9. Regenerate `.agent-cortex/ralph/progress.md`:
40
40
  ```bash
41
41
  # workspace must be the absolute path you cd'd into — never . or $(pwd)
@@ -47,7 +47,7 @@ Run once at startup:
47
47
 
48
48
  ## Event Loop
49
49
 
50
- After initialization, wait for background agents or the poll timer to complete. On each completion notification:
50
+ After initialization, wait for background agents or the poll timer to complete. Completion detection is harness-specific: the `sleep 120` poll timer described below is the **Copilot** mechanism. **PI** replaces it with the blocking `wait_for_agents` tool — it returns as soon as any in-flight agent finishes, so each return plays the role of a poll-timer tick with its completed-agent result attached; there is no timer to start or kill on PI. On each completion notification:
51
51
 
52
52
  ### Poll timer completed
53
53
 
@@ -59,7 +59,7 @@ After initialization, wait for background agents or the poll timer to complete.
59
59
  ### Background agent completed
60
60
 
61
61
  1. **Poll that bead's log** to flush any final lines.
62
- 2. **Read** the completed agent's full output with `read_agent`.
62
+ 2. **Read** the completed agent's full output with {{TOOL:read_agent}} (on PI the completed result arrives in the `wait_for_agents` response; read_agent remains a fallback for a specific result).
63
63
  3. **Identify the bead**: parse the `---REPORT---` block for `BEAD_ID` and `STAGE_COMPLETED`.
64
64
  4. **Close** the chore bead: `bd close <bead-id>`.
65
65
  5. **Handle stage outcome**:
@@ -90,10 +90,10 @@ After initialization, wait for background agents or the poll timer to complete.
90
90
  - **Always** run in **foreground** (interactive) mode. If you find yourself executing as a background task, immediately surface a warning to the user and ask them to re-run you in foreground mode.
91
91
  - **Never** write, edit, or create source code or documentation yourself.
92
92
  - **Never** edit bead task files directly — only use `bd` commands.
93
- - **ALWAYS call the `task` tool to spawn subagents** — never use bash, never run stages inline, never use any other tool. This is your only spawning mechanism.
93
+ - **ALWAYS call the {{TOOL:task}} tool to spawn subagents** — never use {{TOOL:bash}}, never run stages inline, never use any other tool. This is your only spawning mechanism.
94
94
  - **Always** derive orchestration state from beads — do not maintain a separate state file.
95
- - **Always** include the full `bd prime` output verbatim in every `task` tool prompt.
96
- - **Always** keep the poll timer running — restart it immediately after it fires — **unless** the HITL pause condition is met (no chores in-flight, no `stage:*` chores ready, HITL gate beads pending), in which case proceed to **HITL Pause** (see REFERENCE.md) and stop instead.
95
+ - **Always** include the full `bd prime` output verbatim in every {{TOOL:task}} tool prompt.
96
+ - **Always** keep the poll timer running (Copilot) — restart it immediately after it fires — **unless** the HITL pause condition is met (no chores in-flight, no `stage:*` chores ready, HITL gate beads pending), in which case proceed to **HITL Pause** (see REFERENCE.md) and stop instead. On PI there is no timer to keep alive — always keep one `wait_for_agents` call outstanding while agents are in-flight.
97
97
  - **Never** post empty poll updates to chat — only surface new log content.
98
98
  - **Max 5** tasks in-flight at once.
99
99
  - **Max fix rounds** per task as defined by `maxFixRounds` in `skills/planning/create-task/pipeline.json`.
@@ -50,8 +50,10 @@ const STATUS_CHAR_MAP: Record<string, BeadStatus> = {
50
50
  export function parseListLine(
51
51
  line: string,
52
52
  ): { id: string; status: BeadStatus; title: string } | null {
53
- // Format: {STATUS_CHAR} {ID} ● P{N} {TITLE}
54
- const match = line.trim().match(/^([○◐●✓❄])\s+(\S+)\s+●\s+P\d+\s+(.+)$/);
53
+ // Formats accepted:
54
+ // {STATUS_CHAR} {ID} P{N} {TITLE} — current bd list output
55
+ // {STATUS_CHAR} {ID} ● P{N} {TITLE} — older bd (● before P{N} is optional)
56
+ const match = line.trim().match(/^([○◐●✓❄])\s+(\S+)\s+(?:●\s+)?P\d+\s+(.+)$/);
55
57
  if (!match) return null;
56
58
  const [, statusChar, id, title] = match;
57
59
  const status = STATUS_CHAR_MAP[statusChar];
@@ -28,9 +28,11 @@ Task arrives
28
28
  ├── Running beads / task execution? ────→ ralph
29
29
  │ └── Single stage? ─────────────────→ run-pipeline-stage
30
30
  ├── Implementing code? ────────────────→ tdd
31
- │ └── Designing an interface? ───────→ design-an-interface
31
+ │ ├── Designing an interface? ───────→ design-an-interface
32
+ │ └── Need style conventions? ───────→ style-code
32
33
  ├── Reviewing code? ───────────────────→ style-code
33
- │ └── Security concerns? ────────────→ review-security
34
+ │ ├── Security concerns? ────────────→ review-security
35
+ │ └── Reviewing tests? ──────────────→ style-tests
34
36
  ├── Writing tests? ────────────────────→ style-tests
35
37
  ├── Writing docs? ─────────────────────→ style-documentation
36
38
  ├── Writing external comms? ───────────→ style-comms
@@ -76,6 +78,14 @@ Touch only what you're asked to touch. Do not clean up orthogonal code, refactor
76
78
 
77
79
  A task is not complete until verification passes. "Seems right" is never sufficient — there must be evidence: passing tests, build output, or runtime data.
78
80
 
81
+ ### 7. Flag Persistent Failures
82
+
83
+ When a tool or command fails repeatedly, stop and flag it to the user for investigation. Don't paper over failures with workarounds — workarounds waste tokens and hide real problems. Describe what failed, how many times, and any obvious patterns. Let the user decide whether to investigate in a dedicated session. Inefficiencies and failures should be squashed, not hidden.
84
+
85
+ ### 8. Stay On Target
86
+
87
+ Stick to the task at hand. If you discover work that's outside the current scope — a tangential bug, an adjacent improvement, a missing doc — flag it to the user but don't pursue it. If the side quest blocks or slows your progress, stop and report it. Otherwise, note it and get back on track. Scope creep mid-task burns tokens and dilutes focus.
88
+
79
89
  ## Lifecycle Sequence
80
90
 
81
91
  For a complete feature, the typical skill sequence is:
@@ -100,6 +110,9 @@ Not every task needs every skill. A bug fix might only need: `tdd` → `style-co
100
110
  - Making wrong assumptions without stating them.
101
111
  - Overcomplicating code when a simple solution exists.
102
112
  - Modifying code orthogonal to the task scope.
113
+ - Going on side-quests instead of staying on target.
114
+ - Going on side-quests instead of staying on target.
115
+ - Going on side-quests instead of staying on target.
103
116
 
104
117
  ## Verification checklist
105
118
 
package/test/cli.test.mjs CHANGED
@@ -1,6 +1,8 @@
1
1
  import { describe, it } from "node:test";
2
2
  import assert from "node:assert/strict";
3
3
  import { execFile } from "node:child_process";
4
+ import { mkdtempSync, readFileSync, rmSync } from "node:fs";
5
+ import { tmpdir } from "node:os";
4
6
  import { fileURLToPath } from "node:url";
5
7
  import { dirname, join } from "node:path";
6
8
  import { parseArgs, buildHelpText, validateHarness } from "../lib/cli.mjs";
@@ -56,6 +58,84 @@ describe("parseArgs", () => {
56
58
  assert.deepStrictEqual(result, { command: "install", harness: null });
57
59
  });
58
60
 
61
+ it("returns output for install claude --output <dir>", () => {
62
+ const result = parseArgs(["install", "claude", "--output", "/tmp/x"]);
63
+ assert.deepStrictEqual(result, { command: "install", harness: "claude", output: "/tmp/x" });
64
+ });
65
+
66
+ it("returns output for install claude --output=<dir>", () => {
67
+ const result = parseArgs(["install", "claude", "--output=/tmp/x"]);
68
+ assert.deepStrictEqual(result, { command: "install", harness: "claude", output: "/tmp/x" });
69
+ });
70
+
71
+ it("returns dryRun for install claude --dry-run", () => {
72
+ const result = parseArgs(["install", "claude", "--dry-run"]);
73
+ assert.deepStrictEqual(result, { command: "install", harness: "claude", dryRun: true });
74
+ });
75
+
76
+ it("combines --dry-run with --output", () => {
77
+ const result = parseArgs(["install", "claude", "--output", "/tmp/x", "--dry-run"]);
78
+ assert.deepStrictEqual(result, { command: "install", harness: "claude", output: "/tmp/x", dryRun: true });
79
+ });
80
+
81
+ it("reports an unknown option for claude", () => {
82
+ const result = parseArgs(["install", "claude", "--bogus"]);
83
+ assert.equal(result.command, "install");
84
+ assert.equal(result.harness, "claude");
85
+ assert.ok(result.optionError.includes("--bogus"));
86
+ });
87
+
88
+ it("reports a missing value for --output for claude", () => {
89
+ const result = parseArgs(["install", "claude", "--output"]);
90
+ assert.equal(result.command, "install");
91
+ assert.equal(result.harness, "claude");
92
+ assert.ok(result.optionError.includes("--output"));
93
+ });
94
+
95
+ it("parses --dry-run for install", () => {
96
+ const result = parseArgs(["install", "pi", "--dry-run"]);
97
+ assert.deepStrictEqual(result, { command: "install", harness: "pi", dryRun: true });
98
+ });
99
+
100
+ it("parses --output <dir> for install", () => {
101
+ const result = parseArgs(["install", "pi", "--output", "/tmp/out"]);
102
+ assert.deepStrictEqual(result, { command: "install", harness: "pi", output: "/tmp/out" });
103
+ });
104
+
105
+ it("parses --plugin-root <dir> for install", () => {
106
+ const result = parseArgs(["install", "pi", "--plugin-root", "/tmp/plugin"]);
107
+ assert.deepStrictEqual(result, { command: "install", harness: "pi", pluginRoot: "/tmp/plugin" });
108
+ });
109
+
110
+ it("combines multiple install options", () => {
111
+ const result = parseArgs(["install", "pi", "--dry-run", "--output", "/tmp/out", "--plugin-root", "/tmp/plugin"]);
112
+ assert.deepStrictEqual(result, {
113
+ command: "install",
114
+ harness: "pi",
115
+ dryRun: true,
116
+ output: "/tmp/out",
117
+ pluginRoot: "/tmp/plugin",
118
+ });
119
+ });
120
+
121
+ it("reports a missing value for --output", () => {
122
+ const result = parseArgs(["install", "pi", "--output"]);
123
+ assert.equal(result.command, "install");
124
+ assert.equal(result.harness, "pi");
125
+ assert.ok(result.optionError.includes("--output"));
126
+ });
127
+
128
+ it("reports an unknown option", () => {
129
+ const result = parseArgs(["install", "pi", "--bogus"]);
130
+ assert.equal(result.command, "install");
131
+ assert.equal(result.harness, "pi");
132
+ assert.ok(result.optionError.includes("--bogus"));
133
+ });
134
+
135
+ it("keeps the old shape when no options are given", () => {
136
+ assert.deepStrictEqual(parseArgs(["install", "pi"]), { command: "install", harness: "pi" });
137
+ });
138
+
59
139
  it("returns { command: 'unknown', name: 'foo' } for unknown command", () => {
60
140
  const result = parseArgs(["foo"]);
61
141
  assert.deepStrictEqual(result, { command: "unknown", name: "foo" });
@@ -117,8 +197,9 @@ describe("CLI integration", () => {
117
197
  assert.ok(stdout.includes("copilot"));
118
198
  });
119
199
 
120
- it("exits 0 for install claude", async () => {
121
- const { exitCode, stdout } = await runCli(["install", "claude"]);
200
+ it("exits 0 for install claude (into a temp output dir)", async () => {
201
+ const dir = mkdtempSync(join(tmpdir(), "agent-cortex-cli-"));
202
+ const { exitCode, stdout } = await runCli(["install", "claude", "--output", dir]);
122
203
  assert.equal(exitCode, 0);
123
204
  assert.ok(stdout.includes("claude"));
124
205
  });
@@ -135,6 +216,38 @@ describe("CLI integration", () => {
135
216
  assert.ok(stderr.includes("Missing harness"));
136
217
  });
137
218
 
219
+ it("exits 1 and prints error for install with an unknown option", async () => {
220
+ const { exitCode, stderr } = await runCli(["install", "pi", "--bogus"]);
221
+ assert.equal(exitCode, 1);
222
+ assert.ok(stderr.includes("--bogus"));
223
+ });
224
+
225
+ it("exits 1 and prints error for install with a missing option value", async () => {
226
+ const { exitCode, stderr } = await runCli(["install", "pi", "--output"]);
227
+ assert.equal(exitCode, 1);
228
+ assert.ok(stderr.includes("--output"));
229
+ });
230
+
231
+ it("exits 0 for install pi --dry-run and writes nothing", async () => {
232
+ const { exitCode, stdout } = await runCli(["install", "pi", "--dry-run"]);
233
+ assert.equal(exitCode, 0);
234
+ assert.ok(stdout.includes("ralph.agent.md"));
235
+ assert.ok(stdout.includes("dry-run"));
236
+ });
237
+
238
+ it("exits 0 for install pi --output <tmp> and writes agent files", async () => {
239
+ const outDir = mkdtempSync(join(tmpdir(), "cli-pi-install-"));
240
+ try {
241
+ const { exitCode, stdout } = await runCli(["install", "pi", "--output", outDir]);
242
+ assert.equal(exitCode, 0);
243
+ assert.ok(stdout.includes("ralph.agent.md"));
244
+ const ralph = readFileSync(join(outDir, "agents", "ralph.agent.md"), "utf-8");
245
+ assert.ok(ralph.includes("agent-cortex:ralph"));
246
+ } finally {
247
+ rmSync(outDir, { recursive: true, force: true });
248
+ }
249
+ });
250
+
138
251
  it("exits 1 and prints error for install with unknown harness", async () => {
139
252
  const { exitCode, stderr } = await runCli(["install", "docker"]);
140
253
  assert.equal(exitCode, 1);
@@ -0,0 +1,298 @@
1
+ // Integration tests for `agent-cortex install copilot`: the install-time
2
+ // generator for the Copilot CLI flat agent files (agents/*.agent.md). All tests
3
+ // install into mkdtemp dirs so the real repo agents/ is never touched.
4
+
5
+ import { describe, it } from "node:test";
6
+ import assert from "node:assert/strict";
7
+ import { execFile } from "node:child_process";
8
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync, existsSync, readdirSync, statSync } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+ import { fileURLToPath } from "node:url";
11
+ import { join, dirname } from "node:path";
12
+ import { installCopilot } from "../bin/installers/copilot.mjs";
13
+
14
+ const __dirname = dirname(fileURLToPath(import.meta.url));
15
+ const ROOT = join(__dirname, "..");
16
+ const CLI_PATH = join(ROOT, "bin", "agent-cortex.mjs");
17
+
18
+ function runCli(args) {
19
+ return new Promise((resolve) => {
20
+ execFile(process.execPath, [CLI_PATH, ...args], (error, stdout, stderr) => {
21
+ resolve({ exitCode: error ? error.code ?? 1 : 0, stdout, stderr });
22
+ });
23
+ });
24
+ }
25
+
26
+ // ─── Fixtures ────────────────────────────────────────────────────────────────
27
+
28
+ function tokenMap() {
29
+ return {
30
+ name: "fixture token map",
31
+ version: 1,
32
+ harnesses: ["copilot"],
33
+ tools: {
34
+ bash: { copilot: "bash" },
35
+ view: { copilot: "view" },
36
+ task: { copilot: "task" },
37
+ ask_user: { copilot: "ask_user" },
38
+ },
39
+ paths: {
40
+ plugin_root: { copilot: "~/.copilot/installed-plugins/_direct/agent-cortex" },
41
+ agents_dir: { copilot: { base: "plugin_root", relative: "agents" } },
42
+ },
43
+ };
44
+ }
45
+
46
+ function makeFixture() {
47
+ const root = mkdtempSync(join(tmpdir(), "copilot-installer-"));
48
+ return {
49
+ root,
50
+ output: join(root, "generated"),
51
+ cleanup: () => rmSync(root, { recursive: true, force: true }),
52
+ };
53
+ }
54
+
55
+ function writeFixture(fx, relPath, content) {
56
+ const p = join(fx.root, relPath);
57
+ mkdirSync(dirname(p), { recursive: true });
58
+ writeFileSync(p, content);
59
+ }
60
+
61
+ /** Seed a minimal but realistic package tree (agents + token-map). */
62
+ function seedCopilotPackage(fx, opts = {}) {
63
+ const { agents = {} } = opts;
64
+ writeFixture(fx, "token-map.json", JSON.stringify(tokenMap()));
65
+ for (const [name, def] of Object.entries(agents)) {
66
+ writeFixture(fx, `agents/${name}/agent.md`, def.body ?? `# ${name}\nBody of ${name}.`);
67
+ writeFixture(
68
+ fx,
69
+ `agents/${name}/copilot/frontmatter.json`,
70
+ JSON.stringify(
71
+ def.frontmatter ?? {
72
+ name: `agent-cortex:${name}`,
73
+ description: `Description of ${name}`,
74
+ tools: ["bash", "view"],
75
+ argumentHint: `Hint for ${name}`,
76
+ },
77
+ ),
78
+ );
79
+ for (const [section, content] of Object.entries(def.sections ?? {})) {
80
+ writeFixture(fx, `agents/${name}/copilot/${section}.md`, content);
81
+ }
82
+ }
83
+ }
84
+
85
+ /** Deterministic snapshot of a directory: relative paths + byte content. */
86
+ function snapshot(dir) {
87
+ const entries = [];
88
+ const walk = (p) => {
89
+ for (const e of readdirSync(p, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
90
+ const full = join(p, e.name);
91
+ if (e.isDirectory()) {
92
+ entries.push([e.name, "dir"]);
93
+ walk(full);
94
+ } else entries.push([e.name, "file", readFileSync(full, "utf-8")]);
95
+ }
96
+ };
97
+ walk(dir);
98
+ return entries;
99
+ }
100
+
101
+ // ─── Agent composition ───────────────────────────────────────────────────────
102
+
103
+ describe("installCopilot — agent composition", () => {
104
+ it("writes one flat <name>.agent.md per composable agent dir with copilot frontmatter and no leftover tokens", () => {
105
+ const fx = makeFixture();
106
+ try {
107
+ seedCopilotPackage(fx, {
108
+ agents: {
109
+ alpha: {
110
+ body: "# alpha\nUse {{TOOL:view}} to read.\n{{SECTION:polling}}\nRead {{PATH:agents_dir}}.",
111
+ sections: { polling: "Spawn with {{TOOL:task}}." },
112
+ frontmatter: {
113
+ name: "agent-cortex:alpha",
114
+ description: "Reads things.",
115
+ tools: ["bash", "view"],
116
+ argumentHint: "Read something",
117
+ },
118
+ },
119
+ },
120
+ });
121
+
122
+ const result = installCopilot({ root: fx.root, output: fx.output });
123
+
124
+ const file = join(fx.output, "alpha.agent.md");
125
+ assert.ok(existsSync(file), "flat agent file written");
126
+ const content = readFileSync(file, "utf-8");
127
+ assert.ok(content.startsWith("---\n"), "yaml frontmatter");
128
+ assert.ok(content.includes("# GENERATED from agents/alpha/ by scripts/build-copilot-agents.mjs — DO NOT EDIT."));
129
+ // Committed flat-file field order: description, name, tools (YAML array), argument-hint.
130
+ const order = ["description:", "name: \"agent-cortex:alpha\"", "tools: [", "argument-hint:"].map((s) => content.indexOf(s));
131
+ assert.ok(order.every((i) => i !== -1), "all frontmatter fields present");
132
+ assert.deepEqual([...order].sort((a, b) => a - b), order, "frontmatter fields in committed order");
133
+ assert.match(content, /tools: \["bash", "view"\]/, "copilot tools kept as a YAML array (no translation)");
134
+ assert.ok(content.includes("Use view to read."), "{{TOOL:view}} substituted");
135
+ assert.ok(content.includes("Spawn with task."), "{{SECTION:polling}} composed from copilot/polling.md");
136
+ assert.ok(!/{{/.test(content), "no literal tokens remain");
137
+ assert.equal(result.agents.length, 1);
138
+ assert.deepEqual(result.agents, ["alpha"]);
139
+ assert.equal(result.output, fx.output);
140
+ } finally {
141
+ fx.cleanup();
142
+ }
143
+ });
144
+
145
+ it("skips non-composable entries in agents/ and never clobbers composable sources", () => {
146
+ const fx = makeFixture();
147
+ try {
148
+ seedCopilotPackage(fx, { agents: { alpha: { body: "# alpha" } } });
149
+ writeFixture(fx, "agents/README.md", "# agents dir docs\n");
150
+
151
+ const result = installCopilot({ root: fx.root, output: fx.output });
152
+
153
+ assert.deepEqual(readdirSync(fx.output).sort(), ["alpha.agent.md"], "no README, no subdirectories written");
154
+ assert.deepEqual(result.agents, ["alpha"]);
155
+ // The composable source dir next to the generated file stays untouched.
156
+ assert.ok(existsSync(join(fx.root, "agents", "alpha", "agent.md")), "composable source preserved");
157
+ assert.ok(existsSync(join(fx.root, "agents", "alpha", "copilot", "frontmatter.json")), "harness dir preserved");
158
+ } finally {
159
+ fx.cleanup();
160
+ }
161
+ });
162
+
163
+ it("throws when a composable dir lacks copilot/frontmatter.json", () => {
164
+ const fx = makeFixture();
165
+ try {
166
+ seedCopilotPackage(fx, { agents: { alpha: { body: "# alpha" } } });
167
+ rmSync(join(fx.root, "agents", "alpha", "copilot"), { recursive: true, force: true });
168
+ assert.throws(
169
+ () => installCopilot({ root: fx.root, output: fx.output }),
170
+ /without copilot\/frontmatter\.json/,
171
+ );
172
+ } finally {
173
+ fx.cleanup();
174
+ }
175
+ });
176
+
177
+ it("defaults to the plugin-scanned agents/ dir of the package root (plugin.json \"agents\": \"agents/\")", () => {
178
+ const fx = makeFixture();
179
+ try {
180
+ seedCopilotPackage(fx, { agents: { alpha: { body: "# alpha" } } });
181
+
182
+ const result = installCopilot({ root: fx.root });
183
+
184
+ assert.equal(result.output, join(fx.root, "agents"), "default output is the dir plugin.json scans");
185
+ assert.ok(existsSync(join(fx.root, "agents", "alpha.agent.md")), "flat file lands next to the composable dir");
186
+ assert.ok(existsSync(join(fx.root, "agents", "alpha", "agent.md")), "composable source untouched");
187
+ } finally {
188
+ fx.cleanup();
189
+ }
190
+ });
191
+
192
+ it("throws on an unknown tool token", () => {
193
+ const fx = makeFixture();
194
+ try {
195
+ seedCopilotPackage(fx, { agents: { alpha: { body: "# alpha\nUse {{TOOL:nope}} now." } } });
196
+ assert.throws(() => installCopilot({ root: fx.root, output: fx.output }), /unknown tool token {{TOOL:nope}}/);
197
+ } finally {
198
+ fx.cleanup();
199
+ }
200
+ });
201
+ });
202
+
203
+ // ─── Output / dry-run ────────────────────────────────────────────────────────
204
+
205
+ describe("installCopilot — output & dry-run", () => {
206
+ it("dry-run reports the plan without writing anything", () => {
207
+ const fx = makeFixture();
208
+ try {
209
+ seedCopilotPackage(fx, { agents: { alpha: { body: "# alpha" } } });
210
+
211
+ const result = installCopilot({ root: fx.root, output: fx.output, dryRun: true });
212
+
213
+ assert.equal(result.dryRun, true);
214
+ assert.equal(existsSync(fx.output), false, "no output dir created in dry-run");
215
+ assert.deepEqual(result.agents, ["alpha"], "planned files still reported");
216
+ } finally {
217
+ fx.cleanup();
218
+ }
219
+ });
220
+
221
+ it("is deterministic across repeated installs into the same output", () => {
222
+ const fx = makeFixture();
223
+ try {
224
+ seedCopilotPackage(fx, {
225
+ agents: {
226
+ alpha: { body: "# alpha\n{{SECTION:polling}}", sections: { polling: "Poll with {{TOOL:task}}." } },
227
+ beta: { body: "# beta" },
228
+ },
229
+ });
230
+
231
+ installCopilot({ root: fx.root, output: fx.output });
232
+ const first = snapshot(fx.output);
233
+ installCopilot({ root: fx.root, output: fx.output });
234
+ assert.deepEqual(snapshot(fx.output), first);
235
+ } finally {
236
+ fx.cleanup();
237
+ }
238
+ });
239
+ });
240
+
241
+ // ─── Integration against the real repo ───────────────────────────────────────
242
+
243
+ describe("installCopilot — real repo integration", () => {
244
+ it("regenerates byte-identical copies of the committed agents/*.agent.md files", () => {
245
+ const fx = makeFixture();
246
+ try {
247
+ const result = installCopilot({ root: ROOT, output: fx.output });
248
+
249
+ assert.deepEqual(result.agents.sort(), ["plan", "ralph", "ralph-plan", "strategy"]);
250
+ for (const name of result.agents) {
251
+ const generated = readFileSync(join(fx.output, `${name}.agent.md`), "utf-8");
252
+ const committed = readFileSync(join(ROOT, "agents", `${name}.agent.md`), "utf-8");
253
+ assert.equal(generated, committed, `${name}.agent.md must match the committed flat file (CI drift check)`);
254
+ }
255
+ } finally {
256
+ fx.cleanup();
257
+ }
258
+ });
259
+ });
260
+
261
+ // ─── CLI wiring ──────────────────────────────────────────────────────────────
262
+
263
+ describe("CLI integration", () => {
264
+ it("exits 0 for install copilot --output <tmp> and writes the flat files", async () => {
265
+ const fx = makeFixture();
266
+ try {
267
+ const { exitCode, stdout } = await runCli(["install", "copilot", "--output", fx.output]);
268
+ assert.equal(exitCode, 0);
269
+ assert.ok(stdout.includes("copilot"), "prints the harness name");
270
+ assert.ok(stdout.includes("Generated"), "prints the generation summary");
271
+ assert.ok(existsSync(join(fx.output, "ralph.agent.md")), "agent files written");
272
+ assert.ok(existsSync(join(fx.output, "plan.agent.md")));
273
+ } finally {
274
+ fx.cleanup();
275
+ }
276
+ });
277
+
278
+ it("exits 0 for install copilot --dry-run and writes nothing", async () => {
279
+ const fx = makeFixture();
280
+ try {
281
+ const { exitCode, stdout } = await runCli(["install", "copilot", "--output", fx.output, "--dry-run"]);
282
+ assert.equal(exitCode, 0);
283
+ assert.ok(/dry-run/i.test(stdout));
284
+ assert.equal(existsSync(fx.output), false, "no files written in dry-run");
285
+ } finally {
286
+ fx.cleanup();
287
+ }
288
+ });
289
+
290
+ it("exits 0 for install copilot (default output) without touching committed files", async () => {
291
+ const before = readFileSync(join(ROOT, "agents", "ralph.agent.md"), "utf-8");
292
+ const { exitCode } = await runCli(["install", "copilot"]);
293
+ assert.equal(exitCode, 0);
294
+ // Default output regenerates the shared agents/ dir in place — output must
295
+ // stay byte-identical to what is committed.
296
+ assert.equal(readFileSync(join(ROOT, "agents", "ralph.agent.md"), "utf-8"), before);
297
+ });
298
+ });