@jaybeeuu/agent-cortex 1.42.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/style-versioning/SKILL.md +78 -8
- package/.github/workflows/ci.yml +51 -38
- package/AGENTS.md +32 -17
- package/CHANGELOG.md +182 -0
- package/README.md +167 -126
- package/agents/README.md +26 -27
- package/agents/ralph/agent.md +7 -10
- package/agents/ralph/claude/polling.md +1 -1
- package/bin/agent-cortex.mjs +46 -44
- package/bin/installers/claude.mjs +2 -2
- package/bin/installers/pi.mjs +885 -237
- package/docs/claude-hooks.md +5 -4
- package/docs/commit-signing.md +231 -0
- package/docs/ideas/enable-signed-commit-enforcement-changesets-bot-commits-must-be-signed.md +10 -1
- package/docs/ideas/extension-manifests.md +5 -0
- package/docs/research/disable-model-invocation.md +82 -0
- package/docs/research/skill-design-research.md +2 -2
- package/docs/skills/skill-anatomy.md +5 -2
- package/docs/technical-direction/harness-config.md +30 -14
- package/docs/technical-direction/migration-execution-labels.md +17 -9
- package/docs/user-preferences.md +2 -2
- package/extensions/agent-modes/README.md +3 -3
- package/extensions/agent-modes/discover.test.ts +19 -0
- package/extensions/agent-modes/discover.ts +31 -9
- package/extensions/session-start/README.md +38 -0
- package/extensions/session-start/index.test.ts +122 -0
- package/extensions/session-start/index.ts +27 -0
- package/extensions/session-start/package.json +19 -0
- package/extensions/session-start/tsconfig.json +12 -0
- package/extensions/skill-stats/pnpm-lock.yaml +329 -0
- package/extensions/subagent/README.md +3 -3
- package/extensions/subagent/agents.test.ts +176 -0
- package/extensions/subagent/agents.ts +101 -11
- package/extensions/subagent/index.ts +2 -1
- package/extensions/subagent/package.json +1 -1
- package/lib/cli.mjs +40 -16
- package/package.json +8 -9
- package/pi/settings.json +1 -7
- package/pnpm-workspace.yaml +6 -0
- package/scripts/check-changeset.mjs +42 -0
- package/scripts/lib/compose-agent.mjs +10 -10
- package/scripts/lib/release-gates.mjs +61 -0
- package/scripts/lib/verify-worker-tree.mjs +107 -0
- package/scripts/resolve-publish-command.mjs +17 -0
- package/skills/planning/create-task/scripts/pnpm-lock.yaml +342 -0
- package/skills/productivity/validate-agent-dir/scripts/pnpm-lock.yaml +329 -0
- package/skills/workflow/git-workflow/SKILL.md +35 -1
- package/skills/workflow/ralph/REFERENCE.md +20 -4
- package/skills/workflow/ralph/SKILL.md +9 -6
- package/skills/workflow/run-pipeline-stage/SKILL.md +11 -4
- package/skills/workflow/run-pipeline-stage/playbooks/code.md +2 -0
- package/skills/workflow/run-pipeline-stage/playbooks/document.md +3 -0
- package/skills/workflow/run-pipeline-stage/playbooks/fix.md +2 -0
- package/skills/workflow/run-pipeline-stage/prompts/stage-runner.md +11 -0
- package/skills/workflow/run-pipeline-stage/scripts/pnpm-lock.yaml +342 -0
- package/test/cli.test.mjs +37 -39
- package/test/helpers/fake-pi.mjs +132 -0
- package/test/pi-installer.test.mjs +345 -286
- package/test/pi-register.test.mjs +342 -0
- package/test/pi-sweep.test.mjs +619 -0
- package/test/release-gates.test.mjs +190 -0
- package/test/verify-worker-tree.test.mjs +120 -0
- package/token-map.README.md +16 -18
- package/token-map.json +29 -33
- package/agents/plan/copilot/frontmatter.json +0 -6
- package/agents/plan.agent.md +0 -59
- package/agents/ralph/copilot/frontmatter.json +0 -6
- package/agents/ralph/copilot/polling.md +0 -62
- package/agents/ralph-plan/copilot/frontmatter.json +0 -6
- package/agents/ralph-plan.agent.md +0 -126
- package/agents/ralph.agent.md +0 -331
- package/agents/strategy/copilot/frontmatter.json +0 -6
- package/agents/strategy.agent.md +0 -112
- package/bin/installers/copilot.mjs +0 -124
- package/extensions/session-start.ts +0 -44
- package/hooks.json +0 -10
- package/lib/pi-packages.mjs +0 -169
- package/plugin.json +0 -35
- package/scripts/build-copilot-agents.mjs +0 -30
- package/scripts/install-instructions.sh +0 -82
- package/scripts/sync-plugin-version.sh +0 -17
- package/test/copilot-installer.test.mjs +0 -308
- package/test/pi-packages.test.mjs +0 -295
|
@@ -0,0 +1,619 @@
|
|
|
1
|
+
// Tests for the legacy sweep step of `agent-cortex install pi`: the
|
|
2
|
+
// pre-staged-model installer wrote rendered agents and skill copies into pi's
|
|
3
|
+
// own user directories (~/.pi/agent/…). Now that the staged package is the
|
|
4
|
+
// single source, those copies are stale — two sources of the same skill is the
|
|
5
|
+
// collision diagnostic the staged model exists to remove.
|
|
6
|
+
//
|
|
7
|
+
// The sweep is a one-shot MIGRATION, not a standing rule: it attributes by name
|
|
8
|
+
// from the package root, so a same-name file is indistinguishable from a
|
|
9
|
+
// rendered copy — which is only acceptable once, and is why it records that it
|
|
10
|
+
// ran and never runs again. pi's extensions directory is never touched: no
|
|
11
|
+
// installer ever wrote there, so anything in it is the user's.
|
|
12
|
+
//
|
|
13
|
+
// Every test runs against a throwaway tree: `root` stands in for the package
|
|
14
|
+
// root (what this package still ships), `piUserDir` for pi's own user directory
|
|
15
|
+
// seeded with the old flow's copies plus entries of the user's own.
|
|
16
|
+
|
|
17
|
+
import { describe, it } from "node:test";
|
|
18
|
+
import assert from "node:assert/strict";
|
|
19
|
+
import { execFile } from "node:child_process";
|
|
20
|
+
import { mkdtemp, mkdir, writeFile, rm, readdir, stat, symlink, chmod } from "node:fs/promises";
|
|
21
|
+
import { tmpdir } from "node:os";
|
|
22
|
+
import { dirname, join } from "node:path";
|
|
23
|
+
import { fileURLToPath } from "node:url";
|
|
24
|
+
import { sweepLegacyPiUserDirs } from "../bin/installers/pi.mjs";
|
|
25
|
+
import { makeFakePi } from "./helpers/fake-pi.mjs";
|
|
26
|
+
|
|
27
|
+
const CLI_PATH = join(dirname(fileURLToPath(import.meta.url)), "..", "bin", "agent-cortex.mjs");
|
|
28
|
+
|
|
29
|
+
function runCli(args, env = {}) {
|
|
30
|
+
return new Promise((resolve) => {
|
|
31
|
+
execFile(process.execPath, [CLI_PATH, ...args], { env: { ...process.env, ...env } }, (error, stdout, stderr) =>
|
|
32
|
+
resolve({ exitCode: error ? (error.code ?? 1) : 0, stdout, stderr }),
|
|
33
|
+
);
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function pathExists(p) {
|
|
38
|
+
try {
|
|
39
|
+
await stat(p);
|
|
40
|
+
return true;
|
|
41
|
+
} catch {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function makeTmp() {
|
|
47
|
+
return mkdtemp(join(tmpdir(), "pi-sweep-"));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Write a `{ relativePath: content }` map below `baseDir`. */
|
|
51
|
+
async function writeFiles(baseDir, files) {
|
|
52
|
+
for (const [rel, content] of Object.entries(files)) {
|
|
53
|
+
const p = join(baseDir, rel);
|
|
54
|
+
await mkdir(dirname(p), { recursive: true });
|
|
55
|
+
await writeFile(p, content);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* A throwaway package root plus pi's user directory, seeded with what the old
|
|
61
|
+
* flow left behind: rendered agents (including the orphaned pipeline-worker),
|
|
62
|
+
* skill copies inside the package's own skill groups, and copied extensions —
|
|
63
|
+
* alongside an agent, a skill, and an extension of the user's own.
|
|
64
|
+
*/
|
|
65
|
+
async function makeSweepTree() {
|
|
66
|
+
const home = await makeTmp();
|
|
67
|
+
const root = join(home, "package-root");
|
|
68
|
+
const piUserDir = join(home, ".pi", "agent");
|
|
69
|
+
|
|
70
|
+
await writeFiles(root, {
|
|
71
|
+
"skills/engineering/tdd/SKILL.md": "---\nname: tdd\n---\n\nBody.\n",
|
|
72
|
+
"skills/workflow/ralph/SKILL.md": "---\nname: ralph\n---\n\nBody.\n",
|
|
73
|
+
"agents/plan/agent.md": "# plan\n",
|
|
74
|
+
"extensions/skill-stats/index.ts": "export default () => {};\n",
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
await writeFiles(piUserDir, {
|
|
78
|
+
"settings.json": "{}\n",
|
|
79
|
+
".agent-cortex-install.json": `${JSON.stringify({ agents: ["plan.agent.md"], skills: ["workflow/ralph/SKILL.md"] })}\n`,
|
|
80
|
+
"agents/plan.agent.md": '---\nname: "agent-cortex:plan"\n---\n\n# plan\n',
|
|
81
|
+
"agents/pipeline-worker.agent.md": '---\nname: "pipeline-worker"\n---\n',
|
|
82
|
+
"agents/mine.agent.md": '---\nname: "mine"\n---\n\n# my agent\n',
|
|
83
|
+
"skills/workflow/ralph/SKILL.md": "---\nname: ralph\n---\n\nBody.\n",
|
|
84
|
+
"skills/productivity/migration/SKILL.md": "---\nname: migration\n---\n\nBody.\n",
|
|
85
|
+
"skills/mine/SKILL.md": "---\nname: mine\n---\n\nBody.\n",
|
|
86
|
+
"extensions/skill-stats/index.ts": "export default () => {};\n",
|
|
87
|
+
"extensions/mine/index.ts": "export default () => {};\n",
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
home,
|
|
92
|
+
root,
|
|
93
|
+
piUserDir,
|
|
94
|
+
// The migration record lives outside pi's user directory, under the home
|
|
95
|
+
// install root (~/.agent-cortex) — never inside the directory it describes.
|
|
96
|
+
recordPath: join(home, ".agent-cortex", "legacy-sweep.json"),
|
|
97
|
+
path: (rel) => join(piUserDir, rel),
|
|
98
|
+
cleanup: () => rm(home, { recursive: true, force: true }),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Sweep the throwaway pi user directory, recording the migration outside it. */
|
|
103
|
+
function sweepTree(tree, options = {}) {
|
|
104
|
+
return sweepLegacyPiUserDirs({
|
|
105
|
+
root: tree.root,
|
|
106
|
+
piUserDir: tree.piUserDir,
|
|
107
|
+
recordPath: tree.recordPath,
|
|
108
|
+
warn: () => {},
|
|
109
|
+
...options,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ─── sweepLegacyPiUserDirs ───────────────────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
describe("sweepLegacyPiUserDirs — agents", () => {
|
|
116
|
+
it("removes the rendered agent copies and the orphan the old flow shipped, keeping the user's own agent", async () => {
|
|
117
|
+
const tree = await makeSweepTree();
|
|
118
|
+
try {
|
|
119
|
+
const result = await sweepTree(tree);
|
|
120
|
+
|
|
121
|
+
assert.equal(await pathExists(tree.path("agents/plan.agent.md")), false, "rendered copy removed");
|
|
122
|
+
assert.equal(await pathExists(tree.path("agents/pipeline-worker.agent.md")), false, "orphan removed");
|
|
123
|
+
assert.equal(await pathExists(tree.path("agents/mine.agent.md")), true, "user agent kept");
|
|
124
|
+
assert.ok(result.removed.includes(tree.path("agents/plan.agent.md")));
|
|
125
|
+
assert.ok(result.kept.includes(tree.path("agents/mine.agent.md")));
|
|
126
|
+
} finally {
|
|
127
|
+
await tree.cleanup();
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// The migration attributes by name, so a copy the user wrote under a name this
|
|
133
|
+
// package ships is indistinguishable from a rendered one. That is accepted for
|
|
134
|
+
// a single run and is exactly why the sweep is one-shot.
|
|
135
|
+
describe("sweepLegacyPiUserDirs — a user's copy of a shipped name", () => {
|
|
136
|
+
it("removes both a hand-written agent and an edited skill copy in the single migration", async () => {
|
|
137
|
+
const tree = await makeSweepTree();
|
|
138
|
+
try {
|
|
139
|
+
await writeFiles(tree.piUserDir, {
|
|
140
|
+
// No GENERATED marker: written by the user, not rendered by an install.
|
|
141
|
+
"agents/plan.agent.md": '---\nname: "my-plan"\n---\n\n# my own plan\n',
|
|
142
|
+
// Edited after the install that rendered it.
|
|
143
|
+
"skills/workflow/ralph/SKILL.md": "---\nname: ralph\n---\n\nMy own edits.\n",
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
await sweepTree(tree);
|
|
147
|
+
|
|
148
|
+
assert.equal(await pathExists(tree.path("agents/plan.agent.md")), false);
|
|
149
|
+
assert.equal(await pathExists(tree.path("skills/workflow/ralph")), false);
|
|
150
|
+
} finally {
|
|
151
|
+
await tree.cleanup();
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("keeps a same-name agent and skill written after the migration", async () => {
|
|
156
|
+
const tree = await makeSweepTree();
|
|
157
|
+
try {
|
|
158
|
+
await sweepTree(tree);
|
|
159
|
+
await writeFiles(tree.piUserDir, {
|
|
160
|
+
"agents/plan.agent.md": '---\nname: "my-plan"\n---\n\n# my own plan\n',
|
|
161
|
+
"skills/workflow/ralph/SKILL.md": "---\nname: ralph\n---\n\nMy own ralph.\n",
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
const second = await sweepTree(tree);
|
|
165
|
+
|
|
166
|
+
assert.deepEqual(second.removed, []);
|
|
167
|
+
assert.equal(await pathExists(tree.path("agents/plan.agent.md")), true);
|
|
168
|
+
assert.equal(await pathExists(tree.path("skills/workflow/ralph/SKILL.md")), true);
|
|
169
|
+
} finally {
|
|
170
|
+
await tree.cleanup();
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
describe("sweepLegacyPiUserDirs — skills", () => {
|
|
176
|
+
it("removes the skill copies inside the package's skill groups — including one it no longer ships — and keeps a skill of the user's own", async () => {
|
|
177
|
+
const tree = await makeSweepTree();
|
|
178
|
+
try {
|
|
179
|
+
await sweepTree(tree);
|
|
180
|
+
|
|
181
|
+
assert.equal(await pathExists(tree.path("skills/workflow/ralph")), false, "rendered copy removed");
|
|
182
|
+
assert.equal(
|
|
183
|
+
await pathExists(tree.path("skills/productivity/migration")),
|
|
184
|
+
false,
|
|
185
|
+
"copy of a skill this package no longer ships removed",
|
|
186
|
+
);
|
|
187
|
+
assert.equal(await pathExists(tree.path("skills/mine/SKILL.md")), true, "user skill kept");
|
|
188
|
+
} finally {
|
|
189
|
+
await tree.cleanup();
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("removes the skill-group directories it empties, leaving the ones that still hold a user's skill", async () => {
|
|
194
|
+
const tree = await makeSweepTree();
|
|
195
|
+
try {
|
|
196
|
+
await writeFiles(tree.piUserDir, { "skills/workflow/mine/SKILL.md": "---\nname: mine\n---\n" });
|
|
197
|
+
|
|
198
|
+
await sweepTree(tree);
|
|
199
|
+
|
|
200
|
+
assert.equal(await pathExists(tree.path("skills/productivity")), false, "emptied group dir removed");
|
|
201
|
+
assert.equal(await pathExists(tree.path("skills/workflow")), true, "group dir holding a user skill kept");
|
|
202
|
+
} finally {
|
|
203
|
+
await tree.cleanup();
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("leaves a group directory that was already empty alone", async () => {
|
|
208
|
+
const tree = await makeSweepTree();
|
|
209
|
+
try {
|
|
210
|
+
await mkdir(tree.path("skills/planning"), { recursive: true });
|
|
211
|
+
|
|
212
|
+
await sweepTree(tree);
|
|
213
|
+
|
|
214
|
+
assert.equal(await pathExists(tree.path("skills/planning")), true, "a directory this sweep did not empty stays");
|
|
215
|
+
} finally {
|
|
216
|
+
await tree.cleanup();
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("does not follow a symlinked skill group out of pi's user directory", async () => {
|
|
221
|
+
const tree = await makeSweepTree();
|
|
222
|
+
try {
|
|
223
|
+
await writeFiles(tree.home, { "shared-skills/ralph/SKILL.md": "---\nname: ralph\n---\n\nBody.\n" });
|
|
224
|
+
await rm(tree.path("skills/workflow"), { recursive: true, force: true });
|
|
225
|
+
await symlink(join(tree.home, "shared-skills"), tree.path("skills/workflow"), "dir");
|
|
226
|
+
|
|
227
|
+
await sweepTree(tree);
|
|
228
|
+
|
|
229
|
+
assert.equal(await pathExists(join(tree.home, "shared-skills/ralph/SKILL.md")), true, "link target untouched");
|
|
230
|
+
assert.equal(await pathExists(tree.path("skills/workflow")), true, "the link itself stays");
|
|
231
|
+
} finally {
|
|
232
|
+
await tree.cleanup();
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
// No agent-cortex installer has ever written into pi's extensions directory —
|
|
238
|
+
// this repo's own extension READMEs tell users to put the shipped names there —
|
|
239
|
+
// so the directory holds only user state and the sweep does not enter it.
|
|
240
|
+
describe("sweepLegacyPiUserDirs — extensions", () => {
|
|
241
|
+
it("never touches pi's extensions directory, not even a copy of a shipped extension", async () => {
|
|
242
|
+
const tree = await makeSweepTree();
|
|
243
|
+
try {
|
|
244
|
+
const result = await sweepTree(tree);
|
|
245
|
+
|
|
246
|
+
assert.equal(await pathExists(tree.path("extensions/skill-stats/index.ts")), true, "shipped name kept");
|
|
247
|
+
assert.equal(await pathExists(tree.path("extensions/mine/index.ts")), true, "user extension kept");
|
|
248
|
+
assert.equal(result.removed.some((path) => path.includes("extensions")), false, "nothing reported in that tree");
|
|
249
|
+
} finally {
|
|
250
|
+
await tree.cleanup();
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
describe("sweepLegacyPiUserDirs — run modes", () => {
|
|
256
|
+
it("reports the removals without deleting anything on a dry run", async () => {
|
|
257
|
+
const tree = await makeSweepTree();
|
|
258
|
+
try {
|
|
259
|
+
const result = await sweepTree(tree, { dryRun: true });
|
|
260
|
+
|
|
261
|
+
assert.ok(result.removed.includes(tree.path("agents/plan.agent.md")));
|
|
262
|
+
assert.ok(result.removed.includes(tree.path("skills/workflow/ralph")));
|
|
263
|
+
assert.equal(await pathExists(tree.path("agents/plan.agent.md")), true, "nothing deleted");
|
|
264
|
+
assert.equal(await pathExists(tree.path("skills/workflow/ralph")), true, "nothing deleted");
|
|
265
|
+
assert.equal(result.removed.includes(tree.path("extensions/skill-stats")), false);
|
|
266
|
+
} finally {
|
|
267
|
+
await tree.cleanup();
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it("writes nothing and records nothing on a dry run", async () => {
|
|
272
|
+
const tree = await makeSweepTree();
|
|
273
|
+
try {
|
|
274
|
+
await sweepTree(tree, { dryRun: true });
|
|
275
|
+
|
|
276
|
+
assert.equal(await pathExists(tree.recordPath), false);
|
|
277
|
+
} finally {
|
|
278
|
+
await tree.cleanup();
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it("leaves pi's own user directory alone when nothing was written there", async () => {
|
|
283
|
+
const home = await makeTmp();
|
|
284
|
+
try {
|
|
285
|
+
const result = await sweepLegacyPiUserDirs({
|
|
286
|
+
root: join(home, "package-root"),
|
|
287
|
+
piUserDir: join(home, "absent"),
|
|
288
|
+
recordPath: join(home, ".agent-cortex", "legacy-sweep.json"),
|
|
289
|
+
warn: () => {},
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
assert.deepEqual(result.removed, []);
|
|
293
|
+
assert.deepEqual(await readdir(home), [], "nothing created");
|
|
294
|
+
} finally {
|
|
295
|
+
await rm(home, { recursive: true, force: true });
|
|
296
|
+
}
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
// The record is what makes the sweep one-shot, so it must live outside the
|
|
301
|
+
// directory it describes (~/.pi/agent may hold no agent-cortex artifact after
|
|
302
|
+
// the migration) and must only claim a migration that actually finished.
|
|
303
|
+
describe("sweepLegacyPiUserDirs — the migration record", () => {
|
|
304
|
+
it("records the migration outside pi's user directory, leaving no agent-cortex artifact in it", async () => {
|
|
305
|
+
const tree = await makeSweepTree();
|
|
306
|
+
try {
|
|
307
|
+
await sweepTree(tree);
|
|
308
|
+
|
|
309
|
+
assert.equal(
|
|
310
|
+
await pathExists(tree.path(".agent-cortex-legacy-sweep.json")),
|
|
311
|
+
false,
|
|
312
|
+
"nothing is written into pi's user directory",
|
|
313
|
+
);
|
|
314
|
+
assert.equal(await pathExists(tree.recordPath), true, "the migration is recorded outside it");
|
|
315
|
+
assert.equal(await pathExists(tree.path("agents/mine.agent.md")), true, "the user's own agent is untouched");
|
|
316
|
+
} finally {
|
|
317
|
+
await tree.cleanup();
|
|
318
|
+
}
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it("gates per pi user directory, so a second directory still migrates once", async () => {
|
|
322
|
+
const tree = await makeSweepTree();
|
|
323
|
+
try {
|
|
324
|
+
await sweepTree(tree);
|
|
325
|
+
const otherPiUserDir = join(tree.home, "other-pi", "agent");
|
|
326
|
+
await writeFiles(otherPiUserDir, { "agents/plan.agent.md": '---\nname: "agent-cortex:plan"\n---\n' });
|
|
327
|
+
|
|
328
|
+
const other = await sweepLegacyPiUserDirs({
|
|
329
|
+
root: tree.root,
|
|
330
|
+
piUserDir: otherPiUserDir,
|
|
331
|
+
recordPath: tree.recordPath,
|
|
332
|
+
warn: () => {},
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
assert.deepEqual(other.removed, [join(otherPiUserDir, "agents", "plan.agent.md")]);
|
|
336
|
+
assert.deepEqual((await sweepTree(tree)).removed, [], "the first directory stays migrated");
|
|
337
|
+
} finally {
|
|
338
|
+
await tree.cleanup();
|
|
339
|
+
}
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it("does not re-run the migration when the record cannot be read", async () => {
|
|
343
|
+
const tree = await makeSweepTree();
|
|
344
|
+
try {
|
|
345
|
+
await writeFiles(tree.home, { ".agent-cortex/legacy-sweep.json": "{ not json" });
|
|
346
|
+
|
|
347
|
+
const result = await sweepTree(tree);
|
|
348
|
+
|
|
349
|
+
assert.deepEqual(result.removed, [], "an ambiguous record never deletes");
|
|
350
|
+
assert.equal(await pathExists(tree.path("agents/plan.agent.md")), true);
|
|
351
|
+
} finally {
|
|
352
|
+
await tree.cleanup();
|
|
353
|
+
}
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it("warns instead of failing when the record cannot be written", async () => {
|
|
357
|
+
const tree = await makeSweepTree();
|
|
358
|
+
const recordDir = dirname(tree.recordPath);
|
|
359
|
+
const warnings = [];
|
|
360
|
+
try {
|
|
361
|
+
await mkdir(recordDir, { recursive: true });
|
|
362
|
+
await chmod(recordDir, 0o555);
|
|
363
|
+
|
|
364
|
+
const result = await sweepTree(tree, { warn: (msg) => warnings.push(msg) });
|
|
365
|
+
|
|
366
|
+
assert.ok(result.removed.includes(tree.path("agents/plan.agent.md")), "the migration still ran");
|
|
367
|
+
assert.equal(warnings.length, 1);
|
|
368
|
+
assert.ok(warnings[0].includes(tree.recordPath), "the warning names the record that could not be written");
|
|
369
|
+
} finally {
|
|
370
|
+
await chmod(recordDir, 0o755);
|
|
371
|
+
await tree.cleanup();
|
|
372
|
+
}
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it("records nothing when an artifact could not be removed, and retries the remainder on the next install", async () => {
|
|
376
|
+
const tree = await makeSweepTree();
|
|
377
|
+
const blockedGroup = tree.path("skills/workflow");
|
|
378
|
+
try {
|
|
379
|
+
await chmod(blockedGroup, 0o555);
|
|
380
|
+
await sweepTree(tree);
|
|
381
|
+
|
|
382
|
+
assert.equal(await pathExists(tree.path("skills/workflow/ralph")), true, "the copy survived");
|
|
383
|
+
assert.equal(await pathExists(tree.recordPath), false, "an incomplete migration is not recorded");
|
|
384
|
+
} finally {
|
|
385
|
+
await chmod(blockedGroup, 0o755);
|
|
386
|
+
}
|
|
387
|
+
try {
|
|
388
|
+
await sweepTree(tree);
|
|
389
|
+
|
|
390
|
+
assert.equal(await pathExists(tree.path("skills/workflow/ralph")), false, "the retry removes the remainder");
|
|
391
|
+
assert.equal(await pathExists(tree.recordPath), true, "the completed migration is recorded");
|
|
392
|
+
} finally {
|
|
393
|
+
await tree.cleanup();
|
|
394
|
+
}
|
|
395
|
+
});
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
// A directory the sweep cannot read is not an empty one. These tests make a
|
|
399
|
+
// directory unreadable (mode 000, the leftover-sudo shape) and assert the sweep
|
|
400
|
+
// refuses to record the migration it could not complete, so the next install
|
|
401
|
+
// retries once the permissions allow it. Root ignores file modes, so they skip
|
|
402
|
+
// when the suite runs as root.
|
|
403
|
+
const skipAsRoot = process.getuid?.() === 0 ? "file modes do not restrict root" : false;
|
|
404
|
+
|
|
405
|
+
describe("sweepLegacyPiUserDirs — a directory the sweep cannot read", () => {
|
|
406
|
+
it("does not record the migration when a skill group cannot be listed, and retries once it can", { skip: skipAsRoot }, async () => {
|
|
407
|
+
const tree = await makeSweepTree();
|
|
408
|
+
const blockedGroup = tree.path("skills/workflow");
|
|
409
|
+
const warnings = [];
|
|
410
|
+
try {
|
|
411
|
+
await chmod(blockedGroup, 0o000);
|
|
412
|
+
await sweepTree(tree, { warn: (msg) => warnings.push(msg) });
|
|
413
|
+
} finally {
|
|
414
|
+
await chmod(blockedGroup, 0o755);
|
|
415
|
+
}
|
|
416
|
+
try {
|
|
417
|
+
assert.equal(await pathExists(tree.path("skills/workflow/ralph")), true, "the copy it could not see survived");
|
|
418
|
+
assert.equal(await pathExists(tree.recordPath), false, "a migration that could not list is not recorded");
|
|
419
|
+
assert.ok(
|
|
420
|
+
warnings.some((warning) => warning.includes(blockedGroup)),
|
|
421
|
+
"the warning names the directory it could not list",
|
|
422
|
+
);
|
|
423
|
+
|
|
424
|
+
await sweepTree(tree);
|
|
425
|
+
|
|
426
|
+
assert.equal(await pathExists(tree.path("skills/workflow/ralph")), false, "the retry removes the copy");
|
|
427
|
+
assert.equal(await pathExists(tree.recordPath), true, "the completed migration is recorded");
|
|
428
|
+
} finally {
|
|
429
|
+
await tree.cleanup();
|
|
430
|
+
}
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
it("does not record the migration when pi's skills directory cannot be listed", { skip: skipAsRoot }, async () => {
|
|
434
|
+
const tree = await makeSweepTree();
|
|
435
|
+
const skillsDir = tree.path("skills");
|
|
436
|
+
const warnings = [];
|
|
437
|
+
try {
|
|
438
|
+
await chmod(skillsDir, 0o000);
|
|
439
|
+
await sweepTree(tree, { warn: (msg) => warnings.push(msg) });
|
|
440
|
+
} finally {
|
|
441
|
+
await chmod(skillsDir, 0o755);
|
|
442
|
+
}
|
|
443
|
+
try {
|
|
444
|
+
assert.equal(await pathExists(tree.path("skills/workflow/ralph/SKILL.md")), true, "the copies survived");
|
|
445
|
+
assert.equal(await pathExists(tree.recordPath), false, "a migration that could not list is not recorded");
|
|
446
|
+
assert.ok(
|
|
447
|
+
warnings.some((warning) => warning.includes(skillsDir)),
|
|
448
|
+
"the warning names the directory it could not list",
|
|
449
|
+
);
|
|
450
|
+
} finally {
|
|
451
|
+
await tree.cleanup();
|
|
452
|
+
}
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
it("does not record the migration when pi's agents directory cannot be listed", { skip: skipAsRoot }, async () => {
|
|
456
|
+
const tree = await makeSweepTree();
|
|
457
|
+
const agentsDir = tree.path("agents");
|
|
458
|
+
const warnings = [];
|
|
459
|
+
try {
|
|
460
|
+
await chmod(agentsDir, 0o000);
|
|
461
|
+
await sweepTree(tree, { warn: (msg) => warnings.push(msg) });
|
|
462
|
+
} finally {
|
|
463
|
+
await chmod(agentsDir, 0o755);
|
|
464
|
+
}
|
|
465
|
+
try {
|
|
466
|
+
assert.equal(await pathExists(tree.path("agents/plan.agent.md")), true, "the copy it could not see survived");
|
|
467
|
+
assert.equal(await pathExists(tree.recordPath), false, "a migration that could not list is not recorded");
|
|
468
|
+
assert.ok(
|
|
469
|
+
warnings.some((warning) => warning.includes(agentsDir)),
|
|
470
|
+
"the warning names the directory it could not list",
|
|
471
|
+
);
|
|
472
|
+
} finally {
|
|
473
|
+
await tree.cleanup();
|
|
474
|
+
}
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
it("does not record the migration when the package root cannot be listed", { skip: skipAsRoot }, async () => {
|
|
478
|
+
const tree = await makeSweepTree();
|
|
479
|
+
const shippedSkills = join(tree.root, "skills");
|
|
480
|
+
const warnings = [];
|
|
481
|
+
try {
|
|
482
|
+
await chmod(shippedSkills, 0o000);
|
|
483
|
+
await sweepTree(tree, { warn: (msg) => warnings.push(msg) });
|
|
484
|
+
} finally {
|
|
485
|
+
await chmod(shippedSkills, 0o755);
|
|
486
|
+
}
|
|
487
|
+
try {
|
|
488
|
+
assert.equal(
|
|
489
|
+
await pathExists(tree.path("skills/workflow/ralph")),
|
|
490
|
+
true,
|
|
491
|
+
"nothing is removed while attribution is unavailable",
|
|
492
|
+
);
|
|
493
|
+
assert.equal(await pathExists(tree.recordPath), false, "a migration that could not attribute is not recorded");
|
|
494
|
+
assert.ok(
|
|
495
|
+
warnings.some((warning) => warning.includes(shippedSkills)),
|
|
496
|
+
"the warning names the package-root directory it could not list",
|
|
497
|
+
);
|
|
498
|
+
} finally {
|
|
499
|
+
await tree.cleanup();
|
|
500
|
+
}
|
|
501
|
+
});
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
describe("sweepLegacyPiUserDirs — the path record left in pi's user directory", () => {
|
|
505
|
+
it("removes a stale `.agent-cortex-install.json` along with the copies it lists, and leaves pi's config alone", async () => {
|
|
506
|
+
const tree = await makeSweepTree();
|
|
507
|
+
try {
|
|
508
|
+
await sweepTree(tree);
|
|
509
|
+
|
|
510
|
+
assert.equal(await pathExists(tree.path(".agent-cortex-install.json")), false, "stale record removed");
|
|
511
|
+
assert.equal(await pathExists(tree.path("settings.json")), true, "pi's config untouched");
|
|
512
|
+
} finally {
|
|
513
|
+
await tree.cleanup();
|
|
514
|
+
}
|
|
515
|
+
});
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
// ─── CLI wiring ──────────────────────────────────────────────────────────────
|
|
519
|
+
|
|
520
|
+
/** Seed pi's user directory inside a fake HOME with two legacy artifacts. */
|
|
521
|
+
async function seedLegacyArtifacts(home) {
|
|
522
|
+
await writeFiles(join(home, ".pi", "agent"), {
|
|
523
|
+
"agents/pipeline-worker.agent.md": '---\nname: "pipeline-worker"\n---\n',
|
|
524
|
+
"skills/workflow/ralph/SKILL.md": "---\nname: ralph\n---\n\nBody.\n",
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
describe("install pi CLI: legacy sweep", () => {
|
|
529
|
+
it("sweeps the old artifacts once the staged package is registered", async () => {
|
|
530
|
+
const home = await makeTmp();
|
|
531
|
+
const bin = await makeTmp();
|
|
532
|
+
try {
|
|
533
|
+
await makeFakePi(bin);
|
|
534
|
+
await seedLegacyArtifacts(home);
|
|
535
|
+
|
|
536
|
+
const { exitCode } = await runCli(["install", "pi"], { HOME: home, PATH: `${bin}:${process.env.PATH}` });
|
|
537
|
+
|
|
538
|
+
assert.equal(exitCode, 0);
|
|
539
|
+
assert.equal(await pathExists(join(home, ".pi", "agent", "agents", "pipeline-worker.agent.md")), false);
|
|
540
|
+
assert.equal(await pathExists(join(home, ".pi", "agent", "skills", "workflow", "ralph")), false);
|
|
541
|
+
assert.equal(
|
|
542
|
+
await pathExists(join(home, ".agent-cortex", "pi", "skills", "workflow", "ralph", "SKILL.md")),
|
|
543
|
+
true,
|
|
544
|
+
"the staged package carries the skill",
|
|
545
|
+
);
|
|
546
|
+
assert.equal(
|
|
547
|
+
await pathExists(join(home, ".pi", "agent", ".agent-cortex-legacy-sweep.json")),
|
|
548
|
+
false,
|
|
549
|
+
"pi's user directory holds no agent-cortex artifact after the migration",
|
|
550
|
+
);
|
|
551
|
+
assert.equal(
|
|
552
|
+
await pathExists(join(home, ".agent-cortex", "legacy-sweep.json")),
|
|
553
|
+
true,
|
|
554
|
+
"the migration is recorded under the home install root",
|
|
555
|
+
);
|
|
556
|
+
} finally {
|
|
557
|
+
await rm(home, { recursive: true, force: true });
|
|
558
|
+
await rm(bin, { recursive: true, force: true });
|
|
559
|
+
}
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
it("does not sweep a same-name artifact the user writes after that first migration", async () => {
|
|
563
|
+
const home = await makeTmp();
|
|
564
|
+
const bin = await makeTmp();
|
|
565
|
+
try {
|
|
566
|
+
await makeFakePi(bin);
|
|
567
|
+
await seedLegacyArtifacts(home);
|
|
568
|
+
const env = { HOME: home, PATH: `${bin}:${process.env.PATH}` };
|
|
569
|
+
await runCli(["install", "pi"], env);
|
|
570
|
+
await writeFiles(join(home, ".pi", "agent"), {
|
|
571
|
+
"agents/pipeline-worker.agent.md": '---\nname: "mine"\n---\n',
|
|
572
|
+
"skills/workflow/ralph/SKILL.md": "---\nname: mine\n---\n",
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
const { exitCode } = await runCli(["install", "pi"], env);
|
|
576
|
+
|
|
577
|
+
assert.equal(exitCode, 0);
|
|
578
|
+
assert.equal(await pathExists(join(home, ".pi", "agent", "agents", "pipeline-worker.agent.md")), true);
|
|
579
|
+
assert.equal(await pathExists(join(home, ".pi", "agent", "skills", "workflow", "ralph")), true);
|
|
580
|
+
} finally {
|
|
581
|
+
await rm(home, { recursive: true, force: true });
|
|
582
|
+
await rm(bin, { recursive: true, force: true });
|
|
583
|
+
}
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
it("keeps the old artifacts when the staged package could not be registered", async () => {
|
|
587
|
+
const home = await makeTmp();
|
|
588
|
+
try {
|
|
589
|
+
await seedLegacyArtifacts(home);
|
|
590
|
+
|
|
591
|
+
const { exitCode } = await runCli(["install", "pi"], { HOME: home, PATH: "/nonexistent" });
|
|
592
|
+
|
|
593
|
+
assert.equal(exitCode, 0);
|
|
594
|
+
assert.equal(
|
|
595
|
+
await pathExists(join(home, ".pi", "agent", "skills", "workflow", "ralph")),
|
|
596
|
+
true,
|
|
597
|
+
"nothing swept while the staged package is not registered",
|
|
598
|
+
);
|
|
599
|
+
} finally {
|
|
600
|
+
await rm(home, { recursive: true, force: true });
|
|
601
|
+
}
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
it("keeps the old artifacts on the stage-only form", async () => {
|
|
605
|
+
const home = await makeTmp();
|
|
606
|
+
const out = await makeTmp();
|
|
607
|
+
try {
|
|
608
|
+
await seedLegacyArtifacts(home);
|
|
609
|
+
|
|
610
|
+
const { exitCode } = await runCli(["install", "pi", "--output", out], { HOME: home });
|
|
611
|
+
|
|
612
|
+
assert.equal(exitCode, 0);
|
|
613
|
+
assert.equal(await pathExists(join(home, ".pi", "agent", "agents", "pipeline-worker.agent.md")), true);
|
|
614
|
+
} finally {
|
|
615
|
+
await rm(home, { recursive: true, force: true });
|
|
616
|
+
await rm(out, { recursive: true, force: true });
|
|
617
|
+
}
|
|
618
|
+
});
|
|
619
|
+
});
|