pi-session-mcp 0.7.0 → 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 (66) 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/extension-spec.js +7 -2
  5. package/.pi-session-mcp-cli/setup-result.js +8 -0
  6. package/.pi-session-mcp-cli/skill-catalog.js +35 -0
  7. package/.pi-session-mcp-cli/skill-customize.js +183 -0
  8. package/.pi-session-mcp-cli/skill-library.js +120 -0
  9. package/CHANGELOG.md +110 -0
  10. package/README.md +15 -3
  11. package/dist/capabilities.d.ts +1 -0
  12. package/dist/capabilities.js +2 -1
  13. package/dist/client-adapters/mcp-verifier.js +6 -2
  14. package/dist/config.d.ts +4 -0
  15. package/dist/config.js +47 -7
  16. package/dist/doctor.d.ts +4 -1
  17. package/dist/doctor.js +68 -2
  18. package/dist/execution-profile.d.ts +4 -0
  19. package/dist/execution-profile.js +1 -0
  20. package/dist/extension-spec.js +7 -2
  21. package/dist/pi-adapter.d.ts +1 -1
  22. package/dist/pi-adapter.js +1 -0
  23. package/dist/pi-skills.d.ts +17 -0
  24. package/dist/pi-skills.js +64 -0
  25. package/dist/sdk-pi-adapter.d.ts +7 -3
  26. package/dist/sdk-pi-adapter.js +57 -11
  27. package/dist/server.js +1 -0
  28. package/dist/session-tools.d.ts +1 -1
  29. package/dist/session-tools.js +20 -4
  30. package/dist/setup-cli.d.ts +2 -0
  31. package/dist/setup-cli.js +16 -0
  32. package/dist/setup-main.d.ts +1 -1
  33. package/dist/setup-main.js +18 -3
  34. package/dist/setup-ownership.d.ts +6 -0
  35. package/dist/setup-ownership.js +29 -5
  36. package/dist/setup-skills.d.ts +37 -0
  37. package/dist/setup-skills.js +353 -0
  38. package/dist/setup.d.ts +7 -0
  39. package/dist/setup.js +49 -6
  40. package/dist/skill-catalog.d.ts +26 -0
  41. package/dist/skill-catalog.js +35 -0
  42. package/dist/skill-customize.d.ts +23 -0
  43. package/dist/skill-customize.js +183 -0
  44. package/dist/skill-library.d.ts +61 -0
  45. package/dist/skill-library.js +120 -0
  46. package/docs/acceptance.md +372 -3
  47. package/docs/adr/0006-pi-extension-allowlist.md +5 -2
  48. package/docs/adr/0007-profile-skill-selection.md +217 -0
  49. package/docs/architecture.md +9 -5
  50. package/docs/client-setup-design.md +133 -1
  51. package/docs/client-setup.md +74 -3
  52. package/docs/doctor-design.md +9 -1
  53. package/docs/installation.md +129 -9
  54. package/docs/pi-extensions-design.md +41 -12
  55. package/docs/releasing.md +15 -6
  56. package/docs/session-mcp.md +7 -2
  57. package/docs/skills.md +487 -0
  58. package/docs/threat-model.md +21 -4
  59. package/docs/tool-contracts.md +9 -5
  60. package/package.json +3 -1
  61. package/pi-session-mcp.example.json +9 -3
  62. package/skills/pi-session-implementation/SKILL.md +42 -0
  63. package/skills/pi-session-orchestration/SKILL.md +93 -0
  64. package/skills/pi-session-review/SKILL.md +58 -0
  65. package/docs/superpowers/plans/2026-10-10-macos-setup.md +0 -1725
  66. package/docs/superpowers/plans/2026-10-11-pi-extensions.md +0 -2450
