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
@@ -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,188 @@
1
+ import { createHash } from "node:crypto";
2
+ import { statSync } from "node:fs";
3
+ import { lstat, readdir, readFile, realpath } from "node:fs/promises";
4
+ import { dirname, join, resolve } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ /**
7
+ * The skill library: skills shipped in the package's `skills/` directory, and the operator's
8
+ * override directory `skills/` next to the configuration file. An override with the same name
9
+ * replaces the shipped skill; a new name adds a skill. Nothing else is searched.
10
+ */
11
+ /** Agent Skills name rule: lowercase letters and digits in hyphen-separated groups, 1 to 64 characters. */
12
+ export const SKILL_NAME_PATTERN = /^(?=.{1,64}$)[a-z0-9]+(?:-[a-z0-9]+)*$/;
13
+ /** A `skillCatalog` value `builtin:<name>` names a library skill instead of a path. */
14
+ export const BUILTIN_SKILL_PREFIX = "builtin:";
15
+ /** Skills for the controlling agent, which setup installs into Claude Code and Codex. */
16
+ export const CONTROLLER_SKILLS = Object.freeze(["pi-session-orchestration"]);
17
+ export const MAX_SKILL_TREE_FILES = 32;
18
+ export const MAX_SKILL_TREE_BYTES = 1024 * 1024;
19
+ const MAX_SKILL_TREE_DEPTH = 8;
20
+ /** `dist/`, `.pi-session-mcp-cli/` and `src/` all sit one level below the package root. */
21
+ export const PACKAGE_ROOT = fileURLToPath(new URL("..", import.meta.url));
22
+ export function shippedSkillsDirectory(packageRoot = PACKAGE_ROOT) {
23
+ return join(packageRoot, "skills");
24
+ }
25
+ /** The override directory belongs to the configuration, never to a workspace or the shell's directory. */
26
+ export function skillOverrideDirectory(configPath) {
27
+ return join(dirname(resolve(configPath)), "skills");
28
+ }
29
+ function isRegularFile(path) {
30
+ try {
31
+ return statSync(path).isFile();
32
+ }
33
+ catch {
34
+ return false;
35
+ }
36
+ }
37
+ /** The override wins over the shipped skill of the same name; undefined when neither exists. */
38
+ export function resolveLibrarySkill(name, roots) {
39
+ if (!SKILL_NAME_PATTERN.test(name))
40
+ return undefined;
41
+ const override = join(roots.overrideDirectory, name);
42
+ if (isRegularFile(join(override, "SKILL.md")))
43
+ return Object.freeze({ name, source: "override", directory: override });
44
+ const shipped = join(shippedSkillsDirectory(roots.packageRoot), name);
45
+ if (isRegularFile(join(shipped, "SKILL.md")))
46
+ return Object.freeze({ name, source: "shipped", directory: shipped });
47
+ return undefined;
48
+ }
49
+ /** The `SKILL.md` a `builtin:<name>` catalog value stands for; a missing skill keeps the shipped location. */
50
+ export function builtinSkillPath(name, roots) {
51
+ const skill = resolveLibrarySkill(name, roots);
52
+ return join(skill?.directory ?? join(shippedSkillsDirectory(roots.packageRoot), name), "SKILL.md");
53
+ }
54
+ export class SkillTreeError extends Error {
55
+ code;
56
+ constructor(code) {
57
+ super("Skill directory is not usable");
58
+ this.code = code;
59
+ this.name = "SkillTreeError";
60
+ }
61
+ }
62
+ /** A version 2 skill tree fingerprint as it is stored. */
63
+ export const SKILL_TREE_FINGERPRINT_PATTERN = /^v2:[0-9a-f]{64}$/;
64
+ /** A version 1 fingerprint as 0.8.0 stored it: bare SHA-256 hex. */
65
+ export const LEGACY_SKILL_TREE_FINGERPRINT_PATTERN = /^[0-9a-f]{64}$/;
66
+ const FINGERPRINT_DOMAIN = "pi-session-mcp skill tree v2\n";
67
+ function uint32(value) {
68
+ const bytes = Buffer.alloc(4);
69
+ bytes.writeUInt32BE(value);
70
+ return bytes;
71
+ }
72
+ function uint64(value) {
73
+ const bytes = Buffer.alloc(8);
74
+ bytes.writeBigUInt64BE(BigInt(value));
75
+ return bytes;
76
+ }
77
+ /**
78
+ * Version 2: a domain and version tag, the file count, then per file in byte order of the path
79
+ * the length-prefixed UTF-8 path and the length-prefixed content. Every field has an explicit
80
+ * length, so two different trees never share an encoding.
81
+ */
82
+ export function skillTreeFingerprint(files) {
83
+ const entries = files.map((file) => ({ path: Buffer.from(file.path, "utf8"), bytes: file.bytes }))
84
+ .sort((left, right) => Buffer.compare(left.path, right.path));
85
+ const hash = createHash("sha256").update(FINGERPRINT_DOMAIN, "utf8").update(uint32(entries.length));
86
+ entries.forEach((entry, index) => {
87
+ if (index > 0 && Buffer.compare(entries[index - 1].path, entry.path) === 0)
88
+ throw new SkillTreeError("skill_tree_invalid");
89
+ hash.update(uint32(entry.path.length)).update(entry.path).update(uint64(entry.bytes.length)).update(entry.bytes);
90
+ });
91
+ return `v2:${hash.digest("hex")}`;
92
+ }
93
+ /**
94
+ * Version 1, as 0.8.0 computed it: `path NUL content NUL` per file in reading order. File
95
+ * content may contain NUL bytes, so two different trees can share this value.
96
+ */
97
+ export function legacySkillTreeFingerprint(files) {
98
+ const hash = createHash("sha256");
99
+ for (const file of files)
100
+ hash.update(file.path).update("\0").update(file.bytes).update("\0");
101
+ return hash.digest("hex");
102
+ }
103
+ /**
104
+ * The shipped skills of every release that recorded version 1 fingerprints (0.8.0 only), as
105
+ * trusted references for recognizing such records. Computed from the `v0.8.0` tag and checked
106
+ * against the published npm package.
107
+ */
108
+ export const RELEASED_SKILL_TREES = Object.freeze([
109
+ Object.freeze({ name: "pi-session-orchestration", release: "0.8.0", legacyFingerprint: "e2a72b558ea14748a4badd0058ff65b8731cc125033a666706b103842784673a", fingerprint: "v2:39edc4c4783dec7f90345b22844a17e5e48b465722bb0f1b20059d6ee51e1523" }),
110
+ Object.freeze({ name: "pi-session-implementation", release: "0.8.0", legacyFingerprint: "5f719b44da266d3f66dd5bf3c18f3e3e04ca7df357e2fe2eedbf0409e6c1bf64", fingerprint: "v2:f8ce75eff83a5c952f6ca51c04e1e8025cb7c2c558c30d692f291a3f6594eb12" }),
111
+ Object.freeze({ name: "pi-session-review", release: "0.8.0", legacyFingerprint: "8cf58ccb8d473a3eedf12ce6b902b1e91dd9e2fef83ad110c4d8e2e6164cb257", fingerprint: "v2:610c0b4829eeb1ce6c9b2000ad73f8e88ca230707eef7f7b1708a80767952bf0" }),
112
+ ]);
113
+ /**
114
+ * Whether a version 1 fingerprint from a record proves that `candidate` is the tree it names:
115
+ * a trusted reference, a released shipped tree of this name or one of `references` (such as
116
+ * the current library tree), has exactly this version 1 fingerprint and is byte-identical to
117
+ * `candidate` by its version 2 fingerprint. The version 1 fingerprint alone never suffices.
118
+ */
119
+ export function provesLegacyFingerprint(name, legacyFingerprint, candidate, references = [], released = RELEASED_SKILL_TREES) {
120
+ if (!LEGACY_SKILL_TREE_FINGERPRINT_PATTERN.test(legacyFingerprint) || !SKILL_TREE_FINGERPRINT_PATTERN.test(candidate.fingerprint))
121
+ return false;
122
+ const trusted = [...released.filter((tree) => tree.name === name), ...references];
123
+ return trusted.some((tree) => tree.legacyFingerprint === legacyFingerprint && tree.fingerprint === candidate.fingerprint);
124
+ }
125
+ /**
126
+ * The version 2 fingerprint of the released shipped skill that a version 1 fingerprint names,
127
+ * or undefined. Only released trees qualify: the current shipped tree could share a version 1
128
+ * fingerprint with an older original without being that original.
129
+ */
130
+ export function releasedSkillFingerprint(name, legacyFingerprint, released = RELEASED_SKILL_TREES) {
131
+ return released.find((tree) => tree.name === name && tree.legacyFingerprint === legacyFingerprint)?.fingerprint;
132
+ }
133
+ /**
134
+ * Reads the regular files below a skill directory in a stable order. Entries whose name starts
135
+ * with `.` are skipped or rejected (see options). A symbolic link or special file inside the
136
+ * tree, a missing `SKILL.md`, or more than the file and byte limits fail, so the tree can be
137
+ * copied and compared exactly.
138
+ */
139
+ export async function readSkillTree(directory, options = {}) {
140
+ let root;
141
+ try {
142
+ root = await realpath(directory);
143
+ }
144
+ catch {
145
+ throw new SkillTreeError("skill_tree_missing");
146
+ }
147
+ const files = [];
148
+ let bytes = 0;
149
+ const visit = async (current, prefix, depth) => {
150
+ if (depth > MAX_SKILL_TREE_DEPTH)
151
+ throw new SkillTreeError("skill_tree_invalid");
152
+ let entries;
153
+ try {
154
+ entries = await readdir(current, { withFileTypes: true });
155
+ }
156
+ catch {
157
+ throw new SkillTreeError("skill_tree_invalid");
158
+ }
159
+ for (const entry of entries.sort((left, right) => (left.name < right.name ? -1 : left.name > right.name ? 1 : 0))) {
160
+ if (entry.name.startsWith(".")) {
161
+ if (options.hidden === "reject")
162
+ throw new SkillTreeError("skill_tree_invalid");
163
+ continue;
164
+ }
165
+ const path = join(current, entry.name);
166
+ const relativePath = prefix === "" ? entry.name : `${prefix}/${entry.name}`;
167
+ const info = await lstat(path);
168
+ if (info.isDirectory()) {
169
+ await visit(path, relativePath, depth + 1);
170
+ continue;
171
+ }
172
+ if (!info.isFile())
173
+ throw new SkillTreeError("skill_tree_invalid");
174
+ if (files.length + 1 > MAX_SKILL_TREE_FILES || bytes + info.size > MAX_SKILL_TREE_BYTES)
175
+ throw new SkillTreeError("skill_tree_too_large");
176
+ const content = await readFile(path);
177
+ // Counted as read, so a file that grew after lstat cannot pass the limit.
178
+ bytes += content.length;
179
+ if (bytes > MAX_SKILL_TREE_BYTES)
180
+ throw new SkillTreeError("skill_tree_too_large");
181
+ files.push(Object.freeze({ path: relativePath, bytes: content }));
182
+ }
183
+ };
184
+ await visit(root, "", 0);
185
+ if (!files.some((file) => file.path === "SKILL.md"))
186
+ throw new SkillTreeError("skill_tree_missing");
187
+ return Object.freeze({ files: Object.freeze(files), fingerprint: skillTreeFingerprint(files), legacyFingerprint: legacySkillTreeFingerprint(files) });
188
+ }
package/CHANGELOG.md CHANGED
@@ -2,6 +2,182 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.8.1 — 2026-10-12
6
+
7
+ - `updatedAt` of a running turn now follows public SDK activity. It is the time
8
+ of the last observed assistant message start, update or end and tool
9
+ execution start, update or end of that turn, and freezes at the terminal
10
+ transition. Before, it advanced only after a completed tool execution or a
11
+ completed assistant message, so a turn of an extension provider with its own
12
+ agent loop, such as `pi-cursor-sdk`, kept `updatedAt == startedAt` while it
13
+ streamed. Streaming is activity, not evidence of finished or useful work, and
14
+ work without a public event stays invisible, so an unchanged `updatedAt` is
15
+ still no proof of a hang. No event content is stored or projected, and the
16
+ answer still comes only from the final assistant message. See [tool
17
+ contracts](docs/tool-contracts.md). Issue #47.
18
+ - Skill tree fingerprints are now version 2 (`v2:` and 64 hex digits), an
19
+ unambiguous encoding with explicit lengths. Version 1 of 0.8.0 hashed
20
+ `path NUL content NUL` and could give two different trees the same value,
21
+ because content may contain NUL bytes. A version 1 value never proves
22
+ identity alone: it counts only when a trusted reference (the shipped skills of
23
+ 0.8.0, or for setup's installed copies the current library tree) has that
24
+ value and is byte-identical to the tree in question. Issue #51.
25
+ - Skill customization is now serialized across processes. `--apply
26
+ --customize-skill` takes an exclusive kernel lock on
27
+ `<override dir>/.pi-session-mcp-bases.lock`, re-reads the bases file under the
28
+ lock and replaces it atomically, so two runs for different skills no longer
29
+ lose a base. The wait is bounded at 30 seconds. New codes:
30
+ `skill_customize_lock_busy` and `skill_customize_lock_unavailable`. The
31
+ dry-run takes no lock and writes nothing. The bases file is now schema version
32
+ 2; version 1 files are still read, and every write migrates each legacy value
33
+ that names a released 0.8.0 shipped skill. New codes for a legacy base:
34
+ `skill_base_migration_planned` and `skill_base_migrated` (it names the current
35
+ shipped skill), and `skill_base_unverified` (it names no released skill; the
36
+ Doctor warning `skill_override_outdated` says that the base cannot be
37
+ confirmed). `skill_customize_planned` and `skill_customized` now also complete
38
+ a customization whose bases commit failed. See [Pi
39
+ skills](docs/skills.md#concurrent-runs-and-the-override-lock). Issue #50.
40
+ - Setup's installed skill copies. Ownership records of skill copies now hold
41
+ version 2 fingerprints. A record that 0.8.0 wrote is migrated only when a
42
+ trusted reference proves the copy's exact bytes; otherwise dry-run, apply,
43
+ remove and rollback report the new code `skill_legacy_unverified` and change
44
+ neither the copy nor the record. Updates now name their intermediate
45
+ directories after the pending record's transaction id
46
+ (`.pi-session-mcp-<skill>-<transactionId>.staging`, `.backup`, `.trash`), and
47
+ `--apply`, `--remove` and `--rollback` first settle an interrupted update.
48
+ Anything modified or foreign stays and is reported as the new code
49
+ `skill_recovery_retained`. A failed update leaves its staging directory until
50
+ the next run settles it. Directories of 0.8.0 updates are never searched for
51
+ or deleted. When `--apply` settled an interrupted update and needs no further
52
+ update it reports the new code `skill_recovered`, and a dry-run of a pending
53
+ update reports `skill_recovery_planned`. Issues #51 and #52.
54
+ - Upgrading from 0.8.0: run `pi-session-mcp-setup` as a dry-run and then with
55
+ `--apply`. Unchanged copies of the 0.8.0 controller skill are proven against
56
+ the 0.8.0 table and migrated; others stay and report
57
+ `skill_legacy_unverified`. Doctor resolves 0.8.0 bases against the same table,
58
+ and the next `--apply --customize-skill` rewrites the bases file as version 2.
59
+ 0.8.0 rejects the new formats as corrupt (the bases file, and the whole
60
+ ownership file once it holds a version 2 skill record), so it never acts on
61
+ them. See [Upgrade from 0.8.0](docs/skills.md#upgrade-from-080) and
62
+ [Downgrade](docs/skills.md#downgrade).
63
+ - The orchestration skill now says that a successful `pi_session_start` does not
64
+ prove that the provider accepts requests, asks for a bounded observation and
65
+ cleanup budget after `abort_timeout`, separates start errors that need the
66
+ operator from ones that can be transient, and describes `updatedAt` as the
67
+ last observed public SDK activity. `docs/acceptance.md` records the v0.8.0
68
+ post-release acceptance and names the method limits of earlier multi-session
69
+ records. Issue #54.
70
+ - The multi-session acceptance harness (`scripts/multi-session-acceptance.mjs`,
71
+ not in the npm package) now ends a run within validated time bounds and
72
+ terminates only processes that provably belong to it (PID and start time). It
73
+ checks scope against a baseline stored outside the worker workspace (working
74
+ tree, index and HEAD, so committed changes count) and adds its own business
75
+ checks to the fixture tests, with Git pinned to the fixture and hooks,
76
+ `fsmonitor` and replace objects switched off. It binds the build, the
77
+ controller and worker skills, the override directory, the configuration, the
78
+ fixture baselines, itself and the operator's own `pi-session-orchestration`
79
+ copies in `~/.claude/skills` and `~/.agents/skills` at `prepare` and rechecks
80
+ them before launch and after the run; a Codex run is refused while the home
81
+ copy differs from the candidate's skill. It records each launch before the
82
+ controller starts, writes each original result once, records reanalyses
83
+ separately, and lists every launch in the evidence, marking one without a
84
+ result. A failed analysis, or a signal to the harness during it, is recorded
85
+ as `analysisFailed`, never passes, still terminates the run's processes and
86
+ makes the CLI exit with 1. `prepare --with-extensions` copies the exact
87
+ extension allowlist of a selected profile for extension providers such as
88
+ Cursor. Each session reports whether a polled running turn showed
89
+ `updatedAt` beyond `startedAt`. See
90
+ [Acceptance](docs/acceptance.md#multi-session-skill-acceptance). Issue #53.
91
+ - A release now travels in the pull request of the change it ships, with the
92
+ version, the changelog heading and the pre-release acceptance record; records
93
+ that need the published release follow with the next change. See
94
+ [Releasing](docs/releasing.md#prepare-a-release).
95
+
96
+ ## 0.8.0 — 2026-10-12
97
+
98
+ This is a minor release because the public contract changes, as ADR 0003
99
+ requires: the `skills` field of each execution profile in
100
+ `pi_capabilities_get` and the new error code `skill_load_failed`.
101
+
102
+ - Execution profiles can select Pi skills. An optional top-level
103
+ `skillCatalog` maps a skill alias to the path of one `SKILL.md` file (or to
104
+ `builtin:<name>`, see below), and an
105
+ optional `skills` list in a profile selects catalog aliases. MCP callers still
106
+ choose only workspace and profile aliases; no tool takes a skill. A session
107
+ loads exactly the selected skills and validates them at start; a missing,
108
+ unreadable, oversized or invalid skill, a duplicate Pi skill name, two aliases
109
+ on one file, or an explicit-only skill (`disable-model-invocation: true`)
110
+ fails the start with the new error `skill_load_failed`. Every execution
111
+ profile in `pi_capabilities_get` gains `skills`, the selected catalog aliases
112
+ (`[]` when none). Doctor gains `skill_missing`, `skill_unreadable` and
113
+ `skill_duplicate`. Skill file contents are not snapshotted; do not change
114
+ them while sessions that use them are active. See [Pi
115
+ skills](docs/skills.md) and [ADR 0007](docs/adr/0007-profile-skill-selection.md).
116
+ Issue #46.
117
+ - Compatibility change: with Pi SDK 1.1.0, skills that an allowlisted
118
+ extension package bundles reached a session although `noSkills` was set. An
119
+ extension allowlist alone now makes none of its skills available. A profile
120
+ that relied on them needs a catalog entry with the absolute path of the
121
+ package's `SKILL.md` and a `skills` selection; see the migration section of
122
+ [Pi skills](docs/skills.md).
123
+ - Prompt templates and themes that extension packages ship are now closed
124
+ through the SDK's public overrides as well. They were already never expanded
125
+ for MCP prompts.
126
+ - The package ships three skills under `skills/`: `pi-session-orchestration`
127
+ for the controlling Claude Code or Codex agent, and `pi-session-implementation`
128
+ and `pi-session-review` for Pi workers. The example configuration registers
129
+ the two worker skills with `builtin:` references, so a copied example needs
130
+ no path edits for skills.
131
+ - Skill library and `builtin:`. A `skillCatalog` value `builtin:<name>` names a
132
+ skill of the library: the operator's override
133
+ `<configuration directory>/skills/<name>/SKILL.md` if it exists, else the
134
+ skill shipped in the package. Resolution happens when the configuration
135
+ loads, so adding or removing an override needs a restart. A name that matches
136
+ neither keeps the shipped location, so Doctor reports `skill_missing` and a
137
+ session start fails with `skill_load_failed`. A malformed `builtin:` name makes
138
+ the configuration invalid.
139
+ - `pi-session-mcp-setup --customize-skill <name>` (a dry-run; `--apply` writes)
140
+ copies a shipped skill into the override directory as a starting point and
141
+ records the shipped fingerprint in `.pi-session-mcp-bases.json`. It never
142
+ overwrites an existing override. Findings use the target
143
+ `skills/override/<name>` and the new codes `skill_customize_planned`,
144
+ `skill_customized`, `skill_override_current`, `skill_override_outdated`,
145
+ `skill_base_updated`, `skill_not_shipped`, `skill_bases_corrupt`,
146
+ `skill_override_invalid` and `skill_customize_failed`. After a package
147
+ update changed a shipped skill, `--apply --customize-skill <name>` moves only
148
+ the recorded base once you reviewed the change; it never touches your files.
149
+ A new skill needs no command: create `<override dir>/<name>/SKILL.md` and
150
+ reference it as `builtin:<name>`.
151
+ - `pi-session-mcp-setup --apply` now also installs the controller skill
152
+ `pi-session-orchestration` for each target, after the registration, as a real
153
+ copy of the effective skill (override, else shipped): Codex `~/.agents/skills/`,
154
+ Claude Code `user` and `local` `~/.claude/skills/` (one shared directory),
155
+ Claude Code `project` `<project>/.claude/skills/`. The ownership record holds
156
+ only a content fingerprint. Setup never replaces or deletes a modified copy, a
157
+ foreign skill of the same name, hidden operator files inside the copy, or a
158
+ linked or non-directory entry (`skill_divergent`); a link to exactly the
159
+ effective skill is accepted as the operator's own (`skill_linked`). Inside a
160
+ project setup never follows a link, and a project copy is bound to its
161
+ project. A setup result has a second finding per target with the alias
162
+ `pi-session-orchestration` whenever the skill step runs (not for an apply
163
+ that the plan refuses or a registration that failed); the new codes are `skill_absent`,
164
+ `skill_equivalent`, `skill_linked`, `skill_update_planned`, `skill_source_invalid`,
165
+ `skill_installed`, `skill_already_equivalent`, `skill_updated`,
166
+ `skill_install_failed`, `skill_verified`, `skill_missing`,
167
+ `skill_not_equivalent`, `skill_removed`, `skill_kept_shared`,
168
+ `skill_not_owned` and `skill_divergent`. A divergent skill does not block the
169
+ registration but makes the overall status `failed`. `--remove` and
170
+ `--rollback` remove an unmodified copy, and a retry still cleans up a skill
171
+ that setup owns after the registration is gone. Manual symlinking of the
172
+ skill is no longer needed.
173
+ - Doctor gains the warning `skill_override_outdated` (subject `skill:<name>`,
174
+ or `skill:bases` for a corrupt bases file) when a shipped skill changed since
175
+ the operator copied it.
176
+ - Maintainers get `scripts/multi-session-acceptance.mjs`, a live check in which
177
+ a real Claude Code or Codex controller drives two concurrent Pi sessions
178
+ with selected skills, including a targeted abort. It is not part of the npm
179
+ package; see [acceptance](docs/acceptance.md#multi-session-skill-acceptance).
180
+
5
181
  ## 0.7.1 — 2026-10-11
6
182
 
7
183
  The public MCP contract is unchanged.