@jaybeeuu/agent-cortex 1.41.1 → 1.43.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 (115) hide show
  1. package/.agents/skills/style-versioning/SKILL.md +73 -2
  2. package/.github/workflows/ci.yml +52 -32
  3. package/AGENTS.md +23 -2
  4. package/CHANGELOG.md +189 -0
  5. package/README.md +125 -73
  6. package/agents/README.md +17 -4
  7. package/agents/ralph-plan/agent.md +7 -3
  8. package/agents/ralph-plan.agent.md +7 -3
  9. package/bin/agent-cortex.mjs +48 -30
  10. package/bin/installers/pi.mjs +885 -237
  11. package/claude-extras/scripts/statusline-command.sh +0 -0
  12. package/docs/claude-hooks.md +5 -4
  13. package/docs/commit-signing.md +231 -0
  14. package/docs/ideas/ban-content-assertion-tests.md +3 -1
  15. package/docs/ideas/dedicated-review-tests-skill.md +71 -0
  16. package/docs/ideas/enable-signed-commit-enforcement-changesets-bot-commits-must-be-signed.md +10 -1
  17. package/docs/ideas/extension-manifests.md +5 -0
  18. package/docs/ideas/unify-the-ticket-and-task-decomposition-workflows.md +71 -0
  19. package/docs/research/disable-model-invocation.md +103 -0
  20. package/docs/research/skill-design-research.md +2 -2
  21. package/docs/skills/skill-anatomy.md +5 -1
  22. package/docs/technical-direction/harness-config.md +25 -1
  23. package/docs/technical-direction/migration-execution-labels.md +17 -9
  24. package/extensions/agent-modes/discover.test.ts +19 -0
  25. package/extensions/agent-modes/discover.ts +25 -3
  26. package/extensions/session-start/README.md +38 -0
  27. package/extensions/session-start/index.test.ts +122 -0
  28. package/extensions/session-start/index.ts +27 -0
  29. package/extensions/session-start/package.json +19 -0
  30. package/extensions/session-start/tsconfig.json +12 -0
  31. package/extensions/subagent/README.md +1 -1
  32. package/extensions/subagent/agents.test.ts +176 -0
  33. package/extensions/subagent/agents.ts +102 -11
  34. package/extensions/subagent/index.ts +2 -1
  35. package/extensions/subagent/package.json +1 -1
  36. package/lib/cli.mjs +35 -9
  37. package/package.json +10 -6
  38. package/pi/settings.json +1 -7
  39. package/plugin.json +1 -1
  40. package/pnpm-workspace.yaml +6 -0
  41. package/scripts/check-changeset.mjs +42 -0
  42. package/scripts/install-instructions.sh +0 -0
  43. package/scripts/lib/release-gates.mjs +61 -0
  44. package/scripts/lib/verify-worker-tree.mjs +107 -0
  45. package/scripts/resolve-publish-command.mjs +17 -0
  46. package/scripts/sync-plugin-version.sh +0 -0
  47. package/skills/engineering/grill-with-docs/SKILL.md +3 -2
  48. package/skills/engineering/improve-codebase-architecture/REFERENCE.md +5 -0
  49. package/skills/engineering/improve-codebase-architecture/SKILL.md +2 -1
  50. package/skills/planning/create-task/SKILL.md +8 -3
  51. package/skills/planning/create-task/scripts/create-chores.ts +4 -1
  52. package/skills/planning/prd-to-tasks/REFERENCE.md +2 -2
  53. package/skills/planning/prd-to-tasks/SKILL.md +9 -4
  54. package/skills/planning/request-refactor-plan/FORMAT.md +5 -0
  55. package/skills/planning/request-refactor-plan/SKILL.md +3 -2
  56. package/skills/planning/spec-to-tickets/REFERENCE.md +82 -0
  57. package/skills/planning/spec-to-tickets/SKILL.md +143 -0
  58. package/skills/planning/write-a-prd/FORMAT.md +6 -0
  59. package/skills/planning/write-a-prd/SKILL.md +9 -6
  60. package/skills/planning/write-a-ticket/EXAMPLES.md +133 -0
  61. package/skills/planning/write-a-ticket/SKILL.md +77 -59
  62. package/skills/productivity/bd-tool/SKILL.md +27 -2
  63. package/skills/productivity/grill-me/SKILL.md +11 -0
  64. package/skills/review/review-security/scripts/install-gitleaks.sh +0 -0
  65. package/skills/workflow/git-workflow/SKILL.md +35 -1
  66. package/skills/workflow/plan/SKILL.md +20 -8
  67. package/skills/workflow/ralph/REFERENCE.md +20 -2
  68. package/skills/workflow/ralph/SKILL.md +4 -1
  69. package/skills/workflow/run-pipeline-stage/SKILL.md +17 -5
  70. package/skills/workflow/run-pipeline-stage/playbooks/code.md +2 -0
  71. package/skills/workflow/run-pipeline-stage/playbooks/document.md +3 -0
  72. package/skills/workflow/run-pipeline-stage/playbooks/fix.md +2 -0
  73. package/skills/workflow/run-pipeline-stage/prompts/stage-runner.md +11 -0
  74. package/skills/workflow/using-agent-skills/SKILL.md +1 -0
  75. package/test/cli.test.mjs +26 -29
  76. package/test/create-chores.test.mjs +12 -0
  77. package/test/fake-bd.test.mjs +72 -0
  78. package/test/helpers/fake-bd.mjs +26 -6
  79. package/test/helpers/fake-pi.mjs +132 -0
  80. package/test/pi-installer.test.mjs +345 -286
  81. package/test/pi-register.test.mjs +342 -0
  82. package/test/pi-sweep.test.mjs +619 -0
  83. package/test/release-gates.test.mjs +190 -0
  84. package/test/verify-worker-tree.test.mjs +120 -0
  85. package/token-map.README.md +7 -6
  86. package/token-map.json +6 -5
  87. package/extensions/agent-guardrails/node_modules/.bin/pi +0 -21
  88. package/extensions/agent-guardrails/node_modules/.bin/tsc +0 -21
  89. package/extensions/agent-guardrails/node_modules/.bin/tsserver +0 -21
  90. package/extensions/agent-guardrails/node_modules/.bin/tsx +0 -21
  91. package/extensions/agent-modes/node_modules/.bin/pi +0 -21
  92. package/extensions/agent-modes/node_modules/.bin/tsc +0 -21
  93. package/extensions/agent-modes/node_modules/.bin/tsserver +0 -21
  94. package/extensions/agent-modes/node_modules/.bin/tsx +0 -21
  95. package/extensions/session-start.ts +0 -44
  96. package/extensions/skill-stats/node_modules/.bin/pi +0 -21
  97. package/extensions/skill-stats/node_modules/.bin/tsc +0 -21
  98. package/extensions/skill-stats/node_modules/.bin/tsserver +0 -21
  99. package/extensions/skill-stats/node_modules/.bin/tsx +0 -21
  100. package/extensions/subagent/node_modules/.bin/pi +0 -21
  101. package/extensions/subagent/node_modules/.bin/pi-ai +0 -21
  102. package/extensions/subagent/node_modules/.bin/tsc +0 -21
  103. package/extensions/subagent/node_modules/.bin/tsserver +0 -21
  104. package/extensions/subagent/node_modules/.bin/tsx +0 -21
  105. package/lib/pi-packages.mjs +0 -169
  106. package/skills/planning/create-task/scripts/node_modules/.bin/tsc +0 -21
  107. package/skills/planning/create-task/scripts/node_modules/.bin/tsserver +0 -21
  108. package/skills/planning/create-task/scripts/node_modules/.bin/tsx +0 -21
  109. package/skills/productivity/validate-agent-dir/scripts/node_modules/.bin/tsc +0 -21
  110. package/skills/productivity/validate-agent-dir/scripts/node_modules/.bin/tsserver +0 -21
  111. package/skills/productivity/validate-agent-dir/scripts/node_modules/.bin/tsx +0 -21
  112. package/skills/workflow/run-pipeline-stage/scripts/node_modules/.bin/tsc +0 -21
  113. package/skills/workflow/run-pipeline-stage/scripts/node_modules/.bin/tsserver +0 -21
  114. package/skills/workflow/run-pipeline-stage/scripts/node_modules/.bin/tsx +0 -21
  115. package/test/pi-packages.test.mjs +0 -295