@@ -0,0 +1,183 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { constants } from "node:fs";
3
+ import { lstat, mkdir, open, readFile, rename, rm, unlink, writeFile } from "node:fs/promises";
4
+ import { dirname, join } from "node:path";
5
+ import { result } from "./setup-result.js";
6
+ import { readSkillTree, shippedSkillsDirectory, SKILL_NAME_PATTERN, SkillTreeError, skillOverrideDirectory } from "./skill-library.js";
7
+ /**
8
+ * `--customize-skill <name>`: copies a shipped skill into the operator's override directory as a
9
+ * starting point, and records the shipped fingerprint it was based on. The operator's files are
10
+ * never overwritten or edited; a changed original is only reported and acknowledged by moving
11
+ * the recorded base. Reports carry the skill name and a code, never a path or file content.
12
+ */
13
+ export const SKILL_BASES_FILE = ".pi-session-mcp-bases.json";
14
+ export const MAX_SKILL_BASES_BYTES = 64 * 1024;
15
+ const FINGERPRINT_PATTERN = /^[0-9a-f]{64}$/;
16
+ export class SkillBasesError extends Error {
17
+ constructor() {
18
+ super("Skill bases file is not usable");
19
+ this.name = "SkillBasesError";
20
+ }
21
+ }
22
+ /** Strictly validated contents of the bases file; an absent file or override directory is an empty record. */
23
+ export async function readSkillBases(overrideDirectory) {
24
+ const path = join(overrideDirectory, SKILL_BASES_FILE);
25
+ let text;
26
+ try {
27
+ const info = await lstat(path);
28
+ if (!info.isFile() || info.size > MAX_SKILL_BASES_BYTES)
29
+ throw new SkillBasesError();
30
+ const bytes = await readFile(path);
31
+ if (bytes.length > MAX_SKILL_BASES_BYTES)
32
+ throw new SkillBasesError();
33
+ text = bytes.toString("utf8");
34
+ }
35
+ catch (error) {
36
+ if (error instanceof SkillBasesError)
37
+ throw error;
38
+ if (typeof error === "object" && error !== null && error.code === "ENOENT")
39
+ return Object.freeze({});
40
+ throw new SkillBasesError();
41
+ }
42
+ let parsed;
43
+ try {
44
+ parsed = JSON.parse(text);
45
+ }
46
+ catch {
47
+ throw new SkillBasesError();
48
+ }
49
+ if (!isPlainObject(parsed) || Object.keys(parsed).sort().join() !== "bases,schemaVersion" || parsed.schemaVersion !== 1)
50
+ throw new SkillBasesError();
51
+ const bases = parsed.bases;
52
+ if (!isPlainObject(bases))
53
+ throw new SkillBasesError();
54
+ const entries = Object.entries(bases);
55
+ if (!entries.every(([name, value]) => SKILL_NAME_PATTERN.test(name) && typeof value === "string" && FINGERPRINT_PATTERN.test(value)))
56
+ throw new SkillBasesError();
57
+ return Object.freeze(Object.fromEntries(entries));
58
+ }
59
+ function isPlainObject(value) {
60
+ if (typeof value !== "object" || value === null || Array.isArray(value))
61
+ return false;
62
+ const prototype = Object.getPrototypeOf(value);
63
+ return prototype === Object.prototype || prototype === null;
64
+ }
65
+ /** Atomic replacement: a private temporary file in the same directory, then a rename. */
66
+ async function writeSkillBases(overrideDirectory, bases) {
67
+ const sorted = Object.fromEntries(Object.entries(bases).sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)));
68
+ const content = Buffer.from(`${JSON.stringify({ schemaVersion: 1, bases: sorted })}\n`, "utf8");
69
+ if (content.length > MAX_SKILL_BASES_BYTES)
70
+ throw new SkillBasesError();
71
+ const temporary = join(overrideDirectory, `.${SKILL_BASES_FILE.slice(1)}.${randomBytes(6).toString("hex")}.tmp`);
72
+ const handle = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600);
73
+ try {
74
+ try {
75
+ await handle.writeFile(content);
76
+ await handle.sync();
77
+ }
78
+ finally {
79
+ await handle.close();
80
+ }
81
+ await rename(temporary, join(overrideDirectory, SKILL_BASES_FILE));
82
+ }
83
+ catch (error) {
84
+ await unlink(temporary).catch(() => undefined);
85
+ throw error;
86
+ }
87
+ }
88
+ function isMissing(error) {
89
+ return typeof error === "object" && error !== null && error.code === "ENOENT";
90
+ }
91
+ function report(request, status, code) {
92
+ // The name is echoed only when it is a valid skill name, so arbitrary input never reaches a report.
93
+ const alias = SKILL_NAME_PATTERN.test(request.name) ? request.name : "invalid";
94
+ const target = { client: "skills", scope: "override", alias };
95
+ const finding = { target, status, code };
96
+ // A planned change reports `planned` overall, like a registration dry-run.
97
+ return result(request.apply ? "apply" : "dry-run", [finding], status === "planned" ? "planned" : undefined);
98
+ }
99
+ export async function customizeSkill(request) {
100
+ const done = (status, code) => report(request, status, code);
101
+ if (!SKILL_NAME_PATTERN.test(request.name))
102
+ return done("failed", "skill_not_shipped");
103
+ try {
104
+ const shippedDirectory = join(shippedSkillsDirectory(request.packageRoot), request.name);
105
+ let shipped;
106
+ try {
107
+ shipped = await readSkillTree(shippedDirectory);
108
+ }
109
+ catch (error) {
110
+ if (error instanceof SkillTreeError && error.code === "skill_tree_missing")
111
+ return done("failed", "skill_not_shipped");
112
+ return done("failed", "skill_customize_failed");
113
+ }
114
+ const overrideDirectory = skillOverrideDirectory(request.configPath);
115
+ let bases;
116
+ try {
117
+ bases = await readSkillBases(overrideDirectory);
118
+ }
119
+ catch (error) {
120
+ if (error instanceof SkillBasesError)
121
+ return done("failed", "skill_bases_corrupt");
122
+ throw error;
123
+ }
124
+ const overridePath = join(overrideDirectory, request.name);
125
+ let exists = true;
126
+ try {
127
+ const info = await lstat(overridePath);
128
+ if (!info.isDirectory())
129
+ return done("failed", "skill_override_invalid");
130
+ }
131
+ catch (error) {
132
+ if (!isMissing(error))
133
+ throw error;
134
+ exists = false;
135
+ }
136
+ if (!exists) {
137
+ if (!request.apply)
138
+ return done("planned", "skill_customize_planned");
139
+ await copyShippedSkill(shipped.files, overrideDirectory, overridePath, request.name);
140
+ await writeSkillBases(overrideDirectory, { ...bases, [request.name]: shipped.fingerprint });
141
+ return done("ok", "skill_customized");
142
+ }
143
+ try {
144
+ await readSkillTree(overridePath);
145
+ }
146
+ catch (error) {
147
+ if (error instanceof SkillTreeError)
148
+ return done("failed", "skill_override_invalid");
149
+ throw error;
150
+ }
151
+ if (bases[request.name] === shipped.fingerprint)
152
+ return done("unchanged", "skill_override_current");
153
+ if (!request.apply)
154
+ return done("planned", "skill_override_outdated");
155
+ // Only the recorded base moves: it acknowledges that the operator reviewed the changed original.
156
+ await writeSkillBases(overrideDirectory, { ...bases, [request.name]: shipped.fingerprint });
157
+ return done("ok", "skill_base_updated");
158
+ }
159
+ catch {
160
+ return done("failed", "skill_customize_failed");
161
+ }
162
+ }
163
+ async function copyShippedSkill(files, overrideDirectory, overridePath, name) {
164
+ await mkdir(overrideDirectory, { recursive: true, mode: 0o700 });
165
+ const temporary = join(overrideDirectory, `.${name}.${randomBytes(6).toString("hex")}.tmp`);
166
+ try {
167
+ await mkdir(temporary, { mode: 0o755 });
168
+ for (const file of files) {
169
+ const destination = join(temporary, ...file.path.split("/"));
170
+ await mkdir(dirname(destination), { recursive: true });
171
+ await writeFile(destination, file.bytes, { flag: "wx", mode: 0o644 });
172
+ }
173
+ // The override must not appear while the copy runs, and an operator's directory is never replaced.
174
+ if (await lstat(overridePath).then(() => true, (error) => { if (isMissing(error))
175
+ return false; throw error; }))
176
+ throw new Error("override appeared");
177
+ await rename(temporary, overridePath);
178
+ }
179
+ catch (error) {
180
+ await rm(temporary, { recursive: true, force: true }).catch(() => undefined);
181
+ throw error;
182
+ }
183
+ }
@@ -0,0 +1,61 @@
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
+ /** SHA-256 hex over the sorted relative paths and contents; path-free and stable. */
41
+ readonly fingerprint: string;
42
+ }
43
+ export declare class SkillTreeError extends Error {
44
+ readonly code: "skill_tree_missing" | "skill_tree_invalid" | "skill_tree_too_large";
45
+ constructor(code: "skill_tree_missing" | "skill_tree_invalid" | "skill_tree_too_large");
46
+ }
47
+ export interface ReadSkillTreeOptions {
48
+ /**
49
+ * `skip` (default) ignores entries whose name starts with `.`, as for a source tree. `reject`
50
+ * fails on them, for an installed copy: setup never writes hidden entries, so one there is
51
+ * operator content that a fingerprint over the visible files would not protect.
52
+ */
53
+ readonly hidden?: "skip" | "reject";
54
+ }
55
+ /**
56
+ * Reads the regular files below a skill directory in a stable order. Entries whose name starts
57
+ * with `.` are skipped or rejected (see options). A symbolic link or special file inside the
58
+ * tree, a missing `SKILL.md`, or more than the file and byte limits fail, so the tree can be
59
+ * copied and compared exactly.
60
+ */
61
+ export declare function readSkillTree(directory: string, options?: ReadSkillTreeOptions): Promise<SkillTree>;
@@ -0,0 +1,120 @@
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
+ /**
63
+ * Reads the regular files below a skill directory in a stable order. Entries whose name starts
64
+ * with `.` are skipped or rejected (see options). A symbolic link or special file inside the
65
+ * tree, a missing `SKILL.md`, or more than the file and byte limits fail, so the tree can be
66
+ * copied and compared exactly.
67
+ */
68
+ export async function readSkillTree(directory, options = {}) {
69
+ let root;
70
+ try {
71
+ root = await realpath(directory);
72
+ }
73
+ catch {
74
+ throw new SkillTreeError("skill_tree_missing");
75
+ }
76
+ const files = [];
77
+ let bytes = 0;
78
+ const visit = async (current, prefix, depth) => {
79
+ if (depth > MAX_SKILL_TREE_DEPTH)
80
+ throw new SkillTreeError("skill_tree_invalid");
81
+ let entries;
82
+ try {
83
+ entries = await readdir(current, { withFileTypes: true });
84
+ }
85
+ catch {
86
+ throw new SkillTreeError("skill_tree_invalid");
87
+ }
88
+ for (const entry of entries.sort((left, right) => (left.name < right.name ? -1 : left.name > right.name ? 1 : 0))) {
89
+ if (entry.name.startsWith(".")) {
90
+ if (options.hidden === "reject")
91
+ throw new SkillTreeError("skill_tree_invalid");
92
+ continue;
93
+ }
94
+ const path = join(current, entry.name);
95
+ const relativePath = prefix === "" ? entry.name : `${prefix}/${entry.name}`;
96
+ const info = await lstat(path);
97
+ if (info.isDirectory()) {
98
+ await visit(path, relativePath, depth + 1);
99
+ continue;
100
+ }
101
+ if (!info.isFile())
102
+ throw new SkillTreeError("skill_tree_invalid");
103
+ if (files.length + 1 > MAX_SKILL_TREE_FILES || bytes + info.size > MAX_SKILL_TREE_BYTES)
104
+ throw new SkillTreeError("skill_tree_too_large");
105
+ const content = await readFile(path);
106
+ // Counted as read, so a file that grew after lstat cannot pass the limit.
107
+ bytes += content.length;
108
+ if (bytes > MAX_SKILL_TREE_BYTES)
109
+ throw new SkillTreeError("skill_tree_too_large");
110
+ files.push(Object.freeze({ path: relativePath, bytes: content }));
111
+ }
112
+ };
113
+ await visit(root, "", 0);
114
+ if (!files.some((file) => file.path === "SKILL.md"))
115
+ throw new SkillTreeError("skill_tree_missing");
116
+ const hash = createHash("sha256");
117
+ for (const file of files)
118
+ hash.update(file.path).update("\0").update(file.bytes).update("\0");
119
+ return Object.freeze({ files: Object.freeze(files), fingerprint: hash.digest("hex") });
120
+ }