@jaybeeuu/agent-cortex 1.39.0 → 1.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/.agents/skills/style-versioning/SKILL.md +1 -1
  2. package/.github/workflows/ci.yml +47 -14
  3. package/AGENTS.md +35 -8
  4. package/CHANGELOG.md +60 -0
  5. package/README.md +174 -97
  6. package/agents/README.md +14 -11
  7. package/bin/agent-cortex.mjs +55 -10
  8. package/bin/installers/claude.mjs +674 -174
  9. package/bin/installers/copilot.mjs +13 -18
  10. package/bin/installers/pi.mjs +293 -51
  11. package/claude/settings.json +4 -0
  12. package/{claude → claude-extras}/scripts/statusline-command.sh +32 -4
  13. package/docs/claude-hooks.md +15 -3
  14. package/docs/ideas/ban-content-assertion-tests.md +73 -0
  15. package/docs/ideas/bead-progress-notes-for-session-resumption.md +75 -0
  16. package/docs/ideas/classify-beads-via-deterministic-script-with-agent-fallback.md +70 -0
  17. package/docs/ideas/ctx-alias-version-and-update-commands.md +65 -0
  18. package/docs/ideas/ctx-feedback-command-for-capturing-ideas-from-any-machine.md +67 -0
  19. package/docs/ideas/deployment-beads-for-post-merge-pipelines.md +36 -0
  20. package/docs/ideas/design-and-delivery-as-separate-bead-trees/design-and-delivery-as-separate-bead-trees.md +99 -0
  21. package/docs/ideas/enable-signed-commit-enforcement-changesets-bot-commits-must-be-signed.md +33 -0
  22. package/docs/ideas/extension-manifests.md +55 -14
  23. package/docs/ideas/improve-pi-system-prompt.md +5 -2
  24. package/docs/ideas/map-every-cortex-tool-to-a-real-pi-equivalent.md +95 -0
  25. package/docs/ideas/record-idea-implementation-questions.md +68 -0
  26. package/docs/ideas/self-flushing-session.md +72 -0
  27. package/docs/ideas/state-in-beads-not-md.md +69 -0
  28. package/docs/ideas/task-session-context.local.md +95 -0
  29. package/docs/ideas/test-data-creation-helpers.md +92 -0
  30. package/docs/ideas/test-planning-stage-bead-in-feature-planning.md +68 -0
  31. package/docs/ideas/worktree-housekeeping-for-closed-beads.md +39 -0
  32. package/docs/technical-direction/harness-config.md +161 -0
  33. package/docs/technical-direction/migration-execution-labels.md +60 -23
  34. package/extensions/agent-guardrails/README.md +45 -0
  35. package/extensions/agent-guardrails/guardrails.test.ts +42 -0
  36. package/extensions/agent-guardrails/guardrails.ts +66 -0
  37. package/extensions/agent-guardrails/index.ts +22 -0
  38. package/extensions/agent-guardrails/node_modules/.bin/pi +21 -0
  39. package/extensions/agent-guardrails/node_modules/.bin/tsc +21 -0
  40. package/extensions/agent-guardrails/node_modules/.bin/tsserver +21 -0
  41. package/extensions/agent-guardrails/node_modules/.bin/tsx +21 -0
  42. package/extensions/agent-guardrails/package.json +19 -0
  43. package/extensions/agent-guardrails/tsconfig.json +12 -0
  44. package/extensions/agent-modes/README.md +12 -5
  45. package/extensions/agent-modes/discover.test.ts +30 -7
  46. package/extensions/agent-modes/discover.ts +1 -1
  47. package/extensions/agent-modes/index.test.ts +208 -0
  48. package/extensions/agent-modes/index.ts +23 -6
  49. package/extensions/agent-modes/package.json +1 -1
  50. package/extensions/skill-stats/index.test.ts +3 -2
  51. package/lib/cli.mjs +30 -12
  52. package/lib/pi-packages.mjs +169 -0
  53. package/package.json +8 -4
  54. package/pi/settings.json +23 -12
  55. package/plugin.json +1 -1
  56. package/scripts/build-copilot-agents.mjs +1 -1
  57. package/scripts/check-no-sync.mjs +49 -0
  58. package/scripts/lib/compose-agent.mjs +32 -14
  59. package/scripts/lib/fs.mjs +31 -0
  60. package/skills/engineering/grill-with-docs/SKILL.md +1 -1
  61. package/skills/planning/classify-bead/REFERENCE.md +85 -0
  62. package/skills/planning/classify-bead/SKILL.md +32 -36
  63. package/skills/planning/classify-bead/scripts/classify-bead.mjs +292 -0
  64. package/skills/planning/create-task/SKILL.md +19 -10
  65. package/skills/planning/write-a-prd/SKILL.md +2 -2
  66. package/skills/style/style-code/REFERENCE.md +32 -2
  67. package/skills/style/style-code/SKILL.md +5 -21
  68. package/skills/style/style-tests/SKILL.md +69 -17
  69. package/skills/workflow/hitl-collab/SKILL.md +1 -1
  70. package/skills/workflow/run-pipeline-stage/SKILL.md +13 -1
  71. package/skills/workflow/run-pipeline-stage/prompts/stage-runner.md +12 -0
  72. package/test/classify-bead.test.mjs +277 -0
  73. package/test/claude-installer.test.mjs +199 -139
  74. package/test/cli.test.mjs +95 -41
  75. package/test/copilot-installer.test.mjs +92 -82
  76. package/test/fs.test.mjs +76 -0
  77. package/test/helpers/fake-claude.mjs +152 -0
  78. package/test/installer.test.mjs +542 -57
  79. package/test/pi-installer.test.mjs +457 -102
  80. package/test/pi-packages.test.mjs +295 -0
  81. package/token-map.README.md +23 -12
  82. package/token-map.json +8 -7
  83. package/.claude-plugin/marketplace.json +0 -16
  84. package/claude/.claude-plugin/plugin.json +0 -11
  85. package/claude/agents/plan.md +0 -58
  86. package/claude/agents/ralph-plan.md +0 -121
  87. package/claude/agents/ralph.md +0 -193
  88. package/claude/agents/strategy.md +0 -111
  89. package/claude/hooks/scripts/notify.mjs +0 -54
  90. package/claude/hooks.json +0 -38
  91. /package/{claude → claude-extras}/.mcp.json +0 -0