@@ -13,14 +13,21 @@
13
13
 
14
14
  ## Current-state constraints
15
15
 
16
- - bd CLI is a global binary (`~/.local/bin/bd`); label ops: `bd label list-all [--json]`,
17
- `bd list --label-pattern/regex [--json]`, `bd label add/remove` (batch multiple ids).
16
+ - bd is a globally installed CLI resolved from `PATH` (whatever single `bd` is on PATH —
17
+ Homebrew, npm, or the install script); `BD_PATH` is the override for machines where `bd` is
18
+ not on PATH. Skill scripts invoke `BD_PATH || 'bd'` and never hardcode an install path.
19
+ Label ops: `bd label list-all [--json]`, `bd list --label-pattern/regex [--json]`,
20
+ `bd label add/remove` (batch multiple ids).
21
+ - A `bd` whose install target has been removed (`~/.local/bin` orphans, dangling pnpm shims)
22
+ must not be pinned: it can be a schema behind the workspace DB and fail with a schema
23
+ version mismatch. Keep exactly one `bd` on PATH so `bd` and the DB version agree.
18
24
  - `bd list` supports `--label-pattern`/`--json`; `bd count` does NOT support `--label-pattern`.
19
25
  - `bd ready --label-pattern 'stage:*'` works; `bd ready -l execution:afk` verified.
