pi-session-mcp 0.7.1 → 0.8.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 (59) hide show
  1. package/.pi-session-mcp-cli/config.js +47 -7
  2. package/.pi-session-mcp-cli/doctor.js +68 -2
  3. package/.pi-session-mcp-cli/execution-profile.js +1 -0
  4. package/.pi-session-mcp-cli/setup-result.js +8 -0
  5. package/.pi-session-mcp-cli/skill-catalog.js +35 -0
  6. package/.pi-session-mcp-cli/skill-customize.js +183 -0
  7. package/.pi-session-mcp-cli/skill-library.js +120 -0
  8. package/CHANGELOG.md +85 -0
  9. package/README.md +15 -3
  10. package/dist/capabilities.d.ts +1 -0
  11. package/dist/capabilities.js +2 -1
  12. package/dist/client-adapters/mcp-verifier.js +6 -2
  13. package/dist/config.d.ts +4 -0
  14. package/dist/config.js +47 -7
  15. package/dist/doctor.d.ts +4 -1
  16. package/dist/doctor.js +68 -2
  17. package/dist/execution-profile.d.ts +4 -0
  18. package/dist/execution-profile.js +1 -0
  19. package/dist/pi-adapter.d.ts +1 -1
  20. package/dist/pi-adapter.js +1 -0
  21. package/dist/pi-skills.d.ts +17 -0
  22. package/dist/pi-skills.js +64 -0
  23. package/dist/sdk-pi-adapter.d.ts +7 -3
  24. package/dist/sdk-pi-adapter.js +54 -11
  25. package/dist/server.js +1 -0
  26. package/dist/setup-cli.d.ts +2 -0
  27. package/dist/setup-cli.js +16 -0
  28. package/dist/setup-main.d.ts +1 -1
  29. package/dist/setup-main.js +18 -3
  30. package/dist/setup-ownership.d.ts +6 -0
  31. package/dist/setup-ownership.js +29 -5
  32. package/dist/setup-skills.d.ts +37 -0
  33. package/dist/setup-skills.js +353 -0
  34. package/dist/setup.d.ts +7 -0
  35. package/dist/setup.js +49 -6
  36. package/dist/skill-catalog.d.ts +26 -0
  37. package/dist/skill-catalog.js +35 -0
  38. package/dist/skill-customize.d.ts +23 -0
  39. package/dist/skill-customize.js +183 -0
  40. package/dist/skill-library.d.ts +61 -0
  41. package/dist/skill-library.js +120 -0
  42. package/docs/acceptance.md +243 -1
  43. package/docs/adr/0006-pi-extension-allowlist.md +3 -0
  44. package/docs/adr/0007-profile-skill-selection.md +217 -0
  45. package/docs/architecture.md +9 -5
  46. package/docs/client-setup-design.md +133 -1
  47. package/docs/client-setup.md +74 -3
  48. package/docs/doctor-design.md +9 -1
  49. package/docs/installation.md +122 -5
  50. package/docs/pi-extensions-design.md +17 -4
  51. package/docs/session-mcp.md +7 -2
  52. package/docs/skills.md +487 -0
  53. package/docs/threat-model.md +19 -2
  54. package/docs/tool-contracts.md +8 -4
  55. package/package.json +2 -1
  56. package/pi-session-mcp.example.json +9 -3
  57. package/skills/pi-session-implementation/SKILL.md +42 -0
  58. package/skills/pi-session-orchestration/SKILL.md +93 -0
  59. package/skills/pi-session-review/SKILL.md +58 -0
