@jaybeeuu/agent-cortex 1.42.0 → 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 (62) hide show
  1. package/.agents/skills/style-versioning/SKILL.md +73 -2
  2. package/.github/workflows/ci.yml +47 -27
  3. package/AGENTS.md +23 -2
  4. package/CHANGELOG.md +166 -0
  5. package/README.md +125 -73
  6. package/agents/README.md +17 -4
  7. package/bin/agent-cortex.mjs +48 -30
  8. package/bin/installers/pi.mjs +885 -237
  9. package/docs/claude-hooks.md +5 -4
  10. package/docs/commit-signing.md +231 -0
  11. package/docs/ideas/enable-signed-commit-enforcement-changesets-bot-commits-must-be-signed.md +10 -1
  12. package/docs/ideas/extension-manifests.md +5 -0
  13. package/docs/research/disable-model-invocation.md +103 -0
  14. package/docs/research/skill-design-research.md +2 -2
  15. package/docs/skills/skill-anatomy.md +5 -1
  16. package/docs/technical-direction/harness-config.md +25 -1
  17. package/docs/technical-direction/migration-execution-labels.md +17 -9
  18. package/extensions/agent-modes/discover.test.ts +19 -0
  19. package/extensions/agent-modes/discover.ts +25 -3
  20. package/extensions/session-start/README.md +38 -0
  21. package/extensions/session-start/index.test.ts +122 -0
  22. package/extensions/session-start/index.ts +27 -0
  23. package/extensions/session-start/package.json +19 -0
  24. package/extensions/session-start/tsconfig.json +12 -0
  25. package/extensions/skill-stats/pnpm-lock.yaml +329 -0
  26. package/extensions/subagent/README.md +1 -1
  27. package/extensions/subagent/agents.test.ts +176 -0
  28. package/extensions/subagent/agents.ts +102 -11
  29. package/extensions/subagent/index.ts +2 -1
  30. package/extensions/subagent/package.json +1 -1
  31. package/lib/cli.mjs +35 -9
  32. package/package.json +6 -5
  33. package/pi/settings.json +1 -7
  34. package/plugin.json +1 -1
  35. package/pnpm-workspace.yaml +6 -0
  36. package/scripts/check-changeset.mjs +42 -0
  37. package/scripts/lib/release-gates.mjs +61 -0
  38. package/scripts/lib/verify-worker-tree.mjs +107 -0
  39. package/scripts/resolve-publish-command.mjs +17 -0
  40. package/skills/planning/create-task/scripts/pnpm-lock.yaml +342 -0
  41. package/skills/productivity/validate-agent-dir/scripts/pnpm-lock.yaml +329 -0
  42. package/skills/workflow/git-workflow/SKILL.md +35 -1
  43. package/skills/workflow/ralph/REFERENCE.md +17 -1
  44. package/skills/workflow/ralph/SKILL.md +4 -1
  45. package/skills/workflow/run-pipeline-stage/SKILL.md +11 -4
  46. package/skills/workflow/run-pipeline-stage/playbooks/code.md +2 -0
  47. package/skills/workflow/run-pipeline-stage/playbooks/document.md +3 -0
  48. package/skills/workflow/run-pipeline-stage/playbooks/fix.md +2 -0
  49. package/skills/workflow/run-pipeline-stage/prompts/stage-runner.md +11 -0
  50. package/skills/workflow/run-pipeline-stage/scripts/pnpm-lock.yaml +342 -0
  51. package/test/cli.test.mjs +26 -29
  52. package/test/helpers/fake-pi.mjs +132 -0
  53. package/test/pi-installer.test.mjs +345 -286
  54. package/test/pi-register.test.mjs +342 -0
  55. package/test/pi-sweep.test.mjs +619 -0
  56. package/test/release-gates.test.mjs +190 -0
  57. package/test/verify-worker-tree.test.mjs +120 -0
  58. package/token-map.README.md +7 -6
  59. package/token-map.json +6 -5
  60. package/extensions/session-start.ts +0 -44
  61. package/lib/pi-packages.mjs +0 -169
  62. package/test/pi-packages.test.mjs +0 -295