20
- - 3 script packages exist with per-skill `package.json` + own `pnpm-workspace.yaml` +
21
- own `pnpm-lock.yaml` (create-task, validate-agent-dir, run-pipeline-stage). They are NOT
22
- members of the root workspace (root glob `skills/*/scripts` does not match the nested
23
- `skills/<domain>/<name>/scripts`; root lockfile has zero refs to them).
26
+ - 3 script packages still exist with their own `package.json` (create-task,
27
+ validate-agent-dir, run-pipeline-stage). They ARE root workspace members (root glob
28
+ `skills/**/scripts`; all three are in the root `pnpm-lock.yaml`). Residual per-skill
29
+ `pnpm-lock.yaml` (all three) and `pnpm-workspace.yaml` (validate-agent-dir,
30
+ run-pipeline-stage) are stale artefacts of the pre-D2 layout.
24
31
  - All 3 scripts are stdlib-only (`node:child_process`, `node:fs`, `node:path`, `node:url`).
25
32
  - Extensions (`extensions/skill-stats`) have real peer deps on `@earendil-works/pi-coding-agent`
26
33
  — they remain workspace members with their own package.json.
@@ -162,8 +169,9 @@
162
169
 
163
170
  ## References
164
171
 
165
- - Code evidence: `bin/agent-cortex.mjs` + `lib/cli.mjs` (CLI surface), `skills/*/scripts/
166
- package.json` (3 orphaned), root `package.json` + `pnpm-workspace.yaml`, bd CLI help
167
- (`bd list`, `bd label`, `bd create`), epic `agnt-ctx-s5xd` notes.
172
+ - Code evidence: `bin/agent-cortex.mjs` + `lib/cli.mjs` (CLI surface),
173
+ `skills/*/*/scripts/package.json` (3, root workspace members), root `package.json` +
174
+ `pnpm-workspace.yaml`, bd CLI help (`bd list`, `bd label`, `bd create`), epic
175
+ `agnt-ctx-s5xd` notes.
168
176
  - Idea file: `docs/ideas/ctx-alias-version-and-update-commands.md` (ctx alias + version/update
169
177
  surface that `migrate` joins).
@@ -244,6 +244,25 @@ describe("discoverAgents (flat fallback)", () => {
244
244
  assert.deepEqual(strategy.tools, ["bash", "fetch_content", "read"]);
245
245
  assert.match(strategy.prompt, /Flat body of strategy\./);
246
246
  });
