@jaybeeuu/agent-cortex 1.37.1 → 1.39.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/style-versioning/SKILL.md +9 -8
- package/.github/workflows/ci.yml +190 -8
- package/AGENTS.md +27 -6
- package/CHANGELOG.md +72 -0
- package/README.md +156 -39
- package/agents/README.md +49 -13
- package/agents/plan/agent.md +51 -0
- package/agents/plan/claude/frontmatter.json +6 -0
- package/agents/plan/copilot/frontmatter.json +6 -0
- package/agents/plan/pi/frontmatter.json +6 -0
- package/agents/plan.agent.md +3 -0
- package/agents/ralph/agent.md +262 -0
- package/agents/ralph/claude/frontmatter.json +6 -0
- package/agents/ralph/claude/polling.md +31 -0
- package/agents/ralph/copilot/frontmatter.json +6 -0
- package/agents/ralph/copilot/polling.md +62 -0
- package/agents/ralph/pi/frontmatter.json +6 -0
- package/agents/ralph/pi/polling.md +74 -0
- package/agents/ralph-plan/agent.md +114 -0
- package/agents/ralph-plan/claude/frontmatter.json +6 -0
- package/agents/ralph-plan/copilot/frontmatter.json +6 -0
- package/agents/ralph-plan/pi/frontmatter.json +6 -0
- package/agents/ralph-plan.agent.md +6 -3
- package/agents/ralph.agent.md +83 -57
- package/agents/strategy/agent.md +104 -0
- package/agents/strategy/claude/frontmatter.json +6 -0
- package/agents/strategy/copilot/frontmatter.json +6 -0
- package/agents/strategy/pi/frontmatter.json +6 -0
- package/agents/strategy.agent.md +3 -0
- package/bin/agent-cortex.mjs +76 -1
- package/bin/installers/claude.mjs +376 -0
- package/bin/installers/copilot.mjs +129 -0
- package/bin/installers/pi.mjs +197 -0
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/agents/plan.md +3 -1
- package/claude/agents/ralph-plan.md +6 -4
- package/claude/agents/strategy.md +3 -1
- package/claude/hooks/scripts/notify.mjs +54 -0
- package/claude/hooks.json +15 -0
- package/docs/claude-hooks.md +115 -0
- package/docs/ideas/auto-model-selection.md +75 -0
- package/docs/ideas/extension-manifests.md +75 -0
- package/docs/ideas/improve-pi-system-prompt.md +34 -0
- package/docs/ideas/model-selector.md +40 -0
- package/docs/ideas/pi-task-memory.md +34 -0
- package/docs/ideas/qa-extension.md +43 -0
- package/docs/ideas/record-ideas-as-beads.md +115 -0
- package/docs/ideas/subagent-visibility.md +72 -0
- package/docs/ideas/task-session-context.md +89 -0
- package/docs/ideas/unified-ci-pipeline.md +28 -0
- package/docs/research/skill-design-research.md +4 -4
- package/docs/research/skill-improvements-analysis.md +1 -1
- package/docs/skills/skill-anatomy.md +47 -2
- package/docs/technical-direction/migration-execution-labels.md +132 -0
- package/extensions/agent-modes/README.md +54 -0
- package/extensions/agent-modes/discover.test.ts +241 -0
- package/extensions/agent-modes/discover.ts +458 -0
- package/extensions/agent-modes/index.ts +13 -224
- package/extensions/agent-modes/node_modules/.bin/pi +21 -0
- package/extensions/agent-modes/node_modules/.bin/tsc +21 -0
- package/extensions/agent-modes/node_modules/.bin/tsserver +21 -0
- package/extensions/agent-modes/node_modules/.bin/tsx +21 -0
- package/extensions/agent-modes/package.json +20 -0
- package/extensions/agent-modes/tsconfig.json +12 -0
- package/extensions/subagent/README.md +19 -0
- package/extensions/subagent/index.ts +131 -45
- package/extensions/subagent/node_modules/.bin/pi +21 -0
- package/extensions/subagent/node_modules/.bin/pi-ai +21 -0
- package/extensions/subagent/node_modules/.bin/tsc +21 -0
- package/extensions/subagent/node_modules/.bin/tsserver +21 -0
- package/extensions/subagent/node_modules/.bin/tsx +21 -0
- package/extensions/subagent/package.json +23 -0
- package/extensions/subagent/tsconfig.json +12 -0
- package/extensions/subagent/wait.test.ts +202 -0
- package/extensions/subagent/wait.ts +118 -0
- package/hooks/claude/hooks.json +38 -0
- package/hooks/claude/scripts/notify.mjs +54 -0
- package/lib/cli.mjs +68 -3
- package/package.json +6 -4
- package/plugin.json +1 -1
- package/pnpm-workspace.yaml +1 -1
- package/scripts/build-copilot-agents.mjs +30 -0
- package/scripts/lib/compose-agent.mjs +241 -0
- package/skills/engineering/design-an-interface/SKILL.md +78 -58
- package/skills/engineering/grill-with-docs/REFERENCE.md +78 -0
- package/skills/engineering/grill-with-docs/SKILL.md +66 -63
- package/skills/engineering/improve-codebase-architecture/SKILL.md +66 -48
- package/skills/engineering/technical-direction/FORMAT.md +38 -0
- package/skills/engineering/technical-direction/SKILL.md +70 -77
- package/skills/planning/classify-bead/SKILL.md +59 -35
- package/skills/planning/create-task/REFERENCE.md +40 -0
- package/skills/planning/create-task/SKILL.md +70 -66
- package/skills/planning/create-task/scripts/create-chores.ts +6 -1
- package/skills/planning/create-task/scripts/node_modules/.bin/tsc +21 -0
- package/skills/planning/create-task/scripts/node_modules/.bin/tsserver +21 -0
- package/skills/planning/create-task/scripts/node_modules/.bin/tsx +21 -0
- package/skills/planning/prd-to-tasks/REFERENCE.md +66 -0
- package/skills/planning/prd-to-tasks/SKILL.md +86 -170
- package/skills/planning/record-idea/FORMAT.md +44 -0
- package/skills/planning/record-idea/SKILL.md +70 -53
- package/skills/planning/request-refactor-plan/FORMAT.md +45 -0
- package/skills/planning/request-refactor-plan/SKILL.md +57 -46
- package/skills/planning/write-a-prd/FORMAT.md +54 -0
- package/skills/planning/write-a-prd/SKILL.md +49 -48
- package/skills/planning/write-a-ticket/SKILL.md +68 -53
- package/skills/productivity/handoff/SKILL.md +12 -0
- package/skills/productivity/init-beads/SKILL.md +48 -40
- package/skills/productivity/validate-agent-dir/scripts/node_modules/.bin/tsc +21 -0
- package/skills/productivity/validate-agent-dir/scripts/node_modules/.bin/tsserver +21 -0
- package/skills/productivity/validate-agent-dir/scripts/node_modules/.bin/tsx +21 -0
- package/skills/productivity/write-a-skill/SKILL.md +2 -1
- package/skills/review/maintain-agent-docs/SKILL.md +71 -51
- package/skills/workflow/hitl-collab/SKILL.md +86 -17
- package/skills/workflow/ralph/REFERENCE.md +24 -19
- package/skills/workflow/ralph/SKILL.md +88 -74
- package/skills/workflow/run-pipeline-stage/REFERENCE.md +22 -0
- package/skills/workflow/run-pipeline-stage/SKILL.md +91 -93
- package/skills/workflow/run-pipeline-stage/scripts/generate-progress.test.ts +6 -107
- package/skills/workflow/run-pipeline-stage/scripts/generate-progress.ts +4 -2
- package/skills/workflow/run-pipeline-stage/scripts/node_modules/.bin/tsc +21 -0
- package/skills/workflow/run-pipeline-stage/scripts/node_modules/.bin/tsserver +21 -0
- package/skills/workflow/run-pipeline-stage/scripts/node_modules/.bin/tsx +21 -0
- package/skills/workflow/using-agent-skills/SKILL.md +122 -0
- package/test/claude-installer.test.mjs +248 -0
- package/test/cli.test.mjs +115 -2
- package/test/copilot-installer.test.mjs +298 -0
- package/test/installer.test.mjs +179 -0
- package/test/notify-hook.test.mjs +63 -0
- package/test/pi-installer.test.mjs +371 -0
- package/token-map.README.md +130 -0
- package/token-map.json +106 -0
- package/.github/workflows/ci.test.ts +0 -217
- package/.github/workflows/release.yml +0 -58
- package/scripts/build-claude-agents.mjs +0 -179
- package/scripts/publish-release.sh +0 -32
|
@@ -0,0 +1,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
|
+
});
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// Integration tests for `agent-cortex install claude`: the install-time
|
|
2
|
+
// generator for the Claude Code plugin subtree. All tests install into
|
|
3
|
+
// mkdtemp dirs so the real repo claude/ and ~/.claude are 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, readFileSync, readdirSync, existsSync, lstatSync, readlinkSync, realpathSync } from "node:fs";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
import { dirname, join, relative } from "node:path";
|
|
12
|
+
|
|
13
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
14
|
+
const ROOT = join(__dirname, "..");
|
|
15
|
+
const CLI_PATH = join(ROOT, "bin", "agent-cortex.mjs");
|
|
16
|
+
|
|
17
|
+
function runCli(args) {
|
|
18
|
+
return new Promise((resolve) => {
|
|
19
|
+
execFile(process.execPath, [CLI_PATH, ...args], (error, stdout, stderr) => {
|
|
20
|
+
resolve({ exitCode: error ? error.code ?? 1 : 0, stdout, stderr });
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function makeTmp() {
|
|
26
|
+
return mkdtempSync(join(tmpdir(), "agent-cortex-installer-"));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Deterministic snapshot of a generated tree: relative paths, kind, and for
|
|
30
|
+
* files/symlinks their byte content / link target. */
|
|
31
|
+
function snapshot(dir) {
|
|
32
|
+
const entries = [];
|
|
33
|
+
const walk = (p) => {
|
|
34
|
+
for (const e of readdirSync(p, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
35
|
+
const full = join(p, e.name);
|
|
36
|
+
const rel = relative(dir, full);
|
|
37
|
+
if (e.isSymbolicLink()) entries.push([rel, "link", readlinkSync(full)]);
|
|
38
|
+
else if (e.isDirectory()) {
|
|
39
|
+
entries.push([rel, "dir"]);
|
|
40
|
+
walk(full);
|
|
41
|
+
} else entries.push([rel, "file", readFileSync(full, "utf-8")]);
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
walk(dir);
|
|
45
|
+
return entries;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe("install claude", () => {
|
|
49
|
+
it("composes agents from the composable sources (claude names, no tokens)", async () => {
|
|
50
|
+
const out = makeTmp();
|
|
51
|
+
const { exitCode } = await runCli(["install", "claude", "--output", out]);
|
|
52
|
+
assert.equal(exitCode, 0);
|
|
53
|
+
|
|
54
|
+
const plan = readFileSync(join(out, "agents", "plan.md"), "utf-8");
|
|
55
|
+
assert.match(plan, /^---\n# GENERATED from agents\/plan\//);
|
|
56
|
+
// provenance names the shared installer (no build-time script), so the
|
|
57
|
+
// committed subtree can only come from the install-time generator.
|
|
58
|
+
assert.match(plan, /bin\/installers\/claude\.mjs/);
|
|
59
|
+
assert.doesNotMatch(plan, /build-claude-agents/);
|
|
60
|
+
assert.match(plan, /\nname: plan\n/);
|
|
61
|
+
assert.doesNotMatch(plan, /\{\{(TOOL|PATH|SECTION):/);
|
|
62
|
+
|
|
63
|
+
const ralphPlan = readFileSync(join(out, "agents", "ralph-plan.md"), "utf-8");
|
|
64
|
+
assert.match(ralphPlan, /^---\n# GENERATED from agents\/ralph-plan\//);
|
|
65
|
+
assert.match(ralphPlan, /\nname: ralph-plan\n/);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("copies native agents verbatim (ralph stays native, no composed counterpart)", async () => {
|
|
69
|
+
const out = makeTmp();
|
|
70
|
+
await runCli(["install", "claude", "--output", out]);
|
|
71
|
+
const native = readFileSync(join(ROOT, "agents-native", "ralph.md"), "utf-8");
|
|
72
|
+
assert.equal(readFileSync(join(out, "agents", "ralph.md"), "utf-8"), native);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("symlinks flattened skills and excludes ralph-coupled skills", async () => {
|
|
76
|
+
const out = makeTmp();
|
|
77
|
+
await runCli(["install", "claude", "--output", out]);
|
|
78
|
+
const skills = join(out, "skills");
|
|
79
|
+
const names = readdirSync(skills);
|
|
80
|
+
assert.ok(names.includes("tdd"));
|
|
81
|
+
assert.ok(names.includes("plan"));
|
|
82
|
+
assert.ok(!names.includes("ralph"));
|
|
83
|
+
assert.ok(!names.includes("run-pipeline-stage"));
|
|
84
|
+
assert.ok(lstatSync(join(skills, "tdd")).isSymbolicLink());
|
|
85
|
+
assert.equal(realpathSync(join(skills, "tdd")), realpathSync(join(ROOT, "skills", "engineering", "tdd")));
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("generates plugin.json tracking the package version and referencing hooks.json", async () => {
|
|
89
|
+
const out = makeTmp();
|
|
90
|
+
await runCli(["install", "claude", "--output", out]);
|
|
91
|
+
const pkgVersion = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf-8")).version;
|
|
92
|
+
const plugin = JSON.parse(readFileSync(join(out, ".claude-plugin", "plugin.json"), "utf-8"));
|
|
93
|
+
assert.equal(plugin.name, "agent-cortex");
|
|
94
|
+
assert.equal(plugin.version, pkgVersion);
|
|
95
|
+
assert.equal(plugin.hooks, "./hooks.json");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("generates hooks.json from the canonical hooks/claude/ source", async () => {
|
|
99
|
+
const out = makeTmp();
|
|
100
|
+
await runCli(["install", "claude", "--output", out]);
|
|
101
|
+
const source = JSON.parse(readFileSync(join(ROOT, "hooks", "claude", "hooks.json"), "utf-8"));
|
|
102
|
+
const installed = JSON.parse(readFileSync(join(out, "hooks.json"), "utf-8"));
|
|
103
|
+
assert.deepStrictEqual(installed, source);
|
|
104
|
+
assert.ok(source.hooks.SessionStart);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("matches the Notification hook only on documented notification types", async () => {
|
|
108
|
+
// The Notification matcher is an exact-string list over `notification_type` —
|
|
109
|
+
// there are no success/error types, so that matcher would never fire. Guard
|
|
110
|
+
// against it recurring (see docs/claude-hooks.md for the documented type list).
|
|
111
|
+
const out = makeTmp();
|
|
112
|
+
await runCli(["install", "claude", "--output", out]);
|
|
113
|
+
const installed = JSON.parse(readFileSync(join(out, "hooks.json"), "utf-8"));
|
|
114
|
+
const matcher = installed.hooks.Notification.flatMap((g) => g.hooks).length
|
|
115
|
+
? installed.hooks.Notification[0].matcher
|
|
116
|
+
: "";
|
|
117
|
+
const documented = new Set([
|
|
118
|
+
"permission_prompt",
|
|
119
|
+
"idle_prompt",
|
|
120
|
+
"auth_success",
|
|
121
|
+
"elicitation_dialog",
|
|
122
|
+
"elicitation_url_dialog",
|
|
123
|
+
"elicitation_complete",
|
|
124
|
+
"elicitation_response",
|
|
125
|
+
"agent_needs_input",
|
|
126
|
+
"agent_completed",
|
|
127
|
+
"quota_auto_resume_fired",
|
|
128
|
+
"quota_auto_resume_stale",
|
|
129
|
+
"quota_auto_resume_disabled",
|
|
130
|
+
]);
|
|
131
|
+
const types = matcher ? matcher.split("|") : [];
|
|
132
|
+
assert.ok(types.length > 0, "Notification hook should list at least one matcher type");
|
|
133
|
+
for (const t of types) {
|
|
134
|
+
assert.ok(documented.has(t), `Notification matcher references unknown type "${t}"`);
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("bundles hook support scripts into claude/hooks/ and references them via the plugin root", async () => {
|
|
139
|
+
const out = makeTmp();
|
|
140
|
+
await runCli(["install", "claude", "--output", out]);
|
|
141
|
+
|
|
142
|
+
const canonical = readFileSync(join(ROOT, "hooks", "claude", "scripts", "notify.mjs"), "utf-8");
|
|
143
|
+
const bundled = readFileSync(join(out, "hooks", "scripts", "notify.mjs"), "utf-8");
|
|
144
|
+
assert.equal(bundled, canonical);
|
|
145
|
+
|
|
146
|
+
const installed = JSON.parse(readFileSync(join(out, "hooks.json"), "utf-8"));
|
|
147
|
+
const notify = installed.hooks.Notification.flatMap((g) => g.hooks)
|
|
148
|
+
.filter((h) => h.command?.includes("notify.mjs"));
|
|
149
|
+
assert.ok(notify.length === 1, "hooks.json should reference the notification script");
|
|
150
|
+
assert.match(notify[0].command, /^node "\$\{CLAUDE_PLUGIN_ROOT\}\/hooks\/scripts\/notify\.mjs"$/);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("is deterministic across repeated installs into the same output", async () => {
|
|
154
|
+
const out = makeTmp();
|
|
155
|
+
await runCli(["install", "claude", "--output", out]);
|
|
156
|
+
const first = snapshot(out);
|
|
157
|
+
await runCli(["install", "claude", "--output", out]);
|
|
158
|
+
assert.deepStrictEqual(snapshot(out), first);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("--dry-run reports the plan without writing anything", async () => {
|
|
162
|
+
const out = makeTmp();
|
|
163
|
+
const { exitCode, stdout } = await runCli(["install", "claude", "--output", out, "--dry-run"]);
|
|
164
|
+
assert.equal(exitCode, 0);
|
|
165
|
+
assert.ok(/would/i.test(stdout));
|
|
166
|
+
assert.equal(existsSync(join(out, "agents")), false);
|
|
167
|
+
assert.equal(existsSync(join(out, "skills")), false);
|
|
168
|
+
assert.equal(existsSync(join(out, ".claude-plugin")), false);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("leaves the committed claude/ subtree untouched when --output is given", async () => {
|
|
172
|
+
const out = makeTmp();
|
|
173
|
+
const beforeSkills = readdirSync(join(ROOT, "claude", "skills")).sort();
|
|
174
|
+
const beforePlugin = readFileSync(join(ROOT, "claude", ".claude-plugin", "plugin.json"), "utf-8");
|
|
175
|
+
await runCli(["install", "claude", "--output", out]);
|
|
176
|
+
assert.deepStrictEqual(readdirSync(join(ROOT, "claude", "skills")).sort(), beforeSkills);
|
|
177
|
+
assert.equal(readFileSync(join(ROOT, "claude", ".claude-plugin", "plugin.json"), "utf-8"), beforePlugin);
|
|
178
|
+
});
|
|
179
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Tests for the Claude Code Notification hook script (hooks/claude/scripts/notify.mjs).
|
|
2
|
+
// The script reads the Notification hook JSON payload from stdin and prints a
|
|
3
|
+
// `{ "terminalSequence": "..." }` JSON response that Claude Code emits for us
|
|
4
|
+
// (hooks run without a controlling terminal, so OSC sequences must go through
|
|
5
|
+
// terminalSequence — see the Claude Code hooks reference).
|
|
6
|
+
|
|
7
|
+
import { describe, it } from "node:test";
|
|
8
|
+
import assert from "node:assert/strict";
|
|
9
|
+
import { execFile } from "node:child_process";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
import { dirname, join } from "node:path";
|
|
12
|
+
|
|
13
|
+
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
14
|
+
const SCRIPT = join(ROOT, "hooks", "claude", "scripts", "notify.mjs");
|
|
15
|
+
|
|
16
|
+
/** Run the hook script with the given payload on stdin and a controlled env. */
|
|
17
|
+
function runNotify(payload, env = {}) {
|
|
18
|
+
return new Promise((resolve) => {
|
|
19
|
+
const child = execFile(
|
|
20
|
+
process.execPath,
|
|
21
|
+
[SCRIPT],
|
|
22
|
+
{ env, timeout: 10_000 },
|
|
23
|
+
(error, stdout, stderr) => {
|
|
24
|
+
resolve({ exitCode: error ? error.code ?? 1 : 0, stdout, stderr });
|
|
25
|
+
},
|
|
26
|
+
);
|
|
27
|
+
child.stdin.end(JSON.stringify(payload));
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe("notify.mjs (Claude Notification hook)", () => {
|
|
32
|
+
it("emits an OSC 777 desktop notification on a bare-terminal agent_completed payload", async () => {
|
|
33
|
+
const { exitCode, stdout } = await runNotify(
|
|
34
|
+
{ notification_type: "agent_completed", title: "task done", message: "all tests pass" },
|
|
35
|
+
{ PATH: process.env.PATH },
|
|
36
|
+
);
|
|
37
|
+
assert.equal(exitCode, 0);
|
|
38
|
+
const out = JSON.parse(stdout);
|
|
39
|
+
assert.ok(out.terminalSequence.startsWith("\u001b]777;notify;"));
|
|
40
|
+
assert.match(out.terminalSequence, /task done/);
|
|
41
|
+
assert.match(out.terminalSequence, /all tests pass/);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("switches to OSC 99 when running inside Kitty", async () => {
|
|
45
|
+
const { exitCode, stdout } = await runNotify(
|
|
46
|
+
{ notification_type: "agent_needs_input", title: "input needed", message: "build broke" },
|
|
47
|
+
{ PATH: process.env.PATH, KITTY_WINDOW_ID: "0" },
|
|
48
|
+
);
|
|
49
|
+
assert.equal(exitCode, 0);
|
|
50
|
+
const out = JSON.parse(stdout);
|
|
51
|
+
assert.ok(out.terminalSequence.startsWith("\u001b]99;"));
|
|
52
|
+
assert.match(out.terminalSequence, /build broke/);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("stays silent (no output, exit 0) when the payload has no message", async () => {
|
|
56
|
+
const { exitCode, stdout } = await runNotify(
|
|
57
|
+
{ notification_type: "permission_prompt" },
|
|
58
|
+
{ PATH: process.env.PATH },
|
|
59
|
+
);
|
|
60
|
+
assert.equal(exitCode, 0);
|
|
61
|
+
assert.equal(stdout, "");
|
|
62
|
+
});
|
|
63
|
+
});
|