@@ -1,7 +1,7 @@
1
1
  import { describe, it } from "node:test";
2
2
  import assert from "node:assert/strict";
3
- import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync, existsSync, readdirSync } from "node:fs";
4
- import { join, dirname } from "node:path";
3
+ import { mkdtemp, mkdir, writeFile, rm, readFile, readdir, stat, lstat, symlink } from "node:fs/promises";
4
+ import { join, dirname, resolve } from "node:path";
5
5
  import { tmpdir } from "node:os";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { installPi } from "../bin/installers/pi.mjs";
@@ -10,6 +10,16 @@ import { installPi } from "../bin/installers/pi.mjs";
10
10
  // works against the canonical sources (agents/, skills/, token-map.json).
11
11
  const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
12
12
 
13
+ /** True when a path exists (stat succeeds). */
14
+ async function pathExists(p) {
15
+ try {
16
+ await stat(p);
17
+ return true;
18
+ } catch {
19
+ return false;
20
+ }
21
+ }
22
+
13
23
  // ─── Fixtures ────────────────────────────────────────────────────────────────
14
24
 
15
25
  const TOOL_MAP = {
@@ -37,29 +47,48 @@ function tokenMap(extra = {}) {
37
47
  };
38
48
  }
39
49
 
40
- function makeFixture() {
41
- const root = mkdtempSync(join(tmpdir(), "pi-installer-"));
50
+ async function makeFixture() {
51
+ const root = await mkdtemp(join(tmpdir(), "pi-installer-"));
42
52
  const output = join(root, "out");
43
53
  return {
44
54
  root,
45
55
  output,
46
- cleanup: () => rmSync(root, { recursive: true, force: true }),
56
+ cleanup: async () => rm(root, { recursive: true, force: true }),
47
57
  };
48
58
  }
49
59
 
50
- function writeFixture(fx, relPath, content) {
60
+ async function writeFixture(fx, relPath, content) {
51
61
  const p = join(fx.root, relPath);
52
- mkdirSync(dirname(p), { recursive: true });
53
- writeFileSync(p, content);
62
+ await mkdir(dirname(p), { recursive: true });
63
+ await writeFile(p, content);
64
+ }
65
+
66
+ /** Template pi settings the fixture package ships (defaults the installer merges). */
67
+ function defaultPiSettings() {
68
+ return {
69
+ packages: [{ source: "../../src/agent-cortex", skills: [] }, "npm:pi-web-access@0.10.7"],
70
+ defaultModel: "template/model",
71
+ theme: "dark",
72
+ tinyModel: { model: "template/tiny", maxNameLength: 40 },
73
+ };
74
+ }
75
+
76
+ function defaultPiKeybindings() {
77
+ return { "tui.input.newLine": ["shift+enter"] };
54
78
  }
55
79
 
56
- /** Seed a minimal but realistic package tree (agents + token-map + skills). */
57
- function seedPackage(fx, opts = {}) {
58
- const { agents = {}, skills = {}, tokenMap: map, version } = opts;
59
- writeFixture(fx, "token-map.json", JSON.stringify(map ?? tokenMap(version ? { version } : {})));
80
+ /** Seed a minimal but realistic package tree (agents + token-map + skills + pi templates). */
81
+ async function seedPackage(fx, opts = {}) {
82
+ const { agents = {}, skills = {}, tokenMap: map, version, packages } = opts;
83
+ await writeFixture(fx, "token-map.json", JSON.stringify(map ?? tokenMap(version ? { version } : {})));
84
+ if (packages) {
85
+ await writeFixture(fx, "package.json", JSON.stringify({ name: "fixture", pi: { packages } }));
86
+ }
87
+ await writeFixture(fx, "pi/settings.json", JSON.stringify(opts.piSettings ?? defaultPiSettings(), null, 2));
88
+ await writeFixture(fx, "pi/keybindings.json", JSON.stringify(opts.piKeybindings ?? defaultPiKeybindings(), null, 2));
60
89
  for (const [name, def] of Object.entries(agents)) {
61
- writeFixture(fx, `agents/${name}/agent.md`, def.body ?? `# ${name}\nBody of ${name}.`);
62
- writeFixture(
90
+ await writeFixture(fx, `agents/${name}/agent.md`, def.body ?? `# ${name}\nBody of ${name}.`);
91
+ await writeFixture(
63
92
  fx,
64
93
  `agents/${name}/pi/frontmatter.json`,
65
94
  JSON.stringify(
@@ -72,20 +101,20 @@ function seedPackage(fx, opts = {}) {
72
101
  ),
73
102
  );
74
103
  for (const [section, content] of Object.entries(def.sections ?? {})) {
75
- writeFixture(fx, `agents/${name}/pi/${section}.md`, content);
104
+ await writeFixture(fx, `agents/${name}/pi/${section}.md`, content);
76
105
  }
77
106
  }
78
107
  for (const [path, content] of Object.entries(skills)) {
79
- writeFixture(fx, `skills/${path}`, content);
108
+ await writeFixture(fx, `skills/${path}`, content);
80
109
  }
81
110
  }
82
111
 
83
- function listFiles(dir) {
84
- if (!existsSync(dir)) return [];
112
+ async function listFiles(dir) {
113
+ if (!(await pathExists(dir))) return [];
85
114
  const out = [];
86
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
115
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
87
116
  const p = join(dir, entry.name);
88
- if (entry.isDirectory()) out.push(...listFiles(p).map((f) => join(entry.name, f)));
117
+ if (entry.isDirectory()) out.push(...(await listFiles(p)).map((f) => join(entry.name, f)));
89
118
  else out.push(entry.name);
90
119
  }
91
120
  return out;
@@ -94,10 +123,10 @@ function listFiles(dir) {
94
123
  // ─── Agent composition ───────────────────────────────────────────────────────
95
124
 
96
125
  describe("installPi — agent composition", () => {
97
- it("writes one .agent.md per composable agent dir with pi frontmatter and no leftover tokens", () => {
98
- const fx = makeFixture();
126
+ it("writes one .agent.md per composable agent dir with pi frontmatter and no leftover tokens", async () => {
127
+ const fx = await makeFixture();
99
128
  try {
100
- seedPackage(fx, {
129
+ await seedPackage(fx, {
101
130
  agents: {
102
131
  alpha: {
103
132
  body: "# alpha\nUse {{TOOL:view}} to read.\n{{SECTION:polling}}\nRead {{PATH:skills_dir}}.",
@@ -112,11 +141,11 @@ describe("installPi — agent composition", () => {
112
141
  },
113
142
  });
114
143
 
115
- const result = installPi({ root: fx.root, output: fx.output });
144
+ const result = await installPi({ root: fx.root, output: fx.output });
116
145
 
117
146
  const file = join(fx.output, "agents", "alpha.agent.md");
118
- assert.ok(existsSync(file), "agent file written");
119
- const content = readFileSync(file, "utf-8");
147
+ assert.ok(await pathExists(file), "agent file written");
148
+ const content = await readFile(file, "utf-8");
120
149
  assert.ok(content.startsWith("---\n"), "yaml frontmatter");
121
150
  assert.match(content, /name: "agent-cortex:alpha"/);
122
151
  assert.match(content, /description: "Reads things\."/);
@@ -128,55 +157,55 @@ describe("installPi — agent composition", () => {
128
157
  assert.equal(result.agents.length, 1);
129
158
  assert.deepEqual(result.agents[0], { name: "alpha", filePath: file });
130
159
  } finally {
131
- fx.cleanup();
160
+ await fx.cleanup();
132
161
  }
133
162
  });
134
163
 
135
- it("drops null-mapped tool tokens in prose and reports a warning", () => {
136
- const fx = makeFixture();
164
+ it("drops null-mapped tool tokens in prose and reports a warning", async () => {
165
+ const fx = await makeFixture();
137
166
  try {
138
- seedPackage(fx, {
167
+ await seedPackage(fx, {
139
168
  agents: {
140
169
  alpha: { body: "# alpha\nAsk the user with {{TOOL:ask_user}}, then use {{TOOL:bash}}." },
141
170
  },
142
171
  });
143
172
 
144
- const result = installPi({ root: fx.root, output: fx.output });
145
- const content = readFileSync(join(fx.output, "agents", "alpha.agent.md"), "utf-8");
173
+ const result = await installPi({ root: fx.root, output: fx.output });
174
+ const content = await readFile(join(fx.output, "agents", "alpha.agent.md"), "utf-8");
146
175
 
147
176
  assert.ok(!content.includes("ask_user"), "null-mapped token dropped from prose");
148
177
  assert.ok(content.includes("use bash"), "mapped token substituted");
149
178
  assert.ok(!/{{/.test(content));
150
179
  assert.ok(result.warnings.some((w) => w.includes("{{TOOL:ask_user}}")), "drop reported as warning");
151
180
  } finally {
152
- fx.cleanup();
181
+ await fx.cleanup();
153
182
  }
154
183
  });
155
184
 
156
- it("throws on an unknown tool token", () => {
157
- const fx = makeFixture();
185
+ it("throws on an unknown tool token", async () => {
186
+ const fx = await makeFixture();
158
187
  try {
159
- seedPackage(fx, { agents: { alpha: { body: "# alpha\nUse {{TOOL:nope}} now." } } });
160
- assert.throws(() => installPi({ root: fx.root, output: fx.output }), /unknown tool token {{TOOL:nope}}/);
188
+ await seedPackage(fx, { agents: { alpha: { body: "# alpha\nUse {{TOOL:nope}} now." } } });
189
+ await assert.rejects(installPi({ root: fx.root, output: fx.output }), /unknown tool token {{TOOL:nope}}/);
161
190
  } finally {
162
- fx.cleanup();
191
+ await fx.cleanup();
163
192
  }
164
193
  });
165
194
 
166
- it("throws when a section file referenced by {{SECTION:...}} is missing", () => {
167
- const fx = makeFixture();
195
+ it("throws when a section file referenced by {{SECTION:...}} is missing", async () => {
196
+ const fx = await makeFixture();
168
197
  try {
169
- seedPackage(fx, { agents: { alpha: { body: "# alpha\n{{SECTION:missing}}" } } });
170
- assert.throws(() => installPi({ root: fx.root, output: fx.output }), /missing section "missing"/);
198
+ await seedPackage(fx, { agents: { alpha: { body: "# alpha\n{{SECTION:missing}}" } } });
199
+ await assert.rejects(installPi({ root: fx.root, output: fx.output }), /missing section "missing"/);
171
200
  } finally {
172
- fx.cleanup();
201
+ await fx.cleanup();
173
202
  }
174
203
  });
175
204
 
176
- it("resolves named and relative {{PATH:...}} tokens against the plugin root", () => {
177
- const fx = makeFixture();
205
+ it("resolves named and relative {{PATH:...}} tokens against the plugin root", async () => {
206
+ const fx = await makeFixture();
178
207
  try {
179
- seedPackage(fx, {
208
+ await seedPackage(fx, {
180
209
  agents: {
181
210
  alpha: {
182
211
  body: "# alpha\nNamed: {{PATH:skills_dir}}\nRelative: {{PATH:skills/x/SKILL.md}}",
@@ -184,8 +213,8 @@ describe("installPi — agent composition", () => {
184
213
  },
185
214
  });
186
215
 
187
- const result = installPi({ root: fx.root, output: fx.output });
188
- const content = readFileSync(join(fx.output, "agents", "alpha.agent.md"), "utf-8");
216
+ const result = await installPi({ root: fx.root, output: fx.output });
217
+ const content = await readFile(join(fx.output, "agents", "alpha.agent.md"), "utf-8");
189
218
 
190
219
  assert.ok(
191
220
  content.includes("Named: ~/.pi/agent/npm/node_modules/@jaybeeuu/agent-cortex/skills"),
@@ -197,38 +226,137 @@ describe("installPi — agent composition", () => {
197
226
  );
198
227
  assert.equal(result.warnings.length, 0);
199
228
  } finally {
200
- fx.cleanup();
229
+ await fx.cleanup();
201
230
  }
202
231
  });
203
232
 
204
- it("honours the --plugin-root override for path resolution", () => {
205
- const fx = makeFixture();
233
+ it("honours the --plugin-root override for path resolution", async () => {
234
+ const fx = await makeFixture();
206
235
  try {
207
- seedPackage(fx, {
236
+ await seedPackage(fx, {
208
237
  agents: {
209
238
  alpha: { body: "# alpha\n{{PATH:skills/x/SKILL.md}}" },
210
239
  },
211
240
  });
212
241
 
213
242
  const pluginRoot = join(fx.root, "checkout");
214
- installPi({ root: fx.root, output: fx.output, pluginRoot });
215
- const content = readFileSync(join(fx.output, "agents", "alpha.agent.md"), "utf-8");
243
+ await installPi({ root: fx.root, output: fx.output, pluginRoot });
244
+ const content = await readFile(join(fx.output, "agents", "alpha.agent.md"), "utf-8");
216
245
  assert.ok(content.includes(join(pluginRoot, "skills", "x", "SKILL.md")), "plugin root override used");
217
246
  } finally {
218
- fx.cleanup();
247
+ await fx.cleanup();
219
248
  }
220
249
  });
221
250
 
222
- it("rejects token-map.json versions newer than the implemented contract", () => {
223
- const fx = makeFixture();
251
+ it("rejects token-map.json versions newer than the implemented contract", async () => {
252
+ const fx = await makeFixture();
224
253
  try {
225
- seedPackage(fx, {
254
+ await seedPackage(fx, {
226
255
  version: 99,
227
256
  agents: { alpha: { body: "# alpha" } },
228
257
  });
229
- assert.throws(() => installPi({ root: fx.root, output: fx.output }), /version 99/);
258
+ await assert.rejects(installPi({ root: fx.root, output: fx.output }), /version 99/);
230
259
  } finally {
231
- fx.cleanup();
260
+ await fx.cleanup();
261
+ }
262
+ });
263
+ });
264
+
265
+ // ─── Third-party package provisioning ────────────────────────────────────────
266
+
267
+ describe("installPi — package provisioning", () => {
268
+ function recordingRunner() {
269
+ const calls = [];
270
+ return {
271
+ calls,
272
+ runInstall: async (source) => {
273
+ calls.push(source);
274
+ return { ok: true, error: null };
275
+ },
276
+ };
277
+ }
278
+
279
+ it("does not touch the pi package store unless provisioning is requested", async () => {
280
+ const fx = await makeFixture();
281
+ try {
282
+ await seedPackage(fx, {
283
+ agents: { alpha: { body: "# alpha" } },
284
+ packages: ["npm:pi-questions"],
285
+ });
286
+ const { calls, runInstall } = recordingRunner();
287
+
288
+ const result = await installPi({ root: fx.root, output: fx.output, runInstall });
289
+
290
+ assert.deepEqual(calls, []);
291
+ assert.equal(result.packages, null);
292
+ } finally {
293
+ await fx.cleanup();
294
+ }
295
+ });
296
+
297
+ it("installs the packages declared in the package manifest when enabled", async () => {
298
+ const fx = await makeFixture();
299
+ try {
300
+ await seedPackage(fx, {
301
+ agents: { alpha: { body: "# alpha" } },
302
+ packages: ["npm:pi-questions", "npm:pi-web-access@0.10.7"],
303
+ });
304
+ const { calls, runInstall } = recordingRunner();
305
+
306
+ const result = await installPi({ root: fx.root, output: fx.output, provisionPackages: true, runInstall });
307
+
308
+ assert.deepEqual(calls, ["npm:pi-questions", "npm:pi-web-access@0.10.7"]);
309
+ assert.deepEqual(result.packages.installed, ["npm:pi-questions", "npm:pi-web-access@0.10.7"]);
310
+ assert.deepEqual(result.packages.failed, []);
311
+ } finally {
312
+ await fx.cleanup();
313
+ }
314
+ });
315
+
316
+ it("dry-run reports the package plan without installing", async () => {
317
+ const fx = await makeFixture();
318
+ try {
319
+ await seedPackage(fx, {
320
+ agents: { alpha: { body: "# alpha" } },
321
+ packages: ["npm:pi-questions"],
322
+ });
323
+ const { calls, runInstall } = recordingRunner();
324
+
325
+ const result = await installPi({
326
+ root: fx.root,
327
+ output: fx.output,
328
+ provisionPackages: true,
329
+ dryRun: true,
330
+ runInstall,
331
+ });
332
+
333
+ assert.deepEqual(calls, []);
334
+ assert.deepEqual(result.packages.planned, ["npm:pi-questions"]);
335
+ } finally {
336
+ await fx.cleanup();
337
+ }
338
+ });
339
+
340
+ it("warns but still installs agents when a package install fails", async () => {
341
+ const fx = await makeFixture();
342
+ try {
343
+ await seedPackage(fx, {
344
+ agents: { alpha: { body: "# alpha" } },
345
+ packages: ["npm:pi-questions"],
346
+ });
347
+
348
+ const result = await installPi({
349
+ root: fx.root,
350
+ output: fx.output,
351
+ provisionPackages: true,
352
+ runInstall: async () => ({ ok: false, error: "offline" }),
353
+ });
354
+
355
+ assert.ok(await pathExists(join(fx.output, "agents", "alpha.agent.md")), "agents still installed");
356
+ assert.deepEqual(result.packages.failed, [{ source: "npm:pi-questions", error: "offline" }]);
357
+ assert.ok(result.warnings.some((w) => w.includes("npm:pi-questions")), "failure surfaced as a warning");
358
+ } finally {
359
+ await fx.cleanup();
232
360
  }
233
361
  });
234
362
  });
@@ -236,10 +364,10 @@ describe("installPi — agent composition", () => {
236
364
  // ─── Skills ──────────────────────────────────────────────────────────────────
237
365
 
238
366
  describe("installPi — skills", () => {
239
- it("copies skills into <output>/skills preserving the grouped layout with substituted tokens", () => {
240
- const fx = makeFixture();
367
+ it("copies skills into <output>/skills preserving the grouped layout with substituted tokens", async () => {
368
+ const fx = await makeFixture();
241
369
  try {
242
- seedPackage(fx, {
370
+ await seedPackage(fx, {
243
371
  agents: { alpha: { body: "# alpha" } },
244
372
  skills: {
245
373
  "engineering/echo/SKILL.md":
@@ -248,11 +376,11 @@ describe("installPi — skills", () => {
248
376
  },
249
377
  });
250
378
 
251
- installPi({ root: fx.root, output: fx.output });
379
+ await installPi({ root: fx.root, output: fx.output });
252
380
 
253
381
  const skillFile = join(fx.output, "skills", "engineering", "echo", "SKILL.md");
254
- assert.ok(existsSync(skillFile), "skill copied");
255
- const content = readFileSync(skillFile, "utf-8");
382
+ assert.ok(await pathExists(skillFile), "skill copied");
383
+ const content = await readFile(skillFile, "utf-8");
256
384
  assert.ok(content.includes("description: Echoes read."), "token substituted in skill frontmatter");
257
385
  assert.ok(content.includes("Use read to read and for help."), "null-mapped {{TOOL:skill}} dropped");
258
386
  assert.ok(
@@ -263,10 +391,10 @@ describe("installPi — skills", () => {
263
391
  );
264
392
  assert.ok(!/{{/.test(content), "no literal tokens remain in skill");
265
393
 
266
- const script = readFileSync(join(fx.output, "skills", "engineering", "echo", "scripts", "run.sh"), "utf-8");
394
+ const script = await readFile(join(fx.output, "skills", "engineering", "echo", "scripts", "run.sh"), "utf-8");
267
395
  assert.equal(script, "#!/bin/sh\necho hi\n", "non-md files copied verbatim");
268
396
  } finally {
269
- fx.cleanup();
397
+ await fx.cleanup();
270
398
  }
271
399
  });
272
400
  });
@@ -274,37 +402,258 @@ describe("installPi — skills", () => {
274
402
  // ─── Output / dry-run ────────────────────────────────────────────────────────
275
403
 
276
404
  describe("installPi — output & dry-run", () => {
277
- it("dry-run writes nothing but reports the planned output", () => {
278
- const fx = makeFixture();
405
+ it("dry-run writes nothing but reports the planned output", async () => {
406
+ const fx = await makeFixture();
279
407
  try {
280
- seedPackage(fx, {
408
+ await seedPackage(fx, {
281
409
  agents: { alpha: { body: "# alpha" } },
282
410
  skills: { "engineering/echo/SKILL.md": "---\nname: echo\ndescription: d\n---\n\nBody." },
283
411
  });
284
412
 
285
- const result = installPi({ root: fx.root, output: fx.output, dryRun: true });
413
+ const result = await installPi({ root: fx.root, output: fx.output, dryRun: true });
286
414
 
287
415
  assert.equal(result.dryRun, true);
288
- assert.equal(listFiles(fx.output).length, 0, "no files written in dry-run");
416
+ assert.equal((await listFiles(fx.output)).length, 0, "no files written in dry-run");
289
417
  assert.equal(result.agents.length, 1);
290
418
  assert.equal(result.agents[0].name, "alpha");
291
419
  assert.equal(result.skills.skills, 1);
292
420
  // Planned paths are still reported so the user can see what would happen.
293
421
  assert.ok(result.agents[0].filePath.startsWith(fx.output));
294
422
  } finally {
295
- fx.cleanup();
423
+ await fx.cleanup();
296
424
  }
297
425
  });
298
426
 
299
- it("creates the output directory hierarchy when needed", () => {
300
- const fx = makeFixture();
427
+ it("creates the output directory hierarchy when needed", async () => {
428
+ const fx = await makeFixture();
301
429
  try {
302
- seedPackage(fx, { agents: { alpha: { body: "# alpha" } } });
430
+ await seedPackage(fx, { agents: { alpha: { body: "# alpha" } } });
303
431
  const deep = join(fx.output, "nested", "deep");
304
- installPi({ root: fx.root, output: deep });
305
- assert.ok(existsSync(join(deep, "agents", "alpha.agent.md")));
432
+ await installPi({ root: fx.root, output: deep });
433
+ assert.ok(await pathExists(join(deep, "agents", "alpha.agent.md")));
434
+ } finally {
435
+ await fx.cleanup();
436
+ }
437
+ });
438
+ });
439
+
440
+ // ─── Pi settings.json ────────────────────────────────────────────────────────
441
+
442
+ describe("installPi — pi settings.json", () => {
443
+ it("writes a real settings.json with the repo path package and template keys", async () => {
444
+ const fx = await makeFixture();
445
+ try {
446
+ await seedPackage(fx, { agents: { alpha: { body: "# alpha" } } });
447
+
448
+ const result = await installPi({ root: fx.root, output: fx.output });
449
+
450
+ const settingsPath = join(fx.output, "settings.json");
451
+ assert.equal((await lstat(settingsPath)).isSymbolicLink(), false, "settings.json is a real file");
452
+ const settings = JSON.parse(await readFile(settingsPath, "utf-8"));
453
+
454
+ const repoEntry = settings.packages.find((p) => !(typeof p === "string" ? p : p.source).startsWith("npm:"));
455
+ assert.deepEqual(repoEntry.skills, [], "repo path package keeps the package filter shape");
456
+ assert.equal(resolve(dirname(settingsPath), repoEntry.source), fx.root, "repo path package resolves to the package root");
457
+ assert.ok(settings.packages.includes("npm:pi-web-access@0.10.7"), "template npm deps preserved");
458
+ assert.equal(settings.defaultModel, "template/model", "template key present");
459
+ assert.match(settings["//"], /GENERATED from pi\/settings\.json/);
460
+ assert.equal(result.settings.action, "written");
461
+ } finally {
462
+ await fx.cleanup();
463
+ }
464
+ });
465
+
466
+ it("preserves personal values and unknown keys while filling template defaults", async () => {
467
+ const fx = await makeFixture();
468
+ try {
469
+ await seedPackage(fx, { agents: { alpha: { body: "# alpha" } } });
470
+ await writeFixture(
471
+ fx,
472
+ "out/settings.json",
473
+ JSON.stringify({
474
+ defaultModel: "personal/model",
475
+ theme: "light",
476
+ personalOnly: { nested: true },
477
+ tinyModel: { model: "personal/tiny" },
478
+ }),
479
+ );
480
+
481
+ await installPi({ root: fx.root, output: fx.output });
482
+
483
+ const settings = JSON.parse(await readFile(join(fx.output, "settings.json"), "utf-8"));
484
+ assert.equal(settings.defaultModel, "personal/model", "personal value wins over the template default");
485
+ assert.equal(settings.theme, "light", "personal value wins over the template default");
486
+ assert.deepEqual(settings.personalOnly, { nested: true }, "unknown key preserved");
487
+ assert.equal(settings.tinyModel.model, "personal/tiny", "nested personal leaf retained");
488
+ assert.equal(settings.tinyModel.maxNameLength, 40, "nested template default filled in");
489
+ assert.ok(
490
+ settings.packages.some((p) => !(typeof p === "string" ? p : p.source).startsWith("npm:")),
491
+ "packages stays CLI-managed",
492
+ );
493
+ } finally {
494
+ await fx.cleanup();
495
+ }
496
+ });
497
+
498
+ it("replaces a legacy symlink with a real managed file", async () => {
499
+ const fx = await makeFixture();
500
+ try {
501
+ await seedPackage(fx, { agents: { alpha: { body: "# alpha" } } });
502
+ await mkdir(fx.output, { recursive: true });
503
+ await symlink(join(fx.root, "pi", "settings.json"), join(fx.output, "settings.json"));
504
+
505
+ const result = await installPi({ root: fx.root, output: fx.output });
506
+
507
+ assert.equal(result.settings.action, "removed-symlink");
508
+ const settingsPath = join(fx.output, "settings.json");
509
+ assert.equal((await lstat(settingsPath)).isSymbolicLink(), false, "symlink replaced by a real file");
510
+ const settings = JSON.parse(await readFile(settingsPath, "utf-8"));
511
+ assert.ok(settings.packages.length > 0, "managed packages written");
512
+ } finally {
513
+ await fx.cleanup();
514
+ }
515
+ });
516
+
517
+ it("re-running leaves settings.json unchanged", async () => {
518
+ const fx = await makeFixture();
519
+ try {
520
+ await seedPackage(fx, { agents: { alpha: { body: "# alpha" } } });
521
+ await installPi({ root: fx.root, output: fx.output });
522
+ const first = await readFile(join(fx.output, "settings.json"), "utf-8");
523
+
524
+ const result = await installPi({ root: fx.root, output: fx.output });
525
+
526
+ assert.equal(result.settings.action, "unchanged");
527
+ assert.equal(await readFile(join(fx.output, "settings.json"), "utf-8"), first);
528
+ } finally {
529
+ await fx.cleanup();
530
+ }
531
+ });
532
+
533
+ it("dry-run plans the settings write without touching the filesystem", async () => {
534
+ const fx = await makeFixture();
535
+ try {
536
+ await seedPackage(fx, { agents: { alpha: { body: "# alpha" } } });
537
+
538
+ const result = await installPi({ root: fx.root, output: fx.output, dryRun: true });
539
+
540
+ assert.equal(result.settings.action, "would-write");
541
+ assert.equal(await pathExists(join(fx.output, "settings.json")), false, "no file written in dry-run");
542
+ } finally {
543
+ await fx.cleanup();
544
+ }
545
+ });
546
+
547
+ it("dry-run reports would-remove-symlink for a legacy symlink", async () => {
548
+ const fx = await makeFixture();
549
+ try {
550
+ await seedPackage(fx, { agents: { alpha: { body: "# alpha" } } });
551
+ await mkdir(fx.output, { recursive: true });
552
+ const link = join(fx.output, "settings.json");
553
+ await symlink(join(fx.root, "pi", "settings.json"), link);
554
+
555
+ const result = await installPi({ root: fx.root, output: fx.output, dryRun: true });
556
+
557
+ assert.equal(result.settings.action, "would-remove-symlink");
558
+ assert.equal(result.settings.removedSymlink, true);
559
+ assert.equal((await lstat(link)).isSymbolicLink(), true, "symlink untouched in dry-run");
560
+ } finally {
561
+ await fx.cleanup();
562
+ }
563
+ });
564
+ });
565
+
566
+ // ─── Pi keybindings.json ─────────────────────────────────────────────────────
567
+
568
+ describe("installPi — pi keybindings.json", () => {
569
+ it("writes the template bindings as a real generated file", async () => {
570
+ const fx = await makeFixture();
571
+ try {
572
+ await seedPackage(fx, { agents: { alpha: { body: "# alpha" } } });
573
+
574
+ const result = await installPi({ root: fx.root, output: fx.output });
575
+
576
+ const keybindingsPath = join(fx.output, "keybindings.json");
577
+ assert.equal((await lstat(keybindingsPath)).isSymbolicLink(), false, "keybindings.json is a real file");
578
+ const keybindings = JSON.parse(await readFile(keybindingsPath, "utf-8"));
579
+ assert.deepEqual(keybindings["tui.input.newLine"], ["shift+enter"], "template bindings copied");
580
+ assert.match(keybindings["//"], /GENERATED from pi\/keybindings\.json/);
581
+ assert.equal(result.keybindings.action, "written");
582
+ } finally {
583
+ await fx.cleanup();
584
+ }
585
+ });
586
+
587
+ it("leaves a user-modified keybindings file alone", async () => {
588
+ const fx = await makeFixture();
589
+ try {
590
+ await seedPackage(fx, { agents: { alpha: { body: "# alpha" } } });
591
+ await writeFixture(
592
+ fx,
593
+ "out/keybindings.json",
594
+ JSON.stringify({ "tui.input.newLine": ["ctrl+j"] }, null, 2),
595
+ );
596
+
597
+ const result = await installPi({ root: fx.root, output: fx.output });
598
+
599
+ assert.equal(result.keybindings.action, "skipped");
600
+ const keybindings = JSON.parse(await readFile(join(fx.output, "keybindings.json"), "utf-8"));
601
+ assert.deepEqual(keybindings["tui.input.newLine"], ["ctrl+j"], "user edit preserved");
602
+ } finally {
603
+ await fx.cleanup();
604
+ }
605
+ });
606
+
607
+ it("refreshes an unmodified install when the template changes", async () => {
608
+ const fx = await makeFixture();
609
+ try {
610
+ await seedPackage(fx, { agents: { alpha: { body: "# alpha" } } });
611
+ await installPi({ root: fx.root, output: fx.output });
612
+ await writeFixture(
613
+ fx,
614
+ "pi/keybindings.json",
615
+ JSON.stringify({ "tui.input.newLine": ["ctrl+j"] }, null, 2),
616
+ );
617
+
618
+ const result = await installPi({ root: fx.root, output: fx.output });
619
+
620
+ assert.equal(result.keybindings.action, "written");
621
+ const keybindings = JSON.parse(await readFile(join(fx.output, "keybindings.json"), "utf-8"));
622
+ assert.deepEqual(keybindings["tui.input.newLine"], ["ctrl+j"], "updated template applied");
623
+ } finally {
624
+ await fx.cleanup();
625
+ }
626
+ });
627
+
628
+ it("replaces a legacy symlink with a real file", async () => {
629
+ const fx = await makeFixture();
630
+ try {
631
+ await seedPackage(fx, { agents: { alpha: { body: "# alpha" } } });
632
+ await mkdir(fx.output, { recursive: true });
633
+ await symlink(join(fx.root, "pi", "keybindings.json"), join(fx.output, "keybindings.json"));
634
+
635
+ const result = await installPi({ root: fx.root, output: fx.output });
636
+
637
+ assert.equal(result.keybindings.action, "removed-symlink");
638
+ assert.equal((await lstat(join(fx.output, "keybindings.json"))).isSymbolicLink(), false);
639
+ } finally {
640
+ await fx.cleanup();
641
+ }
642
+ });
643
+
644
+ it("re-running leaves keybindings.json unchanged", async () => {
645
+ const fx = await makeFixture();
646
+ try {
647
+ await seedPackage(fx, { agents: { alpha: { body: "# alpha" } } });
648
+ await installPi({ root: fx.root, output: fx.output });
649
+ const first = await readFile(join(fx.output, "keybindings.json"), "utf-8");
650
+
651
+ const result = await installPi({ root: fx.root, output: fx.output });
652
+
653
+ assert.equal(result.keybindings.action, "unchanged");
654
+ assert.equal(await readFile(join(fx.output, "keybindings.json"), "utf-8"), first);
306
655
  } finally {
307
- fx.cleanup();
656
+ await fx.cleanup();
308
657
  }
309
658
  });
310
659
  });
@@ -312,60 +661,66 @@ describe("installPi — output & dry-run", () => {
312
661
  // ─── Integration against the real repo ───────────────────────────────────────
313
662
 
314
663
  describe("installPi — real repo integration", () => {
315
- it("installs every canonical agent with pi tool names and no leftover tokens", () => {
316
- const fx = makeFixture();
664
+ it("installs every canonical agent with pi tool names and no leftover tokens", async () => {
665
+ const fx = await makeFixture();
317
666
  try {
318
- const result = installPi({ root: REPO_ROOT, output: fx.output });
667
+ const result = await installPi({ root: REPO_ROOT, output: fx.output });
319
668
 
320
669
  const names = result.agents.map((a) => a.name).sort();
321
670
  assert.deepEqual(names, ["plan", "ralph", "ralph-plan", "strategy"]);
322
671
 
323
672
  // ralph pi frontmatter: ["bash", "view", "rg", "glob", "task", "read_agent", "wait_for_agents"]
324
- const ralph = readFileSync(join(fx.output, "agents", "ralph.agent.md"), "utf-8");
673
+ const ralph = await readFile(join(fx.output, "agents", "ralph.agent.md"), "utf-8");
325
674
  assert.match(ralph, /name: "agent-cortex:ralph"/);
326
675
  assert.match(ralph, /tools: "bash read grep find task read_agent wait_for_agents"/);
327
676
  assert.ok(ralph.includes("## Spawning subagents (PI)"), "{{SECTION:polling}} composed from pi/polling.md");
328
677
  const bodyTokens = ralph.split("---\n").slice(2).join("---\n");
329
678
  assert.ok(!/{{/.test(bodyTokens), "no literal tokens in ralph body");
330
679
 
331
- // plan body carries the relative {{PATH:...}} resolved against the plugin root
332
- const plan = readFileSync(join(fx.output, "agents", "plan.agent.md"), "utf-8");
333
- assert.match(plan, /tools: "bash read edit write grep find task read_agent"/);
680
+ // plan body carries the relative {{PATH:...}} resolved against the plugin root.
681
+ // Tool list preserves authoring order (composer translateToolList, unlike the
682
+ // runtime agent-modes translateTools which sorts): ask_user → ask_questions,
683
+ // skill → read (last position).
684
+ const plan = await readFile(join(fx.output, "agents", "plan.agent.md"), "utf-8");
685
+ assert.match(plan, /tools: "bash read edit write grep find ask_questions task read_agent read"/);
334
686
  assert.ok(
335
687
  plan.includes("~/.pi/agent/npm/node_modules/@jaybeeuu/agent-cortex/skills/workflow/plan/SKILL.md"),
336
688
  "plan PATH token resolved against token-map plugin root",
337
689
  );
338
690
 
339
- // ralph-plan prose {{TOOL:ask_user}} is dropped (null for pi)
340
- const ralphPlan = readFileSync(join(fx.output, "agents", "ralph-plan.agent.md"), "utf-8");
341
- assert.ok(!ralphPlan.includes("ask_user"), "null-mapped tool omitted from ralph-plan body");
691
+ // ralph-plan prose {{TOOL:ask_user}} now substitutes to ask_questions
692
+ const ralphPlan = await readFile(join(fx.output, "agents", "ralph-plan.agent.md"), "utf-8");
693
+ assert.ok(!ralphPlan.includes("ask_user"), "ask_user never appears in ralph-plan body");
694
+ assert.ok(ralphPlan.includes("ask_questions"), "{{TOOL:ask_user}} substituted to ask_questions");
342
695
  assert.ok(
343
- result.warnings.some((w) => w.includes("ask_user")),
344
- "ralph-plan null tool dropped with a warning",
696
+ !result.warnings.some((w) => w.includes("ask_user")),
697
+ "no null-tool warning for ralph-plan (ask_user now maps to ask_questions)",
345
698
  );
346
699
 
347
- // strategy: web_fetch → fetch_content; ask_user/skill omitted
348
- const strategy = readFileSync(join(fx.output, "agents", "strategy.agent.md"), "utf-8");
349
- assert.match(strategy, /tools: "bash read grep find fetch_content edit write"/);
700
+ // strategy: web_fetch → fetch_content; ask_user → ask_questions; skill → read
701
+ // (authoring order preserved: bash, view→read, rg→grep, glob→find, ask_user→
702
+ // ask_questions, web_fetch→fetch_content, skill→read, edit, create)
703
+ const strategy = await readFile(join(fx.output, "agents", "strategy.agent.md"), "utf-8");
704
+ assert.match(strategy, /tools: "bash read grep find ask_questions fetch_content read edit write"/);
350
705
  } finally {
351
- fx.cleanup();
706
+ await fx.cleanup();
352
707
  }
353
708
  });
354
709
 
355
- it("substitutes tokens in every installed skill markdown", () => {
356
- const fx = makeFixture();
710
+ it("substitutes tokens in every installed skill markdown", async () => {
711
+ const fx = await makeFixture();
357
712
  try {
358
- const result = installPi({ root: REPO_ROOT, output: fx.output });
713
+ const result = await installPi({ root: REPO_ROOT, output: fx.output });
359
714
  assert.ok(result.skills.skills > 20, `expected the full skill tree, got ${result.skills.skills}`);
360
715
 
361
- const files = listFiles(join(fx.output, "skills")).filter((f) => f.endsWith(".md"));
716
+ const files = (await listFiles(join(fx.output, "skills"))).filter((f) => f.endsWith(".md"));
362
717
  assert.ok(files.length > 0);
363
718
  for (const f of files) {
364
- const content = readFileSync(join(fx.output, "skills", f), "utf-8");
719
+ const content = await readFile(join(fx.output, "skills", f), "utf-8");
365
720
  assert.ok(!/{{/.test(content), `no literal tokens in skill ${f}`);
366
721
  }
367
722
  } finally {
368
- fx.cleanup();
723
+ await fx.cleanup();
369
724
  }
370
725
  });
371
726
  });