247
+ it("reads a space-separated scalar tools list instead of falling back to defaults", () => {
248
+ const fx = track(makeFixture());
249
+ writeFlatAgent(fx, "ralph", FLAT_FRONTMATTER("ralph", '"bash read grep task"'));
250
+
251
+ const agents = discoverAgents(fx.agentsDir, REAL_TOKEN_MAP);
252
+
253
+ // A collapsed scalar would land on DEFAULT_TOOLS, lifting the restriction
254
+ // rather than applying it — the tool names must survive the parse.
255
+ assert.deepEqual(agents[0].tools, ["bash", "grep", "read", "task"]);
256
+ });
257
+
258
+ it("reads a comma-separated scalar tools list", () => {
259
+ const fx = track(makeFixture());
260
+ writeFlatAgent(fx, "ralph", FLAT_FRONTMATTER("ralph", '"bash, view, task"'));
261
+
262
+ const agents = discoverAgents(fx.agentsDir, REAL_TOKEN_MAP);
263
+
264
+ assert.deepEqual(agents[0].tools, ["bash", "read", "task"]);
265
+ });
247
266
  });
248
267
 
249
268
  // ─── loadTokenMap ────────────────────────────────────────────────────────────
@@ -290,6 +290,21 @@ interface FlatParsed {
290
290
  prompt: string;
291
291
  }
292
292
 