@@ -0,0 +1,353 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { lstat, mkdir, open, realpath, rename, rm } from "node:fs/promises";
3
+ import { basename, dirname, join } from "node:path";
4
+ import { CONTROLLER_SKILLS, readSkillTree, resolveLibrarySkill, skillOverrideDirectory } from "./skill-library.js";
5
+ /** Whether a fingerprint is setup's own copy: the owned one, or the one a pending update replaces. */
6
+ function ownsFingerprint(record, fingerprint) {
7
+ return fingerprint === record.fingerprint || (record.phase === "pending" && record.replaces === fingerprint);
8
+ }
9
+ /** Paths remain internal. Ownership identifies a client slot, never its location. */
10
+ export class FileSetupSkillInstaller {
11
+ roots;
12
+ constructor(roots) {
13
+ this.roots = roots;
14
+ }
15
+ async run(operation, target, ownership) {
16
+ const findings = [];
17
+ for (const name of CONTROLLER_SKILLS)
18
+ findings.push(await this.skill(operation, target, name, ownership));
19
+ return findings;
20
+ }
21
+ async owns(target, ownership) {
22
+ // Claude Code user and local share one slot: while the other registration exists, its skill
23
+ // is not this target's to clean up.
24
+ if (target.client === "claude-code" && target.scope !== "project"
25
+ && await ownership.get({ ...target, scope: target.scope === "local" ? "user" : "local" }))
26
+ return false;
27
+ for (const name of CONTROLLER_SKILLS)
28
+ if (await ownership.get((await this.location(target, name)).owned))
29
+ return true;
30
+ return false;
31
+ }
32
+ /**
33
+ * The slot path and its ownership key. A project may be untrusted: only its root is
34
+ * canonicalized, and a link below it (for example `.claude` pointing elsewhere) makes the
35
+ * slot divergent, so setup never writes outside the project. In the operator's home the
36
+ * client's skills directory may be the operator's own link, for example into a dotfiles
37
+ * checkout, so it is followed. The skill entry itself is never followed in either place.
38
+ * A project slot's ownership is bound to an opaque, path-free identity of the project, so a
39
+ * remove in another project never touches this project's copy.
40
+ */
41
+ async location(target, name) {
42
+ const client = target.client === "codex" ? ".agents" : ".claude";
43
+ if (target.scope === "project") {
44
+ const project = await realpath(this.roots.cwd);
45
+ const identity = createHash("sha256").update(project).digest("hex").slice(0, 16);
46
+ return { path: join(project, client, "skills", name), owned: { client: target.client, scope: "project", alias: `skill:${name}@${identity}` } };
47
+ }
48
+ return { path: join(await canonicalExisting(join(this.roots.home, client, "skills")), name), owned: { client: target.client, scope: "user", alias: `skill:${name}` } };
49
+ }
50
+ library(name) {
51
+ return resolveLibrarySkill(name, { overrideDirectory: skillOverrideDirectory(this.roots.configPath), packageRoot: this.roots.packageRoot });
52
+ }
53
+ async skill(operation, target, name, ownership) {
54
+ const finding = (code, status) => ({ target: { ...target, alias: name }, code, status });
55
+ let location;
56
+ try {
57
+ location = await this.location(target, name);
58
+ }
59
+ catch {
60
+ return finding(operation === "verify" ? "skill_missing" :
61
+ operation === "dry-run" ? "skill_source_invalid" :
62
+ operation === "apply" ? "skill_install_failed" : "skill_divergent", operation === "remove" || operation === "rollback" ? "divergent" : "failed");
63
+ }
64
+ const { path, owned: ownedTarget } = location;
65
+ const library = this.library(name);
66
+ // Only a usable library skill can stand behind an accepted link; otherwise verify and apply would disagree.
67
+ const usable = library ? await readSkillTree(library.directory).then(() => true, () => false) : false;
68
+ const linkTarget = usable ? await realpath(library.directory).catch(() => undefined) : undefined;
69
+ if (operation === "remove" || operation === "rollback") {
70
+ try {
71
+ const owned = await ownership.get(ownedTarget);
72
+ if (!owned)
73
+ return finding("skill_not_owned", "unchanged");
74
+ if (target.client === "claude-code" && target.scope !== "project") {
75
+ const other = await ownership.get({ ...target, scope: target.scope === "local" ? "user" : "local" });
76
+ if (other?.phase === "owned")
77
+ return finding("skill_kept_shared", "unchanged");
78
+ }
79
+ const current = await inspectSlot(path, linkTarget);
80
+ if (current.state === "absent") {
81
+ healthy(ownership);
82
+ await ownership.delete(ownedTarget);
83
+ healthy(ownership);
84
+ return finding("skill_absent", "ok");
85
+ }
86
+ if (current.state === "linked") {
87
+ // The operator replaced setup's copy with their own link: the record is stale, the link stays.
88
+ healthy(ownership);
89
+ await ownership.delete(ownedTarget);
90
+ healthy(ownership);
91
+ return finding("skill_not_owned", "unchanged");
92
+ }
93
+ if (current.state !== "tree" || !ownsFingerprint(owned, current.fingerprint))
94
+ return finding("skill_divergent", "divergent");
95
+ healthy(ownership);
96
+ if (!await sameSlot(path, current, linkTarget))
97
+ return finding("skill_divergent", "divergent");
98
+ healthy(ownership);
99
+ await rm(path, { recursive: true });
100
+ healthy(ownership);
101
+ await syncDirectory(dirname(path));
102
+ healthy(ownership);
103
+ await ownership.delete(ownedTarget);
104
+ healthy(ownership);
105
+ return finding("skill_removed", "ok");
106
+ }
107
+ catch {
108
+ return finding("skill_divergent", "divergent");
109
+ }
110
+ }
111
+ if (operation === "verify") {
112
+ const current = await inspectSlot(path, linkTarget);
113
+ if (current.state === "absent")
114
+ return finding("skill_missing", "failed");
115
+ if (current.state === "linked")
116
+ return finding("skill_verified", "ok");
117
+ try {
118
+ if (!library)
119
+ throw new Error("skill_source_invalid");
120
+ const desired = await readSkillTree(library.directory);
121
+ return current.state === "tree" && current.fingerprint === desired.fingerprint
122
+ ? finding("skill_verified", "ok") : finding("skill_not_equivalent", "failed");
123
+ }
124
+ catch {
125
+ return finding("skill_not_equivalent", "failed");
126
+ }
127
+ }
128
+ let desired;
129
+ try {
130
+ if (!library)
131
+ throw new Error("skill_source_invalid");
132
+ desired = await readSkillTree(library.directory);
133
+ }
134
+ catch {
135
+ return finding("skill_source_invalid", "failed");
136
+ }
137
+ try {
138
+ const current = await inspectSlot(path, linkTarget);
139
+ const owned = await ownership.get(ownedTarget);
140
+ if (current.state === "linked") {
141
+ if (operation === "apply" && owned) {
142
+ healthy(ownership);
143
+ await ownership.delete(ownedTarget);
144
+ healthy(ownership);
145
+ }
146
+ return finding("skill_linked", "unchanged");
147
+ }
148
+ if (current.state === "divergent")
149
+ return finding("skill_divergent", "divergent");
150
+ // A copy setup did not write, or its own copy modified since, is never replaced.
151
+ if (current.state === "tree" && (!owned || !ownsFingerprint(owned, current.fingerprint))) {
152
+ if (!owned && current.fingerprint === desired.fingerprint)
153
+ return finding(operation === "apply" ? "skill_already_equivalent" : "skill_equivalent", "unchanged");
154
+ return finding("skill_divergent", "divergent");
155
+ }
156
+ if (current.state === "tree" && current.fingerprint === desired.fingerprint) {
157
+ if (operation === "apply" && owned?.phase === "pending") {
158
+ healthy(ownership);
159
+ await ownership.put(ownedRecord(owned, desired.fingerprint));
160
+ healthy(ownership);
161
+ }
162
+ return finding(operation === "apply" ? "skill_already_equivalent" : "skill_equivalent", "unchanged");
163
+ }
164
+ if (operation === "dry-run")
165
+ return current.state === "absent" ? finding("skill_absent", "absent") : finding("skill_update_planned", "planned");
166
+ // The pending record keeps the replaced fingerprint, so an interrupted update leaves a copy
167
+ // that setup can still finish or remove.
168
+ const pending = {
169
+ target: ownedTarget, fingerprint: desired.fingerprint, previous: "absent", phase: "pending", transactionId: randomUUID(),
170
+ ...(current.state === "tree" ? { replaces: current.fingerprint } : {}),
171
+ };
172
+ healthy(ownership);
173
+ await ownership.put(pending);
174
+ healthy(ownership);
175
+ await this.replace(path, name, desired, current, ownership);
176
+ healthy(ownership);
177
+ const after = await inspectSlot(path);
178
+ if (after.state !== "tree" || after.fingerprint !== desired.fingerprint)
179
+ throw new Error("skill_post_install_mismatch");
180
+ healthy(ownership);
181
+ await ownership.put(ownedRecord(pending, desired.fingerprint));
182
+ healthy(ownership);
183
+ return finding(current.state === "absent" ? "skill_installed" : "skill_updated", "ok");
184
+ }
185
+ catch {
186
+ return finding(operation === "apply" ? "skill_install_failed" : "skill_source_invalid", "failed");
187
+ }
188
+ }
189
+ async replace(path, name, desired, current, ownership) {
190
+ const parent = dirname(path);
191
+ healthy(ownership);
192
+ await ensureParents(parent, ownership);
193
+ healthy(ownership);
194
+ const temporary = join(parent, `.pi-session-mcp-${name}-${randomUUID()}`);
195
+ const backup = join(parent, `.pi-session-mcp-${name}-${randomUUID()}`);
196
+ let staged = false;
197
+ try {
198
+ healthy(ownership);
199
+ await mkdir(temporary, { mode: 0o755 });
200
+ staged = true;
201
+ for (const file of desired.files) {
202
+ healthy(ownership);
203
+ const destination = join(temporary, ...file.path.split("/"));
204
+ await ensureParents(dirname(destination), ownership);
205
+ healthy(ownership);
206
+ const handle = await open(destination, "wx", 0o644);
207
+ try {
208
+ await handle.writeFile(file.bytes);
209
+ await handle.sync();
210
+ }
211
+ finally {
212
+ await handle.close();
213
+ }
214
+ }
215
+ await syncTreeDirectories(temporary, desired, ownership);
216
+ healthy(ownership);
217
+ if (!await sameSlot(path, current))
218
+ throw new Error("skill_slot_changed");
219
+ healthy(ownership);
220
+ if (current.state === "tree") {
221
+ await rename(path, backup);
222
+ healthy(ownership);
223
+ await syncDirectory(parent);
224
+ healthy(ownership);
225
+ }
226
+ await rename(temporary, path);
227
+ staged = false;
228
+ healthy(ownership);
229
+ await syncDirectory(parent);
230
+ healthy(ownership);
231
+ const after = await inspectSlot(path);
232
+ if (after.state !== "tree" || after.fingerprint !== desired.fingerprint)
233
+ throw new Error("skill_post_install_mismatch");
234
+ if (current.state === "tree") {
235
+ // Delete only the exact old tree moved by this transaction.
236
+ if (!await sameSlot(backup, current))
237
+ throw new Error("skill_backup_changed");
238
+ healthy(ownership);
239
+ await rm(backup, { recursive: true });
240
+ healthy(ownership);
241
+ await syncDirectory(parent);
242
+ }
243
+ }
244
+ finally {
245
+ // An unhealthy fence retains staged/backup content for conservative recovery.
246
+ if (staged) {
247
+ healthy(ownership);
248
+ await rm(temporary, { recursive: true, force: true });
249
+ }
250
+ }
251
+ }
252
+ }
253
+ function healthy(ownership) { ownership.assertHealthy?.(); }
254
+ /** The owned form of a record, without the replaced fingerprint of a finished update. */
255
+ function ownedRecord(record, fingerprint) {
256
+ return { target: record.target, fingerprint, previous: record.previous, phase: "owned", transactionId: record.transactionId };
257
+ }
258
+ /** The real path of the nearest existing ancestor, with the not yet existing tail appended. */
259
+ async function canonicalExisting(path) {
260
+ const missing = [];
261
+ let current = path;
262
+ for (;;) {
263
+ try {
264
+ return join(await realpath(current), ...missing);
265
+ }
266
+ catch (error) {
267
+ if (error.code !== "ENOENT" || dirname(current) === current)
268
+ throw error;
269
+ missing.unshift(basename(current));
270
+ current = dirname(current);
271
+ }
272
+ }
273
+ }
274
+ /** Reject symlink parents too, so inspecting a slot never traverses one. */
275
+ async function safeParents(directory) {
276
+ if (dirname(directory) !== directory)
277
+ await safeParents(dirname(directory));
278
+ try {
279
+ const info = await lstat(directory);
280
+ if (!info.isDirectory() || info.isSymbolicLink())
281
+ throw new Error("skill_parent_invalid");
282
+ }
283
+ catch (error) {
284
+ if (error.code !== "ENOENT")
285
+ throw error;
286
+ }
287
+ }
288
+ async function inspectSlot(path, linkTarget) {
289
+ try {
290
+ await safeParents(dirname(path));
291
+ const info = await lstat(path);
292
+ if (info.isSymbolicLink()) {
293
+ return linkTarget !== undefined && await realpath(path).then((target) => target === linkTarget, () => false) ? { state: "linked" } : { state: "divergent" };
294
+ }
295
+ if (!info.isDirectory())
296
+ return { state: "divergent" };
297
+ // Hidden entries are operator content: setup never writes them, and the fingerprint would not protect them.
298
+ const tree = await readSkillTree(path, { hidden: "reject" });
299
+ const after = await lstat(path);
300
+ if (!after.isDirectory() || after.isSymbolicLink() || after.dev !== info.dev || after.ino !== info.ino)
301
+ return { state: "divergent" };
302
+ return { state: "tree", fingerprint: tree.fingerprint, dev: info.dev, ino: info.ino };
303
+ }
304
+ catch (error) {
305
+ return { state: error.code === "ENOENT" ? "absent" : "divergent" };
306
+ }
307
+ }
308
+ async function sameSlot(path, expected, linkTarget) {
309
+ const actual = await inspectSlot(path, linkTarget);
310
+ return expected.state === "absent" ? actual.state === "absent" :
311
+ expected.state === "tree" && actual.state === "tree" && actual.fingerprint === expected.fingerprint && actual.dev === expected.dev && actual.ino === expected.ino;
312
+ }
313
+ async function ensureParents(directory, ownership) {
314
+ if (dirname(directory) !== directory)
315
+ await ensureParents(dirname(directory), ownership);
316
+ try {
317
+ const info = await lstat(directory);
318
+ if (!info.isDirectory() || info.isSymbolicLink())
319
+ throw new Error("skill_parent_invalid");
320
+ }
321
+ catch (error) {
322
+ if (error.code !== "ENOENT")
323
+ throw error;
324
+ healthy(ownership);
325
+ await mkdir(directory, { mode: 0o755 });
326
+ healthy(ownership);
327
+ await syncDirectory(directory);
328
+ await syncDirectory(dirname(directory));
329
+ }
330
+ }
331
+ async function syncDirectory(directory) {
332
+ const handle = await open(directory, "r");
333
+ try {
334
+ await handle.sync();
335
+ }
336
+ finally {
337
+ await handle.close();
338
+ }
339
+ }
340
+ async function syncTreeDirectories(root, tree, ownership) {
341
+ const directories = new Set([root]);
342
+ for (const file of tree.files) {
343
+ let cursor = dirname(join(root, file.path));
344
+ while (cursor !== root) {
345
+ directories.add(cursor);
346
+ cursor = dirname(cursor);
347
+ }
348
+ }
349
+ for (const directory of [...directories].sort((a, b) => b.length - a.length)) {
350
+ healthy(ownership);
351
+ await syncDirectory(directory);
352
+ }
353
+ }
package/dist/setup.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { CommandExecution, CommandRunner, RegistrationIntent, RegistrationState } from "./client-adapters/types.js";
2
2
  import { type OwnershipStore } from "./setup-ownership.js";
