pi-session-mcp 0.7.1 → 0.8.1

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 (66) hide show
  1. package/.pi-session-mcp-cli/config.js +47 -7
  2. package/.pi-session-mcp-cli/doctor.js +75 -2
  3. package/.pi-session-mcp-cli/execution-profile.js +1 -0
  4. package/.pi-session-mcp-cli/override-lock.js +125 -0
  5. package/.pi-session-mcp-cli/setup-platform.js +195 -0
  6. package/.pi-session-mcp-cli/setup-result.js +8 -0
  7. package/.pi-session-mcp-cli/skill-catalog.js +35 -0
  8. package/.pi-session-mcp-cli/skill-customize.js +268 -0
  9. package/.pi-session-mcp-cli/skill-library.js +188 -0
  10. package/CHANGELOG.md +176 -0
  11. package/README.md +15 -3
  12. package/dist/capabilities.d.ts +1 -0
  13. package/dist/capabilities.js +2 -1
  14. package/dist/client-adapters/mcp-verifier.js +6 -2
  15. package/dist/config.d.ts +4 -0
  16. package/dist/config.js +47 -7
  17. package/dist/doctor.d.ts +7 -1
  18. package/dist/doctor.js +75 -2
  19. package/dist/execution-profile.d.ts +4 -0
  20. package/dist/execution-profile.js +1 -0
  21. package/dist/override-lock.d.ts +34 -0
  22. package/dist/override-lock.js +125 -0
  23. package/dist/pi-adapter.d.ts +7 -5
  24. package/dist/pi-adapter.js +1 -0
  25. package/dist/pi-skills.d.ts +17 -0
  26. package/dist/pi-skills.js +64 -0
  27. package/dist/sdk-pi-adapter.d.ts +7 -3
  28. package/dist/sdk-pi-adapter.js +82 -16
  29. package/dist/server.js +1 -0
  30. package/dist/session-registry.d.ts +8 -6
  31. package/dist/session-registry.js +3 -2
  32. package/dist/setup-cli.d.ts +2 -0
  33. package/dist/setup-cli.js +16 -0
  34. package/dist/setup-main.d.ts +1 -1
  35. package/dist/setup-main.js +18 -3
  36. package/dist/setup-ownership.d.ts +9 -0
  37. package/dist/setup-ownership.js +32 -5
  38. package/dist/setup-skills.d.ts +76 -0
  39. package/dist/setup-skills.js +586 -0
  40. package/dist/setup.d.ts +7 -0
  41. package/dist/setup.js +49 -6
  42. package/dist/skill-catalog.d.ts +26 -0
  43. package/dist/skill-catalog.js +35 -0
  44. package/dist/skill-customize.d.ts +41 -0
  45. package/dist/skill-customize.js +268 -0
  46. package/dist/skill-library.d.ts +106 -0
  47. package/dist/skill-library.js +188 -0
  48. package/docs/acceptance.md +593 -1
  49. package/docs/adr/0006-pi-extension-allowlist.md +3 -0
  50. package/docs/adr/0007-profile-skill-selection.md +228 -0
  51. package/docs/architecture.md +10 -6
  52. package/docs/client-setup-design.md +219 -1
  53. package/docs/client-setup.md +86 -3
  54. package/docs/doctor-design.md +9 -1
  55. package/docs/installation.md +128 -5
  56. package/docs/pi-extensions-design.md +28 -4
  57. package/docs/releasing.md +18 -9
  58. package/docs/session-mcp.md +7 -2
  59. package/docs/skills.md +616 -0
  60. package/docs/threat-model.md +19 -2
  61. package/docs/tool-contracts.md +9 -5
  62. package/package.json +2 -1
  63. package/pi-session-mcp.example.json +9 -3
  64. package/skills/pi-session-implementation/SKILL.md +42 -0
  65. package/skills/pi-session-orchestration/SKILL.md +94 -0
  66. package/skills/pi-session-review/SKILL.md +58 -0
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,41 @@
1
+ import { type OverrideLockOptions } from "./override-lock.js";
2
+ import { type SetupResult } from "./setup-result.js";
3
+ import { type ReleasedSkillTree } from "./skill-library.js";
4
+ /**
5
+ * `--customize-skill <name>`: copies a shipped skill into the operator's override directory as a
6
+ * starting point, and records the shipped fingerprint it was based on. The operator's files are
7
+ * never overwritten or edited; a changed original is only reported and acknowledged by moving
8
+ * the recorded base. Reports carry the skill name and a code, never a path or file content.
9
+ *
10
+ * Applying is a cross-process transaction: everything from reading the bases file to replacing it
11
+ * runs under the kernel lock of `override-lock.ts`, so two runs for different skills cannot lose
12
+ * each other's base. A dry run takes no lock and writes nothing.
13
+ */
14
+ export declare const SKILL_BASES_FILE = ".pi-session-mcp-bases.json";
15
+ export declare const MAX_SKILL_BASES_BYTES: number;
16
+ export declare class SkillBasesError extends Error {
17
+ constructor();
18
+ }
19
+ /** Per skill, a version 2 fingerprint or a legacy (version 1) one that could not be migrated. */
20
+ export type SkillBases = Readonly<Record<string, string>>;
21
+ /** Strictly validated contents of the bases file; an absent file or override directory is an empty record. */
22
+ export declare function readSkillBases(overrideDirectory: string): Promise<SkillBases>;
23
+ export interface SkillCustomizeRequest {
24
+ readonly name: string;
25
+ readonly configPath: string;
26
+ readonly apply: boolean;
27
+ /** Package root that holds the shipped `skills/` directory; defaults to this package. */
28
+ readonly packageRoot?: string;
29
+ }
30
+ /** Injection points for tests; production callers pass none. */
31
+ export interface SkillCustomizeDependencies {
32
+ /** Reference trees that resolve legacy bases; defaults to the released shipped skills. */
33
+ readonly released?: readonly ReleasedSkillTree[];
34
+ /** Lock acquisition bound and platform seams. */
35
+ readonly lock?: OverrideLockOptions;
36
+ /** Runs while the lock is held, before the bases file is read. */
37
+ readonly afterLock?: () => Promise<void> | void;
38
+ /** Runs while the lock is held, after the new bases content is durable and before it replaces the old file. */
39
+ readonly beforeBasesRename?: () => Promise<void> | void;
40
+ }
41
+ export declare function customizeSkill(request: SkillCustomizeRequest, dependencies?: SkillCustomizeDependencies): Promise<SetupResult>;
@@ -0,0 +1,268 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { constants } from "node:fs";
3
+ import { lstat, mkdir, open, readFile, rename, rm, unlink } from "node:fs/promises";
4
+ import { dirname, join } from "node:path";
5
+ import { acquireOverrideLock, OverrideLockError } from "./override-lock.js";
6
+ import { result } from "./setup-result.js";
7
+ import { LEGACY_SKILL_TREE_FINGERPRINT_PATTERN, readSkillTree, releasedSkillFingerprint, shippedSkillsDirectory, SKILL_NAME_PATTERN, SKILL_TREE_FINGERPRINT_PATTERN, SkillTreeError, skillOverrideDirectory, } from "./skill-library.js";
8
+ /**
9
+ * `--customize-skill <name>`: copies a shipped skill into the operator's override directory as a
10
+ * starting point, and records the shipped fingerprint it was based on. The operator's files are
11
+ * never overwritten or edited; a changed original is only reported and acknowledged by moving
12
+ * the recorded base. Reports carry the skill name and a code, never a path or file content.
13
+ *
14
+ * Applying is a cross-process transaction: everything from reading the bases file to replacing it
15
+ * runs under the kernel lock of `override-lock.ts`, so two runs for different skills cannot lose
16
+ * each other's base. A dry run takes no lock and writes nothing.
17
+ */
18
+ export const SKILL_BASES_FILE = ".pi-session-mcp-bases.json";
19
+ export const MAX_SKILL_BASES_BYTES = 64 * 1024;
20
+ /** Written by this version; version 1 (0.8.0) holds only legacy fingerprints and is still read. */
21
+ const BASES_SCHEMA_VERSION = 2;
22
+ export class SkillBasesError extends Error {
23
+ constructor() {
24
+ super("Skill bases file is not usable");
25
+ this.name = "SkillBasesError";
26
+ }
27
+ }
28
+ /** Strictly validated contents of the bases file; an absent file or override directory is an empty record. */
29
+ export async function readSkillBases(overrideDirectory) {
30
+ const path = join(overrideDirectory, SKILL_BASES_FILE);
31
+ let text;
32
+ try {
33
+ const info = await lstat(path);
34
+ if (!info.isFile() || info.size > MAX_SKILL_BASES_BYTES)
35
+ throw new SkillBasesError();
36
+ const bytes = await readFile(path);
37
+ if (bytes.length > MAX_SKILL_BASES_BYTES)
38
+ throw new SkillBasesError();
39
+ text = bytes.toString("utf8");
40
+ }
41
+ catch (error) {
42
+ if (error instanceof SkillBasesError)
43
+ throw error;
44
+ if (typeof error === "object" && error !== null && error.code === "ENOENT")
45
+ return Object.freeze({});
46
+ throw new SkillBasesError();
47
+ }
48
+ let parsed;
49
+ try {
50
+ parsed = JSON.parse(text);
51
+ }
52
+ catch {
53
+ throw new SkillBasesError();
54
+ }
55
+ if (!isPlainObject(parsed) || Object.keys(parsed).sort().join() !== "bases,schemaVersion")
56
+ throw new SkillBasesError();
57
+ const version = parsed.schemaVersion;
58
+ if (version !== 1 && version !== BASES_SCHEMA_VERSION)
59
+ throw new SkillBasesError();
60
+ const bases = parsed.bases;
61
+ if (!isPlainObject(bases))
62
+ throw new SkillBasesError();
63
+ const entries = Object.entries(bases);
64
+ const validValue = (value) => typeof value === "string" && (LEGACY_SKILL_TREE_FINGERPRINT_PATTERN.test(value) || (version === BASES_SCHEMA_VERSION && SKILL_TREE_FINGERPRINT_PATTERN.test(value)));
65
+ if (!entries.every(([name, value]) => SKILL_NAME_PATTERN.test(name) && validValue(value)))
66
+ throw new SkillBasesError();
67
+ return Object.freeze(Object.fromEntries(entries));
68
+ }
69
+ function isPlainObject(value) {
70
+ if (typeof value !== "object" || value === null || Array.isArray(value))
71
+ return false;
72
+ const prototype = Object.getPrototypeOf(value);
73
+ return prototype === Object.prototype || prototype === null;
74
+ }
75
+ async function syncDirectory(path) {
76
+ const handle = await open(path, "r");
77
+ try {
78
+ await handle.sync();
79
+ }
80
+ finally {
81
+ await handle.close();
82
+ }
83
+ }
84
+ /**
85
+ * Atomic replacement: a private temporary file in the same directory, fsynced, then a rename and a
86
+ * directory fsync. Every provable legacy entry is migrated to its version 2 value on the way; an
87
+ * entry that no released tree names stays verbatim. A failure before the rename leaves the
88
+ * previous file untouched.
89
+ */
90
+ async function writeSkillBases(overrideDirectory, bases, released, beforeRename) {
91
+ const migrated = Object.entries(bases).map(([name, value]) => [name, LEGACY_SKILL_TREE_FINGERPRINT_PATTERN.test(value) ? releasedSkillFingerprint(name, value, released) ?? value : value]);
92
+ const sorted = Object.fromEntries(migrated.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)));
93
+ const content = Buffer.from(`${JSON.stringify({ schemaVersion: BASES_SCHEMA_VERSION, bases: sorted })}\n`, "utf8");
94
+ if (content.length > MAX_SKILL_BASES_BYTES)
95
+ throw new SkillBasesError();
96
+ const temporary = join(overrideDirectory, `.${SKILL_BASES_FILE.slice(1)}.${randomBytes(6).toString("hex")}.tmp`);
97
+ const handle = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600);
98
+ try {
99
+ try {
100
+ await handle.writeFile(content);
101
+ await handle.sync();
102
+ }
103
+ finally {
104
+ await handle.close();
105
+ }
106
+ await beforeRename?.();
107
+ await rename(temporary, join(overrideDirectory, SKILL_BASES_FILE));
108
+ }
109
+ catch (error) {
110
+ await unlink(temporary).catch(() => undefined);
111
+ throw error;
112
+ }
113
+ await syncDirectory(overrideDirectory);
114
+ }
115
+ function isMissing(error) {
116
+ return typeof error === "object" && error !== null && error.code === "ENOENT";
117
+ }
118
+ function report(request, status, code) {
119
+ // The name is echoed only when it is a valid skill name, so arbitrary input never reaches a report.
120
+ const alias = SKILL_NAME_PATTERN.test(request.name) ? request.name : "invalid";
121
+ const target = { client: "skills", scope: "override", alias };
122
+ const finding = { target, status, code };
123
+ // A planned change reports `planned` overall, like a registration dry-run.
124
+ return result(request.apply ? "apply" : "dry-run", [finding], status === "planned" ? "planned" : undefined);
125
+ }
126
+ const OUTDATED = Object.freeze({ kind: "record", planned: "skill_override_outdated", applied: "skill_base_updated" });
127
+ /** Pure with respect to the library: reads the override tree, never changes anything. */
128
+ async function decide(name, overridePath, shipped, bases, released) {
129
+ try {
130
+ if (!(await lstat(overridePath)).isDirectory())
131
+ return { kind: "failed", code: "skill_override_invalid" };
132
+ }
133
+ catch (error) {
134
+ if (!isMissing(error))
135
+ throw error;
136
+ return { kind: "copy", planned: "skill_customize_planned", applied: "skill_customized" };
137
+ }
138
+ let override;
139
+ try {
140
+ override = await readSkillTree(overridePath);
141
+ }
142
+ catch (error) {
143
+ if (error instanceof SkillTreeError)
144
+ return { kind: "failed", code: "skill_override_invalid" };
145
+ throw error;
146
+ }
147
+ const base = Object.hasOwn(bases, name) ? bases[name] : undefined;
148
+ // Without a base an identical override is a customization whose bases commit failed earlier.
149
+ if (base === undefined)
150
+ return override.fingerprint === shipped.fingerprint ? { kind: "record", planned: "skill_customize_planned", applied: "skill_customized" } : OUTDATED;
151
+ if (SKILL_TREE_FINGERPRINT_PATTERN.test(base))
152
+ return base === shipped.fingerprint ? { kind: "current" } : OUTDATED;
153
+ // A legacy base only names a released original when a trusted reference tree has that value.
154
+ const original = releasedSkillFingerprint(name, base, released);
155
+ if (original === undefined)
156
+ return { kind: "record", planned: "skill_base_unverified", applied: "skill_base_updated" };
157
+ return original === shipped.fingerprint ? { kind: "record", planned: "skill_base_migration_planned", applied: "skill_base_migrated" } : OUTDATED;
158
+ }
159
+ export async function customizeSkill(request, dependencies = {}) {
160
+ const done = (status, code) => report(request, status, code);
161
+ if (!SKILL_NAME_PATTERN.test(request.name))
162
+ return done("failed", "skill_not_shipped");
163
+ try {
164
+ const shippedDirectory = join(shippedSkillsDirectory(request.packageRoot), request.name);
165
+ let shipped;
166
+ try {
167
+ shipped = await readSkillTree(shippedDirectory);
168
+ }
169
+ catch (error) {
170
+ if (error instanceof SkillTreeError && error.code === "skill_tree_missing")
171
+ return done("failed", "skill_not_shipped");
172
+ return done("failed", "skill_customize_failed");
173
+ }
174
+ const overrideDirectory = skillOverrideDirectory(request.configPath);
175
+ const overridePath = join(overrideDirectory, request.name);
176
+ const read = async () => {
177
+ try {
178
+ return await readSkillBases(overrideDirectory);
179
+ }
180
+ catch (error) {
181
+ if (error instanceof SkillBasesError)
182
+ return undefined;
183
+ throw error;
184
+ }
185
+ };
186
+ if (!request.apply) {
187
+ // No lock and no write: the report is a snapshot of what an apply would decide.
188
+ const bases = await read();
189
+ if (bases === undefined)
190
+ return done("failed", "skill_bases_corrupt");
191
+ const decision = await decide(request.name, overridePath, shipped, bases, dependencies.released);
192
+ if (decision.kind === "failed")
193
+ return done("failed", decision.code);
194
+ return decision.kind === "current" ? done("unchanged", "skill_override_current") : done("planned", decision.planned);
195
+ }
196
+ await mkdir(overrideDirectory, { recursive: true, mode: 0o700 });
197
+ let lock;
198
+ try {
199
+ lock = await acquireOverrideLock(overrideDirectory, dependencies.lock);
200
+ }
201
+ catch (error) {
202
+ if (error instanceof OverrideLockError)
203
+ return done("failed", error.code === "override_lock_busy" ? "skill_customize_lock_busy" : "skill_customize_lock_unavailable");
204
+ throw error;
205
+ }
206
+ try {
207
+ await dependencies.afterLock?.();
208
+ // Read only now: a snapshot from before the lock may miss a base another run just wrote.
209
+ const bases = await read();
210
+ if (bases === undefined)
211
+ return done("failed", "skill_bases_corrupt");
212
+ const decision = await decide(request.name, overridePath, shipped, bases, dependencies.released);
213
+ if (decision.kind === "failed")
214
+ return done("failed", decision.code);
215
+ if (decision.kind === "current")
216
+ return done("unchanged", "skill_override_current");
217
+ if (decision.kind === "copy")
218
+ await copyShippedSkill(shipped.files, overrideDirectory, overridePath, request.name);
219
+ await writeSkillBases(overrideDirectory, { ...bases, [request.name]: shipped.fingerprint }, dependencies.released, dependencies.beforeBasesRename);
220
+ return done("ok", decision.applied);
221
+ }
222
+ finally {
223
+ await lock.release();
224
+ }
225
+ }
226
+ catch {
227
+ return done("failed", "skill_customize_failed");
228
+ }
229
+ }
230
+ async function copyShippedSkill(files, overrideDirectory, overridePath, name) {
231
+ const temporary = join(overrideDirectory, `.${name}.${randomBytes(6).toString("hex")}.tmp`);
232
+ try {
233
+ await mkdir(temporary, { mode: 0o755 });
234
+ for (const file of files) {
235
+ const destination = join(temporary, ...file.path.split("/"));
236
+ await mkdir(dirname(destination), { recursive: true });
237
+ const handle = await open(destination, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o644);
238
+ try {
239
+ await handle.writeFile(file.bytes);
240
+ await handle.sync();
241
+ }
242
+ finally {
243
+ await handle.close();
244
+ }
245
+ }
246
+ // Every directory entry of the temporary tree is durable before the rename and the bases commit.
247
+ const directories = new Set([temporary]);
248
+ for (const file of files) {
249
+ let cursor = dirname(join(temporary, ...file.path.split("/")));
250
+ while (cursor !== temporary) {
251
+ directories.add(cursor);
252
+ cursor = dirname(cursor);
253
+ }
254
+ }
255
+ for (const directory of [...directories].sort((left, right) => right.length - left.length))
256
+ await syncDirectory(directory);
257
+ // The override must not appear while the copy runs, and an operator's directory is never replaced.
258
+ if (await lstat(overridePath).then(() => true, (error) => { if (isMissing(error))
259
+ return false; throw error; }))
260
+ throw new Error("override appeared");
261
+ await rename(temporary, overridePath);
262
+ await syncDirectory(overrideDirectory);
263
+ }
264
+ catch (error) {
265
+ await rm(temporary, { recursive: true, force: true }).catch(() => undefined);
266
+ throw error;
267
+ }
268
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * The skill library: skills shipped in the package's `skills/` directory, and the operator's
3
+ * override directory `skills/` next to the configuration file. An override with the same name
4
+ * replaces the shipped skill; a new name adds a skill. Nothing else is searched.
5
+ */
6
+ /** Agent Skills name rule: lowercase letters and digits in hyphen-separated groups, 1 to 64 characters. */
7
+ export declare const SKILL_NAME_PATTERN: RegExp;
8
+ /** A `skillCatalog` value `builtin:<name>` names a library skill instead of a path. */
9
+ export declare const BUILTIN_SKILL_PREFIX = "builtin:";
10
+ /** Skills for the controlling agent, which setup installs into Claude Code and Codex. */
11
+ export declare const CONTROLLER_SKILLS: readonly string[];
12
+ export declare const MAX_SKILL_TREE_FILES = 32;
13
+ export declare const MAX_SKILL_TREE_BYTES: number;
14
+ /** `dist/`, `.pi-session-mcp-cli/` and `src/` all sit one level below the package root. */
15
+ export declare const PACKAGE_ROOT: string;
16
+ export declare function shippedSkillsDirectory(packageRoot?: string): string;
17
+ /** The override directory belongs to the configuration, never to a workspace or the shell's directory. */
18
+ export declare function skillOverrideDirectory(configPath: string): string;
19
+ export interface LibrarySkill {
20
+ readonly name: string;
21
+ readonly source: "override" | "shipped";
22
+ /** Absolute directory that holds the skill's `SKILL.md`. Internal; never reported. */
23
+ readonly directory: string;
24
+ }
25
+ export interface LibraryRoots {
26
+ readonly overrideDirectory: string;
27
+ readonly packageRoot?: string;
28
+ }
29
+ /** The override wins over the shipped skill of the same name; undefined when neither exists. */
30
+ export declare function resolveLibrarySkill(name: string, roots: LibraryRoots): LibrarySkill | undefined;
31
+ /** The `SKILL.md` a `builtin:<name>` catalog value stands for; a missing skill keeps the shipped location. */
32
+ export declare function builtinSkillPath(name: string, roots: LibraryRoots): string;
33
+ export interface SkillTreeFile {
34
+ /** Path relative to the skill directory, with `/` separators. */
35
+ readonly path: string;
36
+ readonly bytes: Buffer;
37
+ }
38
+ export interface SkillTree {
39
+ readonly files: readonly SkillTreeFile[];
40
+ /** Version 2 fingerprint (`v2:` and SHA-256 hex) of the paths and contents; path-free and stable. */
41
+ readonly fingerprint: string;
42
+ /**
43
+ * The version 1 fingerprint that 0.8.0 recorded, only to recognize such records. It is
44
+ * ambiguous and never proves identity on its own; see `provesLegacyFingerprint`.
45
+ */
46
+ readonly legacyFingerprint: string;
47
+ }
48
+ export declare class SkillTreeError extends Error {
49
+ readonly code: "skill_tree_missing" | "skill_tree_invalid" | "skill_tree_too_large";
50
+ constructor(code: "skill_tree_missing" | "skill_tree_invalid" | "skill_tree_too_large");
51
+ }
52
+ /** A version 2 skill tree fingerprint as it is stored. */
53
+ export declare const SKILL_TREE_FINGERPRINT_PATTERN: RegExp;
54
+ /** A version 1 fingerprint as 0.8.0 stored it: bare SHA-256 hex. */
55
+ export declare const LEGACY_SKILL_TREE_FINGERPRINT_PATTERN: RegExp;
56
+ /**
57
+ * Version 2: a domain and version tag, the file count, then per file in byte order of the path
58
+ * the length-prefixed UTF-8 path and the length-prefixed content. Every field has an explicit
59
+ * length, so two different trees never share an encoding.
60
+ */
61
+ export declare function skillTreeFingerprint(files: readonly SkillTreeFile[]): string;
62
+ /**
63
+ * Version 1, as 0.8.0 computed it: `path NUL content NUL` per file in reading order. File
64
+ * content may contain NUL bytes, so two different trees can share this value.
65
+ */
66
+ export declare function legacySkillTreeFingerprint(files: readonly SkillTreeFile[]): string;
67
+ export interface ReleasedSkillTree {
68
+ readonly name: string;
69
+ readonly release: string;
70
+ readonly legacyFingerprint: string;
71
+ readonly fingerprint: string;
72
+ }
73
+ /**
74
+ * The shipped skills of every release that recorded version 1 fingerprints (0.8.0 only), as
75
+ * trusted references for recognizing such records. Computed from the `v0.8.0` tag and checked
76
+ * against the published npm package.
77
+ */
78
+ export declare const RELEASED_SKILL_TREES: readonly ReleasedSkillTree[];
79
+ /**
80
+ * Whether a version 1 fingerprint from a record proves that `candidate` is the tree it names:
81
+ * a trusted reference, a released shipped tree of this name or one of `references` (such as
82
+ * the current library tree), has exactly this version 1 fingerprint and is byte-identical to
83
+ * `candidate` by its version 2 fingerprint. The version 1 fingerprint alone never suffices.
84
+ */
85
+ export declare function provesLegacyFingerprint(name: string, legacyFingerprint: string, candidate: Pick<SkillTree, "fingerprint">, references?: readonly Pick<SkillTree, "fingerprint" | "legacyFingerprint">[], released?: readonly ReleasedSkillTree[]): boolean;
86
+ /**
87
+ * The version 2 fingerprint of the released shipped skill that a version 1 fingerprint names,
88
+ * or undefined. Only released trees qualify: the current shipped tree could share a version 1
89
+ * fingerprint with an older original without being that original.
90
+ */
91
+ export declare function releasedSkillFingerprint(name: string, legacyFingerprint: string, released?: readonly ReleasedSkillTree[]): string | undefined;
92
+ export interface ReadSkillTreeOptions {
93
+ /**
94
+ * `skip` (default) ignores entries whose name starts with `.`, as for a source tree. `reject`
95
+ * fails on them, for an installed copy: setup never writes hidden entries, so one there is
96
+ * operator content that a fingerprint over the visible files would not protect.
97
+ */
98
+ readonly hidden?: "skip" | "reject";
99
+ }
100
+ /**
101
+ * Reads the regular files below a skill directory in a stable order. Entries whose name starts
102
+ * with `.` are skipped or rejected (see options). A symbolic link or special file inside the
103
+ * tree, a missing `SKILL.md`, or more than the file and byte limits fail, so the tree can be
104
+ * copied and compared exactly.
105
+ */
106
+ export declare function readSkillTree(directory: string, options?: ReadSkillTreeOptions): Promise<SkillTree>;