293
+ /**
294
+ * Split a scalar `tools` value into tool names.
295
+ *
296
+ * Accepts the quoted string the pi installer renders (`tools: "bash, read"`)
297
+ * as well as an unquoted whitespace-separated list (`tools: bash read`).
298
+ * Commas alone are not enough: a whitespace-separated list would collapse to
299
+ * one bogus name and the agent would run with no usable tool allowlist.
300
+ */
301
+ function splitToolList(raw: string): string[] {
302
+ return raw
303
+ .replace(/^["']|["']$/g, "")
304
+ .split(/[,\s]+/)
305
+ .filter(Boolean);
306
+ }
307
+
293
308
  function parseFlatFrontmatter(raw: string): FlatParsed {
294
309
  const lines = raw.split(/\r?\n/);
295
310
  if (lines[0] !== "---") return { prompt: raw };
@@ -327,11 +342,16 @@ function parseFlatFrontmatter(raw: string): FlatParsed {
327
342
  }
328
343
 
329
344
  // Parse tools from the tools frontmatter.
330
- // Supports two formats:
331
- // tools: ["bash", "view"] (JSON array, single line)
332
- // tools: (YAML list, subsequent lines)
345
+ // Supports three formats:
346
+ // tools: ["bash", "view"] (JSON array, single line)
347
+ // tools: "bash, view" (scalar list, comma or whitespace separated)
348
+ // tools: (YAML list, subsequent lines)
333
349
  // - bash
334
350
  // - view
351
+ //
352
+ // The scalar form is what the pi installer renders for generated agents, so
353
+ // it must be read here rather than dropped to DEFAULT_TOOLS (which lifts the
354
+ // restriction instead of applying it).
335
355
  let inToolsBlock = false;
336
356
  for (const line of yamlLines) {
337
357
  const trimmed = line.trim();
@@ -354,6 +374,8 @@ function parseFlatFrontmatter(raw: string): FlatParsed {
354
374
  tools = [];
355
375
  continue;
356
376
  }
377
+ tools = splitToolList(val);
378
+ continue;
357
379
  }
358
380
  }
359
381
 
@@ -0,0 +1,38 @@
1
+ # session-start
2
+
3
+ A PI extension that injects, at the start of every session, the project's context files:
4
+
5
+ - `AGENTS.md`
6
+ - `docs/user-preferences.md`
7
+
8
+ Paths are relative to the session working directory (`ctx.cwd`); missing or empty files are
9
+ skipped. Content is injected as a hidden `session-start` message on `before_agent_start`, so it
10
+ reaches the model without cluttering the transcript UI.
11
+
12
+ ## No skill list here — on purpose
13
+
14
+ Earlier versions of this extension appended a hand-written blurb advertising three skills with
15
+ `/skill:<name>` syntax. Both halves were wrong:
16
+
17
+ - pi has no `/skill:` resolver — nothing maps that token to a read. Skills are used by reading
18
+ the `SKILL.md` path that pi lists.
19
+ - pi's own system prompt already lists **every** discovered skill with its absolute `SKILL.md`
20
+ path, so a local three-skill list was incomplete and drifted from `skills/` silently.
21
+
22
+ The blurb is gone and `index.test.ts` pins the absence of both `/skill:` syntax and hardcoded
23
+ skill names. Keep the authoritative list in `skills/`; do not reintroduce it here.
24
+
25
+ ## Installation
26
+
27
+ Bundled with the `agent-cortex` PI package — install the package for auto-discovery:
28
+
29
+ ```bash
30
+ pi install git:https://github.com/jaybeeuu/agent-cortex
31
+ ```
32
+
33
+ ## Development
34
+
35
+ ```bash
36
+ pnpm --filter @agent-cortex/session-start test
37
+ pnpm --filter @agent-cortex/session-start typecheck
38
+ ```
@@ -0,0 +1,122 @@
1
+ import { after, before, describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { tmpdir } from "node:os";
6
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
7
+
8
+ import sessionStartExtension from "./index.ts";
9
+
10
+ // ─── Harness ─────────────────────────────────────────────────────────────────
11
+
12
+ interface InjectedMessage {
13
+ customType: string;
14
+ content: string;
15
+ display: boolean;
16
+ }
17
+
18
+ /** A handler recorded from `pi.on`, invoked via Reflect.apply so its exact
19
+ * overload signature does not leak into the fake. */
20
+ type RecordedHandler = (...args: never[]) => unknown;
21
+
22
+ interface FakePi {
23
+ pi: ExtensionAPI;
24
+ /** Simulate pi dispatching `before_agent_start` and return the injected message. */
25
+ start(cwd: string): Promise<InjectedMessage | undefined>;
26
+ }
27
+
28
+ function createFakePi(): FakePi {
29
+ const handlers = new Map<string, RecordedHandler[]>();
30
+
31
+ const pi: ExtensionAPI = {
32
+ on: (event: string, handler: RecordedHandler) => {
33
+ const list = handlers.get(event) ?? [];
34
+ list.push(handler);
35
+ handlers.set(event, list);
36
+ },
37
+ } as unknown as ExtensionAPI;
38
+
39
+ return {
40
+ pi,
41
+ async start(cwd) {
42
+ const handler = handlers.get("before_agent_start")?.[0];
43
+ assert.ok(handler, "session-start should register a before_agent_start handler");
44
+ const run = handler as unknown as (
45
+ event: unknown,
46
+ ctx: { cwd: string },
47
+ ) => Promise<{ message?: InjectedMessage } | undefined>;
48
+ const result = await run({}, { cwd });
49
+ return result?.message;
50
+ },
51
+ };
52
+ }
53
+
54
+ /** Run one simulated session start against `cwd` and return the injected text. */
55
+ async function inject(cwd: string): Promise<string | undefined> {
56
+ const fake = createFakePi();
57
+ sessionStartExtension(fake.pi);
58
+ const message = await fake.start(cwd);
59
+ return message?.content;
60
+ }
61
+
62
+ // ─── Fixtures ────────────────────────────────────────────────────────────────
63
+
64
+ const AGENTS_MD = "# AGENTS\n\nProject instructions live here.";
65
+ const PREFERENCES_MD = "# User Preferences\n\nPrefer pnpm over npm.";
66
+
67
+ describe("session-start injection", () => {
68
+ let emptyProject: string;
69
+ let fullProject: string;
70
+
71
+ before(() => {
72
+ emptyProject = mkdtempSync(join(tmpdir(), "session-start-empty-"));
73
+ fullProject = mkdtempSync(join(tmpdir(), "session-start-full-"));
74
+ writeFileSync(join(fullProject, "AGENTS.md"), AGENTS_MD, "utf-8");
75
+ mkdirSync(join(fullProject, "docs"), { recursive: true });
76
+ writeFileSync(join(fullProject, "docs", "user-preferences.md"), PREFERENCES_MD, "utf-8");
77
+ });
78
+
79
+ after(() => {
80
+ rmSync(emptyProject, { recursive: true, force: true });
81
+ rmSync(fullProject, { recursive: true, force: true });
82
+ });
83
+
84
+ it("injects AGENTS.md and docs/user-preferences.md content", async () => {
85
+ const content = await inject(fullProject);
86
+ assert.ok(content, "expected the context files to be injected");
87
+ assert.match(content, /--- AGENTS\.md ---/);
88
+ assert.match(content, /Project instructions live here\./);
89
+ assert.match(content, /--- user-preferences\.md ---/);
90
+ assert.match(content, /Prefer pnpm over npm\./);
91
+ });
92
+
93
+ it("marks the injected message as a hidden session-start message", async () => {
94
+ const fake = createFakePi();
95
+ sessionStartExtension(fake.pi);
96
+ const message = await fake.start(fullProject);
97
+
98
+ assert.ok(message);
99
+ assert.equal(message.customType, "session-start");
100
+ assert.equal(message.display, false);
101
+ });
102
+
103
+ it("injects nothing for a project without context files", async () => {
104
+ const content = await inject(emptyProject);
105
+ assert.equal(content, undefined, "no context files means no injected message");
106
+ });
107
+
108
+ it("never advertises skills with pi-unresolvable /skill: syntax", async () => {
109
+ const content = await inject(fullProject);
110
+ assert.ok(content);
111
+ assert.ok(!content.includes("/skill:"), "pi has no /skill: resolver — drop the hint blurb");
112
+ });
113
+
114
+ it("never enumerates a fixed subset of skill names", async () => {
115
+ // pi's system prompt already lists every discovered skill with its SKILL.md
116
+ // path; a hand-maintained subset here drifts from skills/ silently.
117
+ const content = (await inject(fullProject)) ?? "";
118
+ for (const skill of ["using-agent-skills", "bd-tool", "git-workflow"]) {
119
+ assert.ok(!content.includes(skill), `${skill} should not be hardcoded in session-start`);
120
+ }
121
+ });
122
+ });
@@ -0,0 +1,27 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { loadSessionContext } from "../lib/context-loader.ts";
3
+
4
+ /**
5
+ * Session-start extension.
6
+ *
7
+ * Injects the project context files (AGENTS.md, docs/user-preferences.md) at
8
+ * the start of every PI session.
9
+ *
10
+ * It deliberately injects nothing about skills: pi's own system prompt already
11
+ * lists every discovered skill with the absolute path to its SKILL.md, so a
12
+ * local copy is incomplete by construction and drifts from `skills/` silently.
13
+ */
14
+ export default function (pi: ExtensionAPI) {
15
+ pi.on("before_agent_start", async (_event, ctx) => {
16
+ const contextFiles = loadSessionContext(ctx.cwd);
17
+ if (!contextFiles) return;
18
+
19
+ return {
20
+ message: {
21
+ customType: "session-start",
22
+ content: contextFiles,
23
+ display: false,
24
+ },
25
+ };
26
+ });
27
+ }
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "@agent-cortex/session-start",
3
+ "version": "1.0.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "test": "node --test --import tsx/esm index.test.ts",
8
+ "typecheck": "tsc --noEmit"
9
+ },
10
+ "devDependencies": {
11
+ "@earendil-works/pi-coding-agent": "^0.78.1",
12
+ "@types/node": "^22.0.0",
13
+ "tsx": "^4.0.0",
14
+ "typescript": "^5.0.0"
15
+ },
16
+ "peerDependencies": {
17
+ "@earendil-works/pi-coding-agent": "^0.78.1"
18
+ }
19
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "Node16",
5
+ "moduleResolution": "Node16",
6
+ "strict": true,
7
+ "noEmit": true,
8
+ "allowImportingTsExtensions": true,
9
+ "skipLibCheck": true
10
+ },
11
+ "include": ["*.ts"]
12
+ }
@@ -58,7 +58,7 @@ This tool executes a separate `pi` subprocess with a delegated system prompt and
58
58
 