@@ -0,0 +1,190 @@
1
+ // Behaviour tests for the release gates that keep `main` from re-publishing a
2
+ // version: the inverted PR changeset gate and the no-publish release gate.
3
+ // The decision logic is pure; the two CLI adapters are exercised through their
4
+ // process boundary because that is the contract CI depends on.
5
+
6
+ import { describe, it } from "node:test";
7
+ import assert from "node:assert/strict";
8
+ import { spawn } from "node:child_process";
9
+ import { fileURLToPath } from "node:url";
10
+ import {
11
+ PUBLISH_COMMAND,
12
+ NO_PUBLISH_COMMAND,
13
+ evaluateChangesetGate,
14
+ parseNameStatus,
15
+ resolvePublishCommand,
16
+ } from "../scripts/lib/release-gates.mjs";
17
+
18
+ const CHECK_CHANGESET = fileURLToPath(new URL("../scripts/check-changeset.mjs", import.meta.url));
19
+ const RESOLVE_PUBLISH = fileURLToPath(
20
+ new URL("../scripts/resolve-publish-command.mjs", import.meta.url),
21
+ );
22
+
23
+ function runScript(script, { args = [], env = {}, stdin = "" } = {}) {
24
+ return new Promise((resolve, reject) => {
25
+ const child = spawn(process.execPath, [script, ...args], {
26
+ env: { ...process.env, ...env },
27
+ });
28
+ let stdout = "";
29
+ let stderr = "";
30
+ child.stdout.on("data", (chunk) => {
31
+ stdout += chunk;
32
+ });
33
+ child.stderr.on("data", (chunk) => {
34
+ stderr += chunk;
35
+ });
36
+ child.on("error", reject);
37
+ child.on("close", (code) => resolve({ code, stdout, stderr }));
38
+ child.stdin.end(stdin);
39
+ });
40
+ }
41
+
42
+ describe("parseNameStatus", () => {
43
+ it("parses added and modified paths from git diff --name-status output", () => {
44
+ const changes = parseNameStatus("M\tpackage.json\nA\t.changeset/fix-release.md\n");
45
+
46
+ assert.deepEqual(changes, [
47
+ { status: "M", path: "package.json" },
48
+ { status: "A", path: ".changeset/fix-release.md" },
49
+ ]);
50
+ });
51
+
52
+ it("uses the new path for a rename", () => {
53
+ const changes = parseNameStatus("R087\t.changeset/old.md\t.changeset/new.md\n");
54
+
55
+ assert.deepEqual(changes, [{ status: "R", path: ".changeset/new.md" }]);
56
+ });
57
+
58
+ it("returns no changes for empty output", () => {
59
+ assert.deepEqual(parseNameStatus(""), []);
60
+ });
61
+ });
62
+
63
+ describe("evaluateChangesetGate", () => {
64
+ it("passes a PR that adds a changeset", () => {
65
+ const result = evaluateChangesetGate({
66
+ changes: [{ status: "A", path: ".changeset/fix-release.md" }],
67
+ labels: [],
68
+ headRef: "fix/release-gate",
69
+ actor: "jaybeeuu",
70
+ });
71
+
72
+ assert.deepEqual(result, { ok: true, reason: "changeset-added" });
73
+ });
74
+
75
+ it("fails a CI-only change that adds no changeset", () => {
76
+ const result = evaluateChangesetGate({
77
+ changes: [{ status: "M", path: ".github/workflows/ci.yml" }],
78
+ labels: [],
79
+ headRef: "chore/bump-pnpm",
80
+ actor: "jaybeeuu",
81
+ });
82
+
83
+ assert.equal(result.ok, false);
84
+ assert.equal(result.reason, "missing-changeset");
85
+ });
86
+
87
+ it("fails a PR that only modifies an existing changeset", () => {
88
+ const result = evaluateChangesetGate({
89
+ changes: [{ status: "M", path: ".changeset/pending.md" }],
90
+ labels: [],
91
+ headRef: "chore/edit-changeset",
92
+ actor: "jaybeeuu",
93
+ });
94
+
95
+ assert.equal(result.ok, false);
96
+ });
97
+
98
+ it("fails a PR that only adds the changeset README", () => {
99
+ const result = evaluateChangesetGate({
100
+ changes: [{ status: "A", path: ".changeset/README.md" }],
101
+ labels: [],
102
+ headRef: "docs/changesets",
103
+ actor: "jaybeeuu",
104
+ });
105
+
106
+ assert.equal(result.ok, false);
107
+ });
108
+
109
+ it("passes an unlabelled-change PR carrying the no-publish label", () => {
110
+ const result = evaluateChangesetGate({
111
+ changes: [{ status: "M", path: ".github/workflows/ci.yml" }],
112
+ labels: ["no-publish"],
113
+ headRef: "chore/bump-pnpm",
114
+ actor: "jaybeeuu",
115
+ });
116
+
117
+ assert.deepEqual(result, { ok: true, reason: "no-publish-label" });
118
+ });
119
+
120
+ it("passes the Version Packages PR by head branch", () => {
121
+ const result = evaluateChangesetGate({
122
+ changes: [{ status: "M", path: "package.json" }],
123
+ labels: [],
124
+ headRef: "changeset-release/main",
125
+ actor: "jaybeeuu",
126
+ });
127
+
128
+ assert.deepEqual(result, { ok: true, reason: "version-packages-pr" });
129
+ });
130
+
131
+ it("passes a PR opened by the GitHub Actions bot", () => {
132
+ const result = evaluateChangesetGate({
133
+ changes: [{ status: "M", path: "package.json" }],
134
+ labels: [],
135
+ headRef: "automation/some-branch",
136
+ actor: "github-actions[bot]",
137
+ });
138
+
139
+ assert.deepEqual(result, { ok: true, reason: "version-packages-pr" });
140
+ });
141
+ });
142
+
143
+ describe("resolvePublishCommand", () => {
144
+ it("publishes by default when the merged PR has no labels", () => {
145
+ assert.equal(resolvePublishCommand([]), PUBLISH_COMMAND);
146
+ });
147
+
148
+ it("publishes when the merged PR carries unrelated labels", () => {
149
+ assert.equal(resolvePublishCommand(["bug", "priority:p2"]), PUBLISH_COMMAND);
150
+ });
151
+
152
+ it("refuses to publish when the merged PR carries the no-publish label", () => {
153
+ assert.equal(resolvePublishCommand(["bug", "no-publish"]), NO_PUBLISH_COMMAND);
154
+ });
155
+
156
+ it("matches the label exactly, not as a substring", () => {
157
+ assert.equal(resolvePublishCommand(["no-publish-ci"]), PUBLISH_COMMAND);
158
+ });
159
+ });
160
+
161
+ describe("check-changeset CLI", () => {
162
+ it("exits 0 for a no-publish PR with no diff", async () => {
163
+ const { code } = await runScript(CHECK_CHANGESET, {
164
+ env: { PR_LABELS: '["no-publish"]', PR_HEAD_REF: "chore/bump-pnpm", PR_ACTOR: "jaybeeuu" },
165
+ });
166
+
167
+ assert.equal(code, 0);
168
+ });
169
+
170
+ it("exits 1 and explains the no-publish escape hatch for a changeset-less PR", async () => {
171
+ const { code, stderr } = await runScript(CHECK_CHANGESET, {
172
+ stdin: "M\tpackage.json\n",
173
+ env: { PR_LABELS: "[]", PR_HEAD_REF: "chore/bump-pnpm", PR_ACTOR: "jaybeeuu" },
174
+ });
175
+
176
+ assert.equal(code, 1);
177
+ assert.match(stderr, /no-publish/);
178
+ });
179
+ });
180
+
181
+ describe("resolve-publish-command CLI", () => {
182
+ it("prints the noop command for a no-publish label set", async () => {
183
+ const { code, stdout } = await runScript(RESOLVE_PUBLISH, {
184
+ args: ['["no-publish"]'],
185
+ });
186
+
187
+ assert.equal(code, 0);
188
+ assert.equal(stdout.trim(), NO_PUBLISH_COMMAND);
189
+ });
190
+ });
@@ -0,0 +1,120 @@
1
+ // Integration tests for scripts/lib/verify-worker-tree.mjs. The module exists to
2
+ // tell a worker that delivered from one that only said it did, so each case runs
3
+ // a real git repository and asserts the verdict — no mocking of git, because git
4
+ // is the thing whose state the verdict is about.
5
+
6
+ import { describe, it } from "node:test";
7
+ import assert from "node:assert/strict";
8
+ import { execFile } from "node:child_process";
9
+ import { mkdtemp, writeFile, rm } from "node:fs/promises";
10
+ import { tmpdir } from "node:os";
11
+ import { join } from "node:path";
12
+ import { promisify } from "node:util";
13
+
14
+ import { verifyWorkerTree } from "../scripts/lib/verify-worker-tree.mjs";
15
+
16
+ const execFileAsync = promisify(execFile);
17
+
18
+ /** Run a git command in `cwd`. */
19
+ async function git(cwd, args) {
20
+ await execFileAsync("git", args, { cwd });
21
+ }
22
+
23
+ /** A repository with one base commit; returns its directory and that commit. */
24
+ async function createRepo() {
25
+ const dir = await mkdtemp(join(tmpdir(), "verify-worker-tree-"));
26
+ await git(dir, ["init", "--quiet"]);
27
+ await git(dir, ["config", "user.email", "t@example.com"]);
28
+ await git(dir, ["config", "user.name", "T"]);
29
+ // A developer's global config may enable commit signing; the fixture must not
30
+ // depend on a secret key being present.
31
+ await git(dir, ["config", "commit.gpgsign", "false"]);
32
+ await writeFile(join(dir, "README.md"), "base\n");
33
+ await git(dir, ["add", "-A"]);
34
+ await git(dir, ["commit", "-m", "base"]);
35
+ const { stdout } = await execFileAsync("git", ["rev-parse", "HEAD"], { cwd: dir });
36
+ return { dir, base: stdout.trim() };
37
+ }
38
+
39
+ /** Commit a file in the repo, as a delivering worker would. */
40
+ async function commitWork(dir) {
41
+ await writeFile(join(dir, "worker-output.txt"), "delivered\n");
42
+ await git(dir, ["add", "-A"]);
43
+ await git(dir, ["commit", "-m", "worker: deliver"]);
44
+ }
45
+
46
+ describe("verifyWorkerTree", () => {
47
+ it("passes a worker that committed and left a clean tree", async () => {
48
+ const { dir, base } = await createRepo();
49
+ try {
50
+ await commitWork(dir);
51
+
52
+ const verdict = await verifyWorkerTree({ worktree: dir, base });
53
+
54
+ assert.deepEqual(verdict, { ok: true, reasons: [], commitsAhead: 1 });
55
+ } finally {
56
+ await rm(dir, { recursive: true, force: true });
57
+ }
58
+ });
59
+
60
+ it("fails a worker that reported success but left its work uncommitted", async () => {
61
+ const { dir, base } = await createRepo();
62
+ try {
63
+ await writeFile(join(dir, "left-behind.txt"), "uncommitted\n");
64
+
65
+ const verdict = await verifyWorkerTree({ worktree: dir, base });
66
+
67
+ assert.equal(verdict.ok, false);
68
+ assert.equal(verdict.reasons[0], "worktree is dirty: 1 uncommitted path(s)");
69
+ assert.equal(verdict.commitsAhead, 0);
70
+ } finally {
71
+ await rm(dir, { recursive: true, force: true });
72
+ }
73
+ });
74
+
75
+ it("fails a worker that left a clean tree with nothing committed", async () => {
76
+ const { dir, base } = await createRepo();
77
+ try {
78
+ const verdict = await verifyWorkerTree({ worktree: dir, base });
79
+
80
+ assert.equal(verdict.ok, false);
81
+ assert.equal(verdict.reasons.length, 1);
82
+ assert.match(verdict.reasons[0], /^no commit ahead of base [0-9a-f]{7}$/);
83
+ } finally {
84
+ await rm(dir, { recursive: true, force: true });
85
+ }
86
+ });
87
+
88
+ it("reports a dirty tree and a missing commit together", async () => {
89
+ const { dir, base } = await createRepo();
90
+ try {
91
+ await writeFile(join(dir, "partial.txt"), "half done\n");
92
+
93
+ const verdict = await verifyWorkerTree({ worktree: dir, base });
94
+
95
+ assert.equal(verdict.ok, false);
96
+ assert.equal(verdict.reasons.length, 2);
97
+ assert.equal(verdict.reasons[0], "worktree is dirty: 1 uncommitted path(s)");
98
+ assert.match(verdict.reasons[1], /^no commit ahead of base [0-9a-f]{7}$/);
99
+ } finally {
100
+ await rm(dir, { recursive: true, force: true });
101
+ }
102
+ });
103
+
104
+ it("counts every commit the worker made, not just the last", async () => {
105
+ const { dir, base } = await createRepo();
106
+ try {
107
+ await commitWork(dir);
108
+ await writeFile(join(dir, "second.txt"), "more\n");
109
+ await git(dir, ["add", "-A"]);
110
+ await git(dir, ["commit", "-m", "worker: second"]);
111
+
112
+ const verdict = await verifyWorkerTree({ worktree: dir, base });
113
+
114
+ assert.equal(verdict.ok, true);
115
+ assert.equal(verdict.commitsAhead, 2);
116
+ } finally {
117
+ await rm(dir, { recursive: true, force: true });
118
+ }
119
+ });
120
+ });
@@ -15,14 +15,14 @@ decisions behind the map.
15
15
  | id | Platform | Agent format | Plugin root |