3
+ import { type SetupSkillInstaller } from "./setup-skills.js";
3
4
  import { type SetupOperation, type SetupResult, type SetupTarget } from "./setup-result.js";
4
5
  export interface SetupClientAdapter {
5
6
  readonly name: string;
@@ -25,6 +26,7 @@ export interface SetupDependencies {
25
26
  readonly adapters?: readonly SetupClientAdapter[];
26
27
  readonly doctor?: SetupDoctor;
27
28
  readonly ownership?: OwnershipStore;
29
+ readonly skills?: SetupSkillInstaller;
28
30
  readonly intendedFingerprint: string;
29
31
  }
30
32
  export interface SetupRequest {
@@ -35,8 +37,13 @@ export declare const isSupportedTarget: (target: SetupTarget) => boolean;
35
37
  export declare class SetupOrchestrator {
36
38
  private readonly dependencies;
37
39
  private readonly ownership;
40
+ /** Apply results that the up-front plan refused: nothing was applied, so no skill is touched. */
41
+ private readonly refusedApplies;
38
42
  constructor(dependencies: SetupDependencies);
39
43
  run(request: SetupRequest): Promise<SetupResult>;
44
+ /** Registration planning gates apply; skill divergence must not block registration. */
45
+ private withSkills;
46
+ private restoreWithSkills;
40
47
  private adapter;
41
48
  private assertOwnershipHealthy;
42
49
  private signal;
package/dist/setup.js CHANGED
@@ -8,6 +8,7 @@ import { createMcpStdioLauncher } from "./setup-process.js";
8
8
  import { verifyMcpCapabilities } from "./client-adapters/mcp-verifier.js";
9
9
  import { PACKAGE_VERSION, readLockfile, readPackageMetadata } from "./package-metadata.js";
10
10
  import { DurableOwnershipStore, MemoryOwnershipStore } from "./setup-ownership.js";
11
+ import { FileSetupSkillInstaller } from "./setup-skills.js";
11
12
  import { result } from "./setup-result.js";
12
13
  export const isSupportedTarget = (target) => target.alias === "pi-session-mcp" &&
13
14
  (target.client === "codex" ? target.scope === "user" :
@@ -15,6 +16,8 @@ export const isSupportedTarget = (target) => target.alias === "pi-session-mcp" &
15
16
  export class SetupOrchestrator {
16
17
  dependencies;
17
18
  ownership;
19
+ /** Apply results that the up-front plan refused: nothing was applied, so no skill is touched. */
20
+ refusedApplies = new WeakSet();
18
21
  constructor(dependencies) {
19
22
  this.dependencies = dependencies;
20
23
  this.ownership = dependencies.ownership ?? new MemoryOwnershipStore();
@@ -25,14 +28,40 @@ export class SetupOrchestrator {
25
28
  if (targets.length !== request.targets.length || targets.length === 0)
26
29
  return result(operation, [{ target: { client: "invalid", scope: "invalid", alias: "invalid" }, status: "failed", code: "target_invalid" }], "failed");
27
30
  if (operation === "dry-run")
28
- return this.plan(targets, this.ownership);
31
+ return this.withSkills(await this.plan(targets, this.ownership), this.ownership);
29
32
  if (operation === "apply")
30
- return this.ownership.transaction((ownership) => this.apply(targets, ownership));
33
+ return this.ownership.transaction(async (ownership) => this.withSkills(await this.apply(targets, ownership), ownership));
31
34
  if (operation === "verify")
32
- return this.ownership.transaction((ownership) => this.verify(targets, ownership));
35
+ return this.ownership.transaction(async (ownership) => this.withSkills(await this.verify(targets, ownership), ownership));
33
36
  if (operation === "rollback")
34
- return this.ownership.transaction((ownership) => this.restore(targets, "rolled_back", ownership));
35
- return this.ownership.transaction((ownership) => this.restore(targets, "removed", ownership));
37
+ return this.ownership.transaction((ownership) => this.restoreWithSkills(targets, "rolled_back", ownership));
38
+ return this.ownership.transaction((ownership) => this.restoreWithSkills(targets, "removed", ownership));
39
+ }
40
+ /** Registration planning gates apply; skill divergence must not block registration. */
41
+ async withSkills(registrations, ownership) {
42
+ const skills = this.dependencies.skills;
43
+ if (!skills || this.refusedApplies.has(registrations))
44
+ return registrations;
45
+ const findings = [];
46
+ for (const finding of registrations.findings) {
47
+ findings.push(finding);
48
+ const operation = registrations.operation;
49
+ const canRun = operation === "dry-run" || operation === "verify" ||
50
+ (operation === "apply" ? ["ok", "unchanged"].includes(finding.status) : finding.status === "ok");
51
+ if (canRun)
52
+ findings.push(...await skills.run(operation, finding.target, ownership));
53
+ }
54
+ const failed = findings.some((finding) => ["failed", "unsupported", "divergent"].includes(finding.status));
55
+ return result(registrations.operation, findings, failed ? "failed" : registrations.status, failed ? 1 : registrations.exitCode);
56
+ }
57
+ async restoreWithSkills(targets, success, ownership) {
58
+ if (!this.dependencies.skills)
59
+ return this.restore(targets, success, ownership);
60
+ const findings = [];
61
+ // Process each removal and its skill before removing another shared registration.
62
+ for (const target of targets)
63
+ findings.push(...(await this.withSkills(await this.restore([target], success, ownership), ownership)).findings);
64
+ return result(success === "removed" ? "remove" : "rollback", findings);
36
65
  }
37
66
  adapter(t) { return this.dependencies.adapters?.find((a) => a.name === t.client && a.supportedScopes.includes(t.scope)); }
38
67
  assertOwnershipHealthy(ownership) { ownership.assertHealthy?.(); }
@@ -84,7 +113,9 @@ export class SetupOrchestrator {
84
113
  const findings = plan.findings.map((finding) => finding.status === "divergent" && finding.code === "divergent"
85
114
  ? { ...finding, code: "divergence_requires_replacement" }
86
115
  : finding);
87
- return result("apply", findings, "failed");
116
+ const refused = result("apply", findings, "failed");
117
+ this.refusedApplies.add(refused);
118
+ return refused;
88
119
  }
89
120
  const findings = [];
90
121
  for (const target of targets) {
@@ -205,6 +236,17 @@ export class SetupOrchestrator {
205
236
  findings.push({ target, status: "unsupported", code: "client_unsupported" });
206
237
  continue;
207
238
  }
239
+ if (!owned && this.dependencies.skills && await this.safe(() => this.dependencies.skills.owns(target, ownership))) {
240
+ // Registration removal can finish before skill cleanup (or before a crash). Retry only
241
+ // for a skill setup still owns, and only after proving the registration is absent; an
242
+ // unowned live registration stays untouched.
243
+ const current = await this.safe(() => adapter.inspect(target, this.dependencies.intendedFingerprint));
244
+ this.assertOwnershipHealthy(ownership);
245
+ if (current?.state === "absent") {
246
+ findings.push({ target, status: "ok", code: success });
247
+ continue;
248
+ }
249
+ }
208
250
  if (!owned || (owned.phase !== "owned" && owned.phase !== "pending")) {
209
251
  findings.push({ target, status: "failed", code: "ownership_unavailable" });
210
252
  continue;
@@ -320,6 +362,7 @@ export async function createProductionSetup(runner, packageRoot, configPath, own
320
362
  return { ok: doctor.ok, code: doctor.ok ? "doctor_ok" : "doctor_failed" };
321
363
  },
322
364
  },
365
+ skills: new FileSetupSkillInstaller({ packageRoot, configPath, home: homedir(), cwd: process.cwd() }),
323
366
  ownership: new DurableOwnershipStore(ownershipPath, { ...(signal ? { signal } : {}) }),
324
367
  intendedFingerprint: await fingerprintFor(referenceTarget),
325
368
  };
@@ -0,0 +1,26 @@
1
+ /**
2
+ * A catalog skill that an execution profile selects. The alias is the only public name;
3
+ * the path stays inside the server and never appears in MCP output, errors or diagnostics.
4
+ */
5
+ export interface SkillSelection {
6
+ /** The operator's `skillCatalog` alias. Path-free; it may differ from the skill's own name. */
7
+ readonly alias: string;
8
+ /** Absolute path of the selected `SKILL.md`, resolved against the configuration file's directory. */
9
+ readonly path: string;
10
+ }
11
+ export declare const MAX_SKILL_CATALOG_ENTRIES = 64;
12
+ export declare const MAX_PROFILE_SKILLS = 16;
13
+ export declare const MAX_SKILL_PATH_LENGTH = 4096;
14
+ /** Upper bound for one selected `SKILL.md`, checked before the Pi SDK reads it. */
15
+ export declare const MAX_SKILL_FILE_BYTES: number;
16
+ export declare const SKILL_FILE_NAME = "SKILL.md";
17
+ /** The library skill name of a `builtin:<name>` catalog value; undefined for every other value. */
18
+ export declare function builtinSkillName(value: string): string | undefined;
19
+ /**
20
+ * Whether a configured catalog value names exactly one local `SKILL.md`: its last path segment
21
+ * is `SKILL.md`, and it is no URL or other scheme-prefixed source, no `~` home shorthand, no
22
+ * glob and no Windows-style path. So it can never stand for a directory, a pattern or a
23
+ * remote source. Relative values are resolved later against the configuration file's directory.
24
+ * The one scheme-prefixed form accepted is `builtin:<name>`, which names a library skill.
25
+ */
26
+ export declare function isSkillCatalogPath(value: string): boolean;
@@ -0,0 +1,35 @@
1
+ import { BUILTIN_SKILL_PREFIX, SKILL_NAME_PATTERN } from "./skill-library.js";
2
+ export const MAX_SKILL_CATALOG_ENTRIES = 64;
3
+ export const MAX_PROFILE_SKILLS = 16;
4
+ export const MAX_SKILL_PATH_LENGTH = 4096;
5
+ /** Upper bound for one selected `SKILL.md`, checked before the Pi SDK reads it. */
6
+ export const MAX_SKILL_FILE_BYTES = 256 * 1024;
7
+ export const SKILL_FILE_NAME = "SKILL.md";
8
+ /** `npm:`, `git+ssh:`, `https:`, `file:` and any other scheme-prefixed source. */
9
+ const SCHEME_PREFIX = /^[A-Za-z][A-Za-z0-9+.-]*:/;
10
+ const GLOB_CHARACTERS = /[*?[\]{}]/;
11
+ /** The library skill name of a `builtin:<name>` catalog value; undefined for every other value. */
12
+ export function builtinSkillName(value) {
13
+ if (!value.startsWith(BUILTIN_SKILL_PREFIX))
14
+ return undefined;
15
+ const name = value.slice(BUILTIN_SKILL_PREFIX.length);
16
+ return SKILL_NAME_PATTERN.test(name) ? name : undefined;
17
+ }
18
+ /**
19
+ * Whether a configured catalog value names exactly one local `SKILL.md`: its last path segment
20
+ * is `SKILL.md`, and it is no URL or other scheme-prefixed source, no `~` home shorthand, no
21
+ * glob and no Windows-style path. So it can never stand for a directory, a pattern or a
22
+ * remote source. Relative values are resolved later against the configuration file's directory.
23
+ * The one scheme-prefixed form accepted is `builtin:<name>`, which names a library skill.
24
+ */
25
+ export function isSkillCatalogPath(value) {
26
+ if (value.length === 0 || value.length > MAX_SKILL_PATH_LENGTH)
27
+ return false;
28
+ if (value.includes("\0") || value.includes("\\") || value.startsWith("~"))
29
+ return false;
30
+ if (builtinSkillName(value) !== undefined)
31
+ return true;
32
+ if (SCHEME_PREFIX.test(value) || GLOB_CHARACTERS.test(value))
33
+ return false;
34
+ return value.split("/").at(-1) === SKILL_FILE_NAME;
35
+ }
@@ -0,0 +1,23 @@
1
+ import { type SetupResult } from "./setup-result.js";
2
+ /**
3
+ * `--customize-skill <name>`: copies a shipped skill into the operator's override directory as a
4
+ * starting point, and records the shipped fingerprint it was based on. The operator's files are
5
+ * never overwritten or edited; a changed original is only reported and acknowledged by moving
6
+ * the recorded base. Reports carry the skill name and a code, never a path or file content.
7
+ */
8
+ export declare const SKILL_BASES_FILE = ".pi-session-mcp-bases.json";
9
+ export declare const MAX_SKILL_BASES_BYTES: number;
10
+ export declare class SkillBasesError extends Error {
11
+ constructor();
12
+ }
13
+ export type SkillBases = Readonly<Record<string, string>>;
14
+ /** Strictly validated contents of the bases file; an absent file or override directory is an empty record. */
15
+ export declare function readSkillBases(overrideDirectory: string): Promise<SkillBases>;
16
+ export interface SkillCustomizeRequest {
17
+ readonly name: string;
18
+ readonly configPath: string;
19
+ readonly apply: boolean;
20
+ /** Package root that holds the shipped `skills/` directory; defaults to this package. */
21
+ readonly packageRoot?: string;
22
+ }
23
+ export declare function customizeSkill(request: SkillCustomizeRequest): Promise<SetupResult>;