59
59
  **Project-local agents** (`.pi/agents/*.md`) are repo-controlled prompts that can instruct the model to read files, run bash commands, etc.
60
60
 
61
- **Default behavior:** Only loads **user-level agents** from `~/.pi/agent/agents`.
61
+ **Default behavior:** Only loads **user-level agents** — pi's own `~/.pi/agent/agents`, plus the `agents/` tree of the package this extension ships inside when that is a rendered (staged) tree, e.g. `~/.agent-cortex/pi/agents` after `agent-cortex install pi`.
62
62
 
63
63
  To enable project-local agents, pass `agentScope: "both"` (or `"project"`). Only do this for repositories you trust.
64
64
 
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Subagent agent discovery — the directories the "user" scope reads.
3
+ *
4
+ * pi has no agent resource type: agents ship beside the extensions that serve
5
+ * them, so user-scope discovery covers pi's own user agent dir (hand-written
6
+ * personal agents, plus copies older installers made) and the package this
7
+ * extension ships inside. `agent-cortex install pi` renders the flat pi agents
8
+ * into `<package>/agents`; a source checkout keeps its composable tree there
9
+ * instead, plus flat renders for other harnesses, which are not pi's agents.
10
+ */
11
+
12
+ import assert from "node:assert/strict";
13
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
14
+ import { tmpdir } from "node:os";
15
+ import { dirname, join } from "node:path";
16
+ import { describe, it } from "node:test";
17
+ import { fileURLToPath } from "node:url";
18
+ import { discoverAgents, loadUserAgents, parseToolList, resolveUserAgentDirs } from "./agents.ts";
19
+
20
+ const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
21
+
22
+ /** Write a flat agent file the way a renderer does: frontmatter plus body. */
23
+ function writeAgent(dir: string, name: string, body = `# ${name}\n`): string {
24
+ const filePath = join(dir, `${name}.agent.md`);
25
+ mkdirSync(dir, { recursive: true });
26
+ writeFileSync(filePath, `---\nname: "${name}"\ndescription: "${name} agent"\n---\n\n${body}`);
27
+ return filePath;
28
+ }
29
+
30
+ /** Write a composable agent source dir: `<agentsDir>/<name>/agent.md`. */
31
+ function writeComposableAgent(agentsDir: string, name: string): void {
32
+ const dir = join(agentsDir, name);
33
+ mkdirSync(dir, { recursive: true });
34
+ writeFileSync(join(dir, "agent.md"), `# ${name}\n`);
35
+ }
36
+
37
+ describe("resolveUserAgentDirs", () => {
38
+ it("reads the agents a staged package rendered beside the extension", () => {
39
+ const root = mkdtempSync(join(tmpdir(), "subagent-package-"));
40
+ try {
41
+ const shipped = writeAgent(join(root, "agents"), "ralph", "Staged ralph body.\n");
42
+
43
+ const dirs = resolveUserAgentDirs(root);
44
+
45
+ assert.ok(dirs.includes(join(root, "agents")), `expected ${join(root, "agents")} in ${dirs.join(", ")}`);
46
+ assert.equal(loadUserAgents(dirs).find((agent) => agent.name === "ralph")?.filePath, shipped);
47
+ } finally {
48
+ rmSync(root, { recursive: true, force: true });
49
+ }
50
+ });
51
+
52
+ it("leaves a source checkout's composable agents tree to the agent-modes extension", () => {
53
+ const root = mkdtempSync(join(tmpdir(), "subagent-checkout-"));
54
+ try {
55
+ writeComposableAgent(join(root, "agents"), "ralph");
56
+ writeAgent(join(root, "agents"), "ralph", "Copilot render.\n");
57
+
58
+ const dirs = resolveUserAgentDirs(root);
59
+
60
+ assert.ok(!dirs.includes(join(root, "agents")), `checkout agents dir must not be read: ${dirs.join(", ")}`);
61
+ } finally {
62
+ rmSync(root, { recursive: true, force: true });
63
+ }
64
+ });
65
+ });
66
+
67
+ describe("discoverAgents (user scope)", () => {
68
+ it("does not hand pi the flat renders this checkout holds for other harnesses", () => {
69
+ const { agents } = discoverAgents(tmpdir(), "user");
70
+
71
+ assert.ok(
72
+ agents.every((agent) => !agent.filePath.startsWith(join(PACKAGE_ROOT, "agents"))),
73
+ `checkout renders leaked into discovery: ${agents.map((agent) => agent.filePath).join(", ")}`,
74
+ );
75
+ });
76
+ });
77
+
78
+ describe("loadUserAgents", () => {
79
+ it("merges every dir and lets the earlier dir win a name collision", () => {
80
+ const root = mkdtempSync(join(tmpdir(), "subagent-agents-"));
81
+ try {
82
+ const personal = join(root, "pi", "agent", "agents");
83
+ const shipped = join(root, "package", "agents");
84
+ writeAgent(personal, "plan", "Personal plan body.\n");
85
+ writeAgent(personal, "personal-only", "Personal agent body.\n");
86
+ writeAgent(shipped, "plan", "Shipped plan body.\n");
87
+ writeAgent(shipped, "shipped-only", "Shipped agent body.\n");
88
+
89
+ const agents = loadUserAgents([personal, shipped]);
90
+
91
+ assert.deepEqual(
92
+ agents.map((agent) => agent.name).sort(),
93
+ ["personal-only", "plan", "shipped-only"],
94
+ );
95
+ assert.equal(agents.find((agent) => agent.name === "plan")?.filePath, join(personal, "plan.agent.md"));
96
+ } finally {
97
+ rmSync(root, { recursive: true, force: true });
98
+ }
99
+ });
100
+
101
+ it("reads a tool list whether it is a YAML scalar or sequence", () => {
102
+ const root = mkdtempSync(join(tmpdir(), "subagent-agents-"));
103
+ try {
104
+ const shipped = join(root, "agents");
105
+ mkdirSync(shipped, { recursive: true });
106
+ writeFileSync(join(shipped, "scalar.agent.md"), `---\nname: "scalar"\ndescription: "d"\ntools: "bash, read"\n---\n\n# scalar\n`);
107
+ writeFileSync(join(shipped, "sequence.agent.md"), `---\nname: "sequence"\ndescription: "d"\ntools: ["bash", "read"]\n---\n\n# sequence\n`);
108
+
109
+ const tools = Object.fromEntries(loadUserAgents([shipped]).map((agent) => [agent.name, agent.tools]));
110
+
111
+ assert.deepEqual(tools, { scalar: ["bash", "read"], sequence: ["bash", "read"] });
112
+ } finally {
113
+ rmSync(root, { recursive: true, force: true });
114
+ }
115
+ });
116
+
117
+ it("ignores directories that do not exist", () => {
118
+ const root = mkdtempSync(join(tmpdir(), "subagent-agents-"));
119
+ try {
120
+ const shipped = join(root, "agents");
121
+ writeAgent(shipped, "ralph", "Shipped ralph body.\n");
122
+
123
+ const agents = loadUserAgents([join(root, "missing"), shipped]);
124
+
125
+ assert.deepEqual(
126
+ agents.map((agent) => agent.name),
127
+ ["ralph"],
128
+ );
129
+ } finally {
130
+ rmSync(root, { recursive: true, force: true });
131
+ }
132
+ });
133
+ });
134
+
135
+ // The frontmatter `tools` value reaches this extension as one scalar string
136
+ // (pi's parseFrontmatter does not split it) and is turned into `pi --tools`,
137
+ // whose parser splits on commas. A whitespace-separated list used to collapse
138
+ // into a single bogus name, producing an allowlist that matched no tool — the
139
+ // delegated agent then started with an empty tool block (agnt-ctx-rpm3).
140
+
141
+ describe("parseToolList", () => {
142
+ it("splits the comma-separated form", () => {
143
+ assert.deepEqual(parseToolList("bash, read, grep, find, task"), [
144
+ "bash",
145
+ "read",
146
+ "grep",
147
+ "find",
148
+ "task",
149
+ ]);
150
+ });
151
+
152
+ it("splits the space-separated form instead of collapsing it to one name", () => {
153
+ assert.deepEqual(parseToolList("bash read grep find task"), [
154
+ "bash",
155
+ "read",
156
+ "grep",
157
+ "find",
158
+ "task",
159
+ ]);
160
+ });
161
+
162
+ it("tolerates mixed and padded separators", () => {
163
+ assert.deepEqual(parseToolList(" bash,read grep ,\ttask "), [
164
+ "bash",
165
+ "read",
166
+ "grep",
167
+ "task",
168
+ ]);
169
+ });
170
+
171
+ it("returns undefined when there is no tool list", () => {
172
+ assert.equal(parseToolList(undefined), undefined);
173
+ assert.equal(parseToolList(""), undefined);
174
+ assert.equal(parseToolList(" "), undefined);
175
+ });
176
+ });