16
16
  |---|---|---|---|
17
17
  | `copilot` | GitHub Copilot CLI | `agents/<name>/` composable dirs → generated `agents/*.agent.md` | `~/.copilot/installed-plugins/_direct/agent-cortex` |
18
- | `pi` | pi coding agent | `agents/<name>/` composable dirs (composed at runtime by agent-modes, or installed install-time by `agent-cortex install pi`) | `~/.pi/agent/npm/node_modules/@jaybeeuu/agent-cortex` |
18
+ | `pi` | pi coding agent | `agents/<name>/` composable dirs (composed at runtime by agent-modes, or rendered install-time by `agent-cortex install pi`) | `~/.agent-cortex/pi` |
19
19
  | `claude` | Claude Code | `~/.agent-cortex/claude/agents/*.md` (materialised home install) | `${CLAUDE_PLUGIN_ROOT}` (env var) |
20
20
 
21
21
  The harness ids match the values accepted by `agent-cortex install <harness>` in
22
- `lib/cli.mjs` (`SUPPORTED_HARNESSES`). The pi `plugin_root` row is the npm-layout
23
- literal that `agent-cortex install pi` uses by default; local checkout and symlinked
24
- installs pass `--plugin-root <dir>` to override it (see README "Pi harness agents &
25
- skills").
22
+ `lib/cli.mjs` (`SUPPORTED_HARNESSES`). The pi `plugin_root` row is the staged package
23
+ directory `agent-cortex install pi` materialises, which pi then loads by path; checkout
24
+ and symlinked installs pass `--plugin-root <dir>` to override it (see README "Pi package
25
+ staging (`agent-cortex install pi`)").
26
26
 
27
27
  ## Design decisions
28
28
 
@@ -49,7 +49,8 @@ rather than inventing a dotted form:
49
49
  column, consumed by `bin/installers/claude.mjs` (the install-time generator — PR #91, #92); the pi column is grounded
50
50
  in pi's documented built-in tool surface (`read`, `bash`, `edit`, `write`, `grep`, `find`,
51
51
  `ls`) plus `ask_questions` — added by the pi-questions package (mapped from
52
- `ask_user`) — and `task` / `read_agent` / `wait_for_agents` subagent tools; `skill` maps
52
+ `ask_user`) — and `task` / `read_agent` / `wait_for_agents` subagent tools, the last three
53
+ supplied by this repo's own `extensions/subagent` rather than by pi; `skill` maps
53
54
  to `read` (skills load by reading the listed SKILL.md path).
54
55
  - **`paths`** — `plugin_root`, `agents_dir`, and `skills_dir`. These are the install-time
55
56
  variables that differ per harness. `plugin_root` is the anchor; the other two resolve
package/token-map.json CHANGED
@@ -31,7 +31,7 @@
31
31
  "plugin_root": {
32
32
  "copilot": "~/.copilot/installed-plugins/_direct/agent-cortex",
33
33
  "claude": "${CLAUDE_PLUGIN_ROOT}",
34
- "pi": "~/.pi/agent/npm/node_modules/@jaybeeuu/agent-cortex"
34
+ "pi": "~/.agent-cortex/pi"
35
35
  },
36
36
  "agents_dir": {
37
37
  "copilot": { "base": "plugin_root", "relative": "agents" },
@@ -63,14 +63,15 @@
63
63
  "tools": {
64
64
  "rg": "Copilot alias for grep. Claude and pi collapse it onto their single search tool (Grep / grep) — same collapse rule as the build script.",
65
65
  "glob": "Claude has a dedicated Glob tool. pi's built-in pattern-search tool is find (hypa_find when the pi-hypa extension is installed).",
66
- "ask_user": "Copilot (ask_user) and Claude (AskUserQuestion) prompt the user mid-run. pi's equivalent is ask_questions, provided by the pi-questions package (declared in the package manifest — package.json pi.packages — and provisioned by agent-cortex install pi; also declared in pi/settings.json for the symlinked checkout) — the genuine interactive-questioning tool, restoring grilling in plan/ralph-plan/strategy modes. Like fetch_content (pi-web-access), it is only present when its package is installed, so the mapping depends on that package (see the extension-manifests idea). pi still intentionally ships no permission popups; ask_questions is the interactive mid-run question tool, not a popup.",
67
- "web_fetch": "pi's fetch_content comes from the pi-web-access package (declared in the package manifest — package.json pi.packages — and provisioned by agent-cortex install pi).",
66
+ "ask_user": "Copilot (ask_user) and Claude (AskUserQuestion) prompt the user mid-run. pi's equivalent is ask_questions, provided by the pi-questions package (an ordinary runtime dependency of this package that `agent-cortex install pi` bundles into the staged package's node_modules) — the genuine interactive-questioning tool, restoring grilling in plan/ralph-plan/strategy modes. Like fetch_content (pi-web-access), it is only present when its package is installed, so the mapping depends on that package (see the extension-manifests idea). pi still intentionally ships no permission popups; ask_questions is the interactive mid-run question tool, not a popup.",
67
+ "web_fetch": "pi's fetch_content comes from the pi-web-access package (an ordinary runtime dependency of this package that `agent-cortex install pi` bundles into the staged package's node_modules).",
68
68
  "skill": "Copilot (skill) and Claude (Skill) expose skills as a tool. pi has no dedicated skill tool — skills are surfaced in the always-present 'Available skills' context block and loaded by reading the listed SKILL.md path ({{PATH:<skill>/SKILL.md}}). The pi mapping is read, the tool used to load a skill's file; a dedicated skill tool is a follow-on design candidate if prompting proves insufficient.",
69
- "read_agent": "Claude's Task returns results inline — there is no separate read step, so read_agent drops on claude (null). Copilot uses the task + read_agent poll loop; pi blocks on wait_for_agents and keeps read_agent as a fallback.",
69
+ "read_agent": "Claude's Task returns results inline — there is no separate read step, so read_agent drops on claude (null). Copilot uses the task + read_agent poll loop; pi exposes read_agent as a fallback to wait_for_agents.",
70
+ "task": "Copilot and Claude provide a subagent tool natively. On pi it is supplied by this repo's own extensions/subagent extension (which also registers read_agent and wait_for_agents), not by pi itself — a pi session started before that extension was installed has no dispatch tool at all. Skills must therefore stop and report a missing subagent tool rather than improvising a backgrounded CLI run, which has no completion signal.",
70
71
  "wait_for_agents": "PI-only blocking wait over background subagent runs (the task tool): returns the results of completed agents and STILL RUNNING for the rest. Copilot has no equivalent tool (its task + read_agent poll loop stays) and Claude's Task delivers results inline with no wait step — both null."
71
72
  },
72
73
  "paths": {
73
- "pi": "plugin_root defaults to the npm-package install layout (~/.pi/agent/npm/node_modules/@jaybeeuu/agent-cortex). Local-checkout installs (pi/settings.json packages entry referencing a local src path) or symlinked installs differ — installers must compute plugin_root from the actual resolution and can override it.",
74
+ "pi": "plugin_root is the staged package `agent-cortex install pi` materialises at ~/.agent-cortex/pi; pi loads that directory by path and the shipped extensions resolve their own resources relative to it (agent-modes computes plugin_root from its actual install location). A checkout or symlinked install differs — installers compute plugin_root from the actual resolution and can override it (--plugin-root).",
74
75
  "claude": "plugin_root stays ${CLAUDE_PLUGIN_ROOT} (env var) for the materialised plugin — never a literal ~/.agent-cortex/claude. Claude Code copies marketplace-installed plugins (including local directory sources like ~/.agent-cortex/claude) into its own cache and expands the env var per context, so a literal home path in substituted files would break under cache-copy; the installer passes the env form through verbatim."
75
76
  },
76
77
  "agents": {
@@ -1,44 +0,0 @@
1
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
- import { loadSessionContext } from "./lib/context-loader.js";
3
-
4
- /**
5
- * Session-start extension.
6
- *
7
- * Injects baseline context at the start of every PI session:
8
- * 1. Skill availability hints (so the agent knows they exist)
9
- * 2. Project context files (AGENTS.md, user-preferences.md)
10
- *
11
- * Context files are read dynamically from the project root (cwd).
12
- */
13
- export default function (pi: ExtensionAPI) {
14
- pi.on("before_agent_start", async (_event, ctx) => {
15
- const skillHints = [
16
- "Available skills — invoke when relevant (not pre-loaded to save context):",
17
- "",
18
- "• /skill:using-agent-skills — meta-skill that routes to the right skill",
19
- " and enforces core agent behaviors. Check this first if unsure.",
20
- "",
21
- "• /skill:bd-tool — project context & task tracking with beads.",
22
- " Run `bd ready` to see available work.",
23
- "",
24
- "• /skill:git-workflow — git discipline: feature branches,",
25
- " worktrees for solo AFK work, PR workflow, and when to ask.",
26
- "",
27
- "Read the SKILL.md for full instructions when needed.",
28
- ].join("\n");
29
-
30
- const contextFiles = loadSessionContext(ctx.cwd);
31
-
32
- const content = contextFiles
33
- ? `${skillHints}\n\n---\n\n${contextFiles}`
34
- : skillHints;
35
-
36
- return {
37
- message: {
38
- customType: "session-start",
39
- content,
40
- display: false,
41
- },
42
- };
43
- });
44
- }
@@ -1,169 +0,0 @@
1
- // Pi third-party package provisioning.
2
- //
3
- // agent-cortex's pi harness needs tools that live in third-party pi packages:
4
- // ask_questions comes from pi-questions, fetch_content from pi-web-access (see
5
- // token-map.json notes and docs/ideas/extension-manifests.md). Those packages
6
- // are declared in the package manifest — package.json → pi.packages — and this
7
- // module makes them real: `agent-cortex install pi` reads the declaration and
8
- // installs any package the pi user scope is missing.
9
- //
10
- // Provisioning goes through the pi CLI (`pi install <source>`), which both
11
- // installs the package under ~/.pi/agent/npm and declares it in
12
- // ~/.pi/agent/settings.json — the same store pi loads packages from. It is
13
- // opt-in (installPi option `provisionPackages`) and idempotent: a package that
14
- // is already declared and installed is skipped. A failed install warns and the
15
- // rest continue, so an offline machine never breaks the agent install.
16
-
17
- import { readFile, readdir } from "node:fs/promises";
18
- import { join } from "node:path";
19
- import { spawn } from "node:child_process";
20
-
21
- const DEFAULT_WARN = (msg) => console.warn(`[pi-packages] ${msg}`);
22
-
23
- /**
24
- * Read the pi package sources agent-cortex declares it needs.
25
- *
26
- * @param {string} root Package root containing package.json
27
- * @returns {Promise<string[]>} Declared sources, e.g. ["npm:pi-questions"]
28
- */
29
- export async function loadRequiredPackages(root) {
30
- const manifest = JSON.parse(await readFile(join(root, "package.json"), "utf-8"));
31
- const packages = manifest?.pi?.packages;
32
- return Array.isArray(packages) ? packages.filter((entry) => typeof entry === "string") : [];
33
- }
34
-
35
- /**
36
- * Extract the npm package name from a pi source string.
37
- *
38
- * @param {string} source e.g. "npm:pi-web-access@0.10.7", "npm:@scope/pkg"
39
- * @returns {string | null} The name, or null for sources we cannot inspect locally
40
- */
41
- export function npmSourceName(source) {
42
- if (typeof source !== "string" || !source.startsWith("npm:")) return null;
43
- const spec = source.slice("npm:".length);
44
- const at = spec.lastIndexOf("@");
45
- return at > 0 ? spec.slice(0, at) : spec;
46
- }
47
-
48
- /**
49
- * Decide which required packages still need installing.
50
- *
51
- * A package is satisfied only when it is both declared in settings (so pi loads
52
- * it) and present in the npm store (so it is installed) — otherwise it needs a
53
- * `pi install` to reconcile.
54
- *
55
- * @param {string[]} required
56
- * @param {{ declaredSources: string[], installedNames: string[] }} state
57
- * @returns {string[]} The sources still to install
58
- */
59
- export function planPackageInstalls(required, { declaredSources, installedNames }) {
60
- const declared = new Set(declaredSources.map(npmSourceName).filter(Boolean));
61
- const installed = new Set(installedNames);
62
- return required.filter((source) => {
63
- const name = npmSourceName(source);
64
- return name === null || !declared.has(name) || !installed.has(name);
65
- });
66
- }
67
-
68
- /**
69
- * Install every required package the pi user scope is missing.
70
- *
71
- * @param {object} options
72
- * @param {string[]} options.required Declared pi sources (see loadRequiredPackages)
73
- * @param {string} options.piRoot Pi user scope dir (default ~/.pi/agent)
74
- * @param {boolean} [options.dryRun] Report the plan without installing
75
- * @param {(source: string) => Promise<{ok: boolean, error: string | null}>} [options.runInstall]
76
- * @param {(msg: string) => void} [options.warn]
77
- * @returns {Promise<{planned: string[], installed: string[], failed: {source: string, error: string}[]}>}
78
- */
79
- export async function provisionPiPackages({
80
- required,
81
- piRoot,
82
- dryRun = false,
83
- runInstall = runPiInstall,
84
- warn = DEFAULT_WARN,
85
- }) {
86
- const declaredSources = await readDeclaredSources(join(piRoot, "settings.json"));
87
- const installedNames = await readInstalledNames(join(piRoot, "npm", "node_modules"));
88
- const planned = planPackageInstalls(required, { declaredSources, installedNames });
89
-
90
- if (dryRun) return { planned, installed: [], failed: [] };
91
-
92
- const installed = [];
93
- const failed = [];
94
- for (const source of planned) {
95
- const result = await runInstall(source);
96
- if (result.ok) {
97
- installed.push(source);
98
- } else {
99
- failed.push({ source, error: result.error });
100
- warn(`could not install pi package ${source}: ${result.error}`);
101
- }
102
- }
103
- return { planned, installed, failed };
104
- }
105
-
106
- /** Install one pi package source through the pi CLI. */
107
- export function runPiInstall(source) {
108
- return new Promise((resolve) => {
109
- const child = spawn("pi", ["install", source], { stdio: ["ignore", "pipe", "pipe"] });
110
- let stderr = "";
111
- child.stdout.resume(); // drain; only failures are interesting
112
- child.stderr.on("data", (chunk) => {
113
- stderr += chunk;
114
- });
115
- child.on("error", (err) => {
116
- resolve({
117
- ok: false,
118
- error: err.code === "ENOENT" ? "pi CLI not found on PATH" : err.message,
119
- });
120
- });
121
- child.on("close", (code) => {
122
- resolve({
123
- ok: code === 0,
124
- error: code === 0 ? null : stderr.trim() || `pi install exited with code ${code}`,
125
- });
126
- });
127
- });
128
- }
129
-
130
- // ─── Pi user-scope inspection ────────────────────────────────────────────────
131
-
132
- /** Read the package sources declared in a pi settings.json (missing or unreadable → none). */
133
- async function readDeclaredSources(settingsPath) {
134
- let settings;
135
- try {
136
- settings = JSON.parse(await readFile(settingsPath, "utf-8"));
137
- } catch {
138
- // Nothing declared yet, or a file pi itself owns and repairs. Provisioning is
139
- // a non-essential step — it must never fail the agent install.
140
- return [];
141
- }
142
- const packages = Array.isArray(settings?.packages) ? settings.packages : [];
143
- return packages
144
- .map((entry) => (typeof entry === "string" ? entry : entry?.source))
145
- .filter((source) => typeof source === "string");
146
- }
147
-
148
- /** List installed package names in the pi npm store (missing dir → none). */
149
- async function readInstalledNames(nodeModulesDir) {
150
- let entries;
151
- try {
152
- entries = await readdir(nodeModulesDir, { withFileTypes: true });
153
- } catch (err) {
154
- if (err.code === "ENOENT") return [];
155
- throw err;
156
- }
157
- const names = [];
158
- for (const entry of entries) {
159
- if (!entry.isDirectory()) continue;
160
- if (!entry.name.startsWith("@")) {
161
- names.push(entry.name);
162
- continue;
163
- }
164
- for (const scoped of await readdir(join(nodeModulesDir, entry.name), { withFileTypes: true })) {
165
- if (scoped.isDirectory()) names.push(`${entry.name}/${scoped.name}`);
166
- }
167
- }
168
- return names;
169
- }