pi-session-mcp 0.7.1 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.pi-session-mcp-cli/config.js +47 -7
- package/.pi-session-mcp-cli/doctor.js +68 -2
- package/.pi-session-mcp-cli/execution-profile.js +1 -0
- package/.pi-session-mcp-cli/setup-result.js +8 -0
- package/.pi-session-mcp-cli/skill-catalog.js +35 -0
- package/.pi-session-mcp-cli/skill-customize.js +183 -0
- package/.pi-session-mcp-cli/skill-library.js +120 -0
- package/CHANGELOG.md +85 -0
- package/README.md +15 -3
- package/dist/capabilities.d.ts +1 -0
- package/dist/capabilities.js +2 -1
- package/dist/client-adapters/mcp-verifier.js +6 -2
- package/dist/config.d.ts +4 -0
- package/dist/config.js +47 -7
- package/dist/doctor.d.ts +4 -1
- package/dist/doctor.js +68 -2
- package/dist/execution-profile.d.ts +4 -0
- package/dist/execution-profile.js +1 -0
- package/dist/pi-adapter.d.ts +1 -1
- package/dist/pi-adapter.js +1 -0
- package/dist/pi-skills.d.ts +17 -0
- package/dist/pi-skills.js +64 -0
- package/dist/sdk-pi-adapter.d.ts +7 -3
- package/dist/sdk-pi-adapter.js +54 -11
- package/dist/server.js +1 -0
- package/dist/setup-cli.d.ts +2 -0
- package/dist/setup-cli.js +16 -0
- package/dist/setup-main.d.ts +1 -1
- package/dist/setup-main.js +18 -3
- package/dist/setup-ownership.d.ts +6 -0
- package/dist/setup-ownership.js +29 -5
- package/dist/setup-skills.d.ts +37 -0
- package/dist/setup-skills.js +353 -0
- package/dist/setup.d.ts +7 -0
- package/dist/setup.js +49 -6
- package/dist/skill-catalog.d.ts +26 -0
- package/dist/skill-catalog.js +35 -0
- package/dist/skill-customize.d.ts +23 -0
- package/dist/skill-customize.js +183 -0
- package/dist/skill-library.d.ts +61 -0
- package/dist/skill-library.js +120 -0
- package/docs/acceptance.md +243 -1
- package/docs/adr/0006-pi-extension-allowlist.md +3 -0
- package/docs/adr/0007-profile-skill-selection.md +217 -0
- package/docs/architecture.md +9 -5
- package/docs/client-setup-design.md +133 -1
- package/docs/client-setup.md +74 -3
- package/docs/doctor-design.md +9 -1
- package/docs/installation.md +122 -5
- package/docs/pi-extensions-design.md +17 -4
- package/docs/session-mcp.md +7 -2
- package/docs/skills.md +487 -0
- package/docs/threat-model.md +19 -2
- package/docs/tool-contracts.md +8 -4
- package/package.json +2 -1
- package/pi-session-mcp.example.json +9 -3
- package/skills/pi-session-implementation/SKILL.md +42 -0
- package/skills/pi-session-orchestration/SKILL.md +93 -0
- package/skills/pi-session-review/SKILL.md +58 -0
|
@@ -4,16 +4,20 @@ import { dirname, resolve } from "node:path";
|
|
|
4
4
|
import { z } from "zod";
|
|
5
5
|
import { MAX_PROFILE_EXTENSIONS, parseExtensionSpec } from "./extension-spec.js";
|
|
6
6
|
import { isMcpServers, snapshotMcpServers } from "./mcp-config.js";
|
|
7
|
+
import { builtinSkillName, isSkillCatalogPath, MAX_PROFILE_SKILLS, MAX_SKILL_CATALOG_ENTRIES } from "./skill-catalog.js";
|
|
7
8
|
import { ALIAS_PATTERN, ImmutableMap, } from "./execution-profile.js";
|
|
9
|
+
import { builtinSkillPath, skillOverrideDirectory } from "./skill-library.js";
|
|
8
10
|
const alias = z.string().regex(ALIAS_PATTERN);
|
|
9
11
|
const permissionProfile = z.enum(["read-only", "coding"]);
|
|
10
12
|
const thinkingLevel = z.enum(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
|
|
11
13
|
const extensionSpec = z.string().refine((value) => parseExtensionSpec(value) !== undefined);
|
|
14
|
+
const skillCatalogPath = z.string().refine(isSkillCatalogPath);
|
|
12
15
|
const profileSchema = z.object({
|
|
13
16
|
default: z.boolean(), permissionProfile, provider: z.string().trim().min(1).max(256),
|
|
14
17
|
model: z.string().trim().min(1).max(256), thinkingLevel,
|
|
15
18
|
mcpServers: z.custom((value) => isMcpServers(value)).optional(),
|
|
16
19
|
extensions: z.array(extensionSpec).max(MAX_PROFILE_EXTENSIONS).optional(),
|
|
20
|
+
skills: z.array(alias).max(MAX_PROFILE_SKILLS).optional(),
|
|
17
21
|
}).strict().superRefine((profile, context) => {
|
|
18
22
|
if (profile.mcpServers !== undefined && !isMcpServers(profile.mcpServers, profile.permissionProfile)) {
|
|
19
23
|
context.addIssue({ code: "custom", path: ["mcpServers"], message: "MCP tool grants do not match the permission profile" });
|
|
@@ -24,6 +28,9 @@ const profileSchema = z.object({
|
|
|
24
28
|
context.addIssue({ code: "custom", path: ["extensions"], message: "An extension package may appear only once per profile" });
|
|
25
29
|
}
|
|
26
30
|
}
|
|
31
|
+
if (profile.skills !== undefined && new Set(profile.skills).size !== profile.skills.length) {
|
|
32
|
+
context.addIssue({ code: "custom", path: ["skills"], message: "A skill alias may appear only once per profile" });
|
|
33
|
+
}
|
|
27
34
|
});
|
|
28
35
|
const CONFIG_ERROR_MESSAGES = {
|
|
29
36
|
config_path_required: "Configuration path is required",
|
|
@@ -40,6 +47,7 @@ export class ConfigError extends Error {
|
|
|
40
47
|
}
|
|
41
48
|
export const configSchema = z.object({
|
|
42
49
|
workspaces: z.record(alias, z.string().min(1)),
|
|
50
|
+
skillCatalog: z.record(alias, skillCatalogPath).refine((catalog) => Object.keys(catalog).length <= MAX_SKILL_CATALOG_ENTRIES).optional(),
|
|
43
51
|
executionProfiles: z.record(alias, profileSchema).superRefine((profiles, context) => {
|
|
44
52
|
const entries = Object.entries(profiles);
|
|
45
53
|
if (entries.length === 0)
|
|
@@ -50,13 +58,39 @@ export const configSchema = z.object({
|
|
|
50
58
|
if (defaults.length === 1 && defaults[0][1].permissionProfile !== "read-only")
|
|
51
59
|
context.addIssue({ code: "custom", message: "The default execution profile must be read-only" });
|
|
52
60
|
}),
|
|
53
|
-
}).strict()
|
|
61
|
+
}).strict().superRefine((config, context) => {
|
|
62
|
+
// A profile may select only cataloged skills; there is no implicit or default skill.
|
|
63
|
+
const catalog = config.skillCatalog ?? {};
|
|
64
|
+
for (const [profileAlias, profile] of Object.entries(config.executionProfiles)) {
|
|
65
|
+
if ((profile.skills ?? []).some((skill) => !Object.hasOwn(catalog, skill))) {
|
|
66
|
+
context.addIssue({ code: "custom", path: ["executionProfiles", profileAlias, "skills"], message: "A selected skill must be a skillCatalog alias" });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
});
|
|
54
70
|
/** The only supported reload behavior: a running controller keeps the snapshot it loaded at startup. */
|
|
55
71
|
export const RELOAD_POLICY = "restart-required";
|
|
56
72
|
export const CONFIGURATION_FINGERPRINT_PATTERN = /^sha256:[0-9a-f]{64}$/;
|
|
57
73
|
function configurationFingerprint(bytes) {
|
|
58
74
|
return `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
|
|
59
75
|
}
|
|
76
|
+
/**
|
|
77
|
+
* Resolves a profile's selection against the catalog. Catalog paths resolve like workspaces,
|
|
78
|
+
* against the configuration file's directory. Two selected aliases on the same path would
|
|
79
|
+
* load one skill twice under different public names, so they invalidate the configuration.
|
|
80
|
+
* A `builtin:<name>` value resolves to the library skill: the operator's override, else the shipped one.
|
|
81
|
+
*/
|
|
82
|
+
function resolveSkillSelection(selected, catalog, base, overrideDirectory) {
|
|
83
|
+
if (selected === undefined || selected.length === 0)
|
|
84
|
+
return undefined;
|
|
85
|
+
const resolveEntry = (value) => {
|
|
86
|
+
const builtin = builtinSkillName(value);
|
|
87
|
+
return builtin === undefined ? resolve(base, value) : builtinSkillPath(builtin, { overrideDirectory });
|
|
88
|
+
};
|
|
89
|
+
const skills = selected.map((skill) => Object.freeze({ alias: skill, path: resolveEntry(catalog[skill]) }));
|
|
90
|
+
if (new Set(skills.map((skill) => skill.path)).size !== skills.length)
|
|
91
|
+
throw new ConfigError("invalid_execution_profile_config");
|
|
92
|
+
return Object.freeze(skills);
|
|
93
|
+
}
|
|
60
94
|
/**
|
|
61
95
|
* The configuration path comes only from the caller or from `PI_SESSION_MCP_CONFIG`.
|
|
62
96
|
* There is no default file: a server's working directory is the client's project,
|
|
@@ -85,14 +119,20 @@ export async function loadConfig(path = process.env.PI_SESSION_MCP_CONFIG) {
|
|
|
85
119
|
throw error;
|
|
86
120
|
}
|
|
87
121
|
const base = dirname(absoluteConfig);
|
|
122
|
+
const overrideDirectory = skillOverrideDirectory(absoluteConfig);
|
|
88
123
|
const workspaces = new ImmutableMap(Object.entries(parsed.workspaces).map(([key, value]) => [key, resolve(base, value)]));
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
124
|
+
const catalog = parsed.skillCatalog ?? {};
|
|
125
|
+
const executionProfiles = new ImmutableMap(Object.entries(parsed.executionProfiles).map(([key, { mcpServers, extensions, skills, ...value }]) => {
|
|
126
|
+
const selection = resolveSkillSelection(skills, catalog, base, overrideDirectory);
|
|
127
|
+
return [key, Object.freeze({
|
|
128
|
+
alias: key, ...value,
|
|
129
|
+
...(mcpServers === undefined ? {} : { mcpServers: snapshotMcpServers(mcpServers) }),
|
|
130
|
+
...(extensions === undefined ? {} : { extensions: Object.freeze(extensions.map((spec) => parseExtensionSpec(spec))) }),
|
|
131
|
+
...(selection === undefined ? {} : { skills: selection }),
|
|
132
|
+
})];
|
|
133
|
+
}));
|
|
94
134
|
const defaultEntry = [...executionProfiles].find(([, profile]) => profile.default);
|
|
95
135
|
if (!defaultEntry)
|
|
96
136
|
throw new Error("Execution profile configuration is invalid");
|
|
97
|
-
return { workspaces, executionProfiles, defaultExecutionProfile: defaultEntry[0], configuration };
|
|
137
|
+
return { workspaces, executionProfiles, defaultExecutionProfile: defaultEntry[0], configuration, skillOverrideDirectory: overrideDirectory };
|
|
98
138
|
}
|
|
@@ -1,11 +1,14 @@
|
|
|
1
|
-
import { access, readFile, readdir, stat } from "node:fs/promises";
|
|
1
|
+
import { access, readFile, readdir, realpath, stat } from "node:fs/promises";
|
|
2
2
|
import { constants } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { loadConfig } from "./config.js";
|
|
5
5
|
import { PiExtensionError } from "./extension-spec.js";
|
|
6
6
|
import { readPackageMetadata } from "./package-metadata.js";
|
|
7
|
+
import { MAX_SKILL_FILE_BYTES } from "./skill-catalog.js";
|
|
8
|
+
import { readSkillBases, SkillBasesError } from "./skill-customize.js";
|
|
9
|
+
import { readSkillTree, shippedSkillsDirectory } from "./skill-library.js";
|
|
7
10
|
const defaultFileSystem = { readFile, stat, access };
|
|
8
|
-
const CHECK_ORDER = ["node_version_unsupported", "package_metadata_unreadable", "build_missing", "build_stale", "version_mismatch", "config_missing", "config_unreadable", "config_invalid", "workspace_missing", "workspace_not_directory", "workspace_unreadable", "workspace_not_writable", "extension_missing", "extension_version_mismatch"];
|
|
11
|
+
const CHECK_ORDER = ["node_version_unsupported", "package_metadata_unreadable", "build_missing", "build_stale", "version_mismatch", "config_missing", "config_unreadable", "config_invalid", "workspace_missing", "workspace_not_directory", "workspace_unreadable", "workspace_not_writable", "extension_missing", "extension_version_mismatch", "skill_missing", "skill_unreadable", "skill_duplicate", "skill_override_outdated"];
|
|
9
12
|
function failure(id, subject, remediation) { return Object.freeze({ id, severity: "error", subject, remediation }); }
|
|
10
13
|
function warning(id, subject, remediation) { return Object.freeze({ id, severity: "warning", subject, remediation }); }
|
|
11
14
|
function ok(id, subject) { return Object.freeze({ id, severity: "ok", subject }); }
|
|
@@ -62,6 +65,37 @@ async function productionBuildInputMtimes(root) {
|
|
|
62
65
|
}
|
|
63
66
|
return mtimes;
|
|
64
67
|
}
|
|
68
|
+
/**
|
|
69
|
+
* Compares the shipped skills with the fingerprints recorded when the operator copied them.
|
|
70
|
+
* Reads only the bases file and the shipped files; nothing is executed or changed.
|
|
71
|
+
*/
|
|
72
|
+
async function skillOverrideChecks(overrideDirectory, shippedDirectory) {
|
|
73
|
+
if (overrideDirectory === undefined)
|
|
74
|
+
return [];
|
|
75
|
+
let bases;
|
|
76
|
+
try {
|
|
77
|
+
bases = await readSkillBases(overrideDirectory);
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
if (error instanceof SkillBasesError)
|
|
81
|
+
return [warning("skill_override_outdated", "skill:bases", "repair or remove the skill bases file")];
|
|
82
|
+
throw error;
|
|
83
|
+
}
|
|
84
|
+
const checks = [];
|
|
85
|
+
for (const [name, base] of Object.entries(bases).sort(([a], [b]) => a.localeCompare(b))) {
|
|
86
|
+
let fingerprint;
|
|
87
|
+
try {
|
|
88
|
+
fingerprint = (await readSkillTree(join(shippedDirectory, name))).fingerprint;
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
checks.push(fingerprint === base
|
|
94
|
+
? ok("skill_override_outdated", `skill:${name}`)
|
|
95
|
+
: warning("skill_override_outdated", `skill:${name}`, `review the changed shipped skill, then run pi-session-mcp-setup --apply --customize-skill ${name}`));
|
|
96
|
+
}
|
|
97
|
+
return checks;
|
|
98
|
+
}
|
|
65
99
|
export async function runDoctor(dependencies = {}) {
|
|
66
100
|
const fs = dependencies.fileSystem ?? defaultFileSystem;
|
|
67
101
|
const root = dependencies.packageRoot ?? process.cwd();
|
|
@@ -195,6 +229,38 @@ export async function runDoctor(dependencies = {}) {
|
|
|
195
229
|
}
|
|
196
230
|
}
|
|
197
231
|
}
|
|
232
|
+
if (config) {
|
|
233
|
+
for (const [alias, profile] of [...config.executionProfiles].sort(([a], [b]) => a.localeCompare(b))) {
|
|
234
|
+
const canonical = new Set();
|
|
235
|
+
for (const skill of profile.skills ?? []) {
|
|
236
|
+
const subject = `${alias}:${skill.alias}`;
|
|
237
|
+
let real;
|
|
238
|
+
try {
|
|
239
|
+
const info = await fs.stat(skill.path);
|
|
240
|
+
if (!info.isFile() || (info.size ?? 0) > MAX_SKILL_FILE_BYTES)
|
|
241
|
+
throw new Error("unusable skill file");
|
|
242
|
+
await fs.access(skill.path, constants.R_OK);
|
|
243
|
+
real = await (fs.realpath ?? realpath)(skill.path);
|
|
244
|
+
}
|
|
245
|
+
catch (error) {
|
|
246
|
+
if (isMissing(error))
|
|
247
|
+
checks.push(failure("skill_missing", subject, "create the skill file or fix its skillCatalog entry"));
|
|
248
|
+
else
|
|
249
|
+
checks.push(ok("skill_missing", subject), failure("skill_unreadable", subject, "point the skillCatalog entry to a readable SKILL.md within the size limit"));
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
checks.push(ok("skill_missing", subject), ok("skill_unreadable", subject));
|
|
253
|
+
if (canonical.has(real))
|
|
254
|
+
checks.push(failure("skill_duplicate", subject, "select each skill file only once per profile"));
|
|
255
|
+
else {
|
|
256
|
+
canonical.add(real);
|
|
257
|
+
checks.push(ok("skill_duplicate", subject));
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
if (config)
|
|
263
|
+
checks.push(...await skillOverrideChecks(config.skillOverrideDirectory, shippedSkillsDirectory(dependencies.packageRoot)));
|
|
198
264
|
const rank = new Map(CHECK_ORDER.map((id, index) => [id, index]));
|
|
199
265
|
checks.sort((a, b) => (rank.get(a.id) ?? 999) - (rank.get(b.id) ?? 999) || a.subject.localeCompare(b.subject));
|
|
200
266
|
const hasError = checks.some((check) => check.severity === "error");
|
|
@@ -9,6 +9,7 @@ export function toResolvedExecutionProfile(profile) {
|
|
|
9
9
|
thinkingLevel: profile.thinkingLevel,
|
|
10
10
|
...(profile.mcpServers === undefined ? {} : { mcpServers: snapshotMcpServers(profile.mcpServers) }),
|
|
11
11
|
...(profile.extensions === undefined ? {} : { extensions: profile.extensions }),
|
|
12
|
+
...(profile.skills === undefined ? {} : { skills: profile.skills }),
|
|
12
13
|
});
|
|
13
14
|
}
|
|
14
15
|
/** A small read-only map implementation which has no mutating methods at runtime. */
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export function result(operation, findings, status = findings.some((finding) => finding.status === "failed" || finding.status === "divergent" || finding.status === "unsupported") ? "failed" : "ok", exitCode = status === "failed" ? 1 : 0) {
|
|
2
|
+
const normalized = findings.map((finding) => Object.freeze({
|
|
3
|
+
target: Object.freeze({ ...finding.target }),
|
|
4
|
+
status: finding.status,
|
|
5
|
+
code: /^[a-z][a-z0-9_]{0,63}$/.test(finding.code) ? finding.code : "operation_failed",
|
|
6
|
+
}));
|
|
7
|
+
return Object.freeze({ schemaVersion: 1, operation, status, findings: Object.freeze(normalized), exitCode });
|
|
8
|
+
}
|
|
@@ -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,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,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
|
+
}
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,91 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.8.0 — 2026-10-12
|
|
6
|
+
|
|
7
|
+
This is a minor release because the public contract changes, as ADR 0003
|
|
8
|
+
requires: the `skills` field of each execution profile in
|
|
9
|
+
`pi_capabilities_get` and the new error code `skill_load_failed`.
|
|
10
|
+
|
|
11
|
+
- Execution profiles can select Pi skills. An optional top-level
|
|
12
|
+
`skillCatalog` maps a skill alias to the path of one `SKILL.md` file (or to
|
|
13
|
+
`builtin:<name>`, see below), and an
|
|
14
|
+
optional `skills` list in a profile selects catalog aliases. MCP callers still
|
|
15
|
+
choose only workspace and profile aliases; no tool takes a skill. A session
|
|
16
|
+
loads exactly the selected skills and validates them at start; a missing,
|
|
17
|
+
unreadable, oversized or invalid skill, a duplicate Pi skill name, two aliases
|
|
18
|
+
on one file, or an explicit-only skill (`disable-model-invocation: true`)
|
|
19
|
+
fails the start with the new error `skill_load_failed`. Every execution
|
|
20
|
+
profile in `pi_capabilities_get` gains `skills`, the selected catalog aliases
|
|
21
|
+
(`[]` when none). Doctor gains `skill_missing`, `skill_unreadable` and
|
|
22
|
+
`skill_duplicate`. Skill file contents are not snapshotted; do not change
|
|
23
|
+
them while sessions that use them are active. See [Pi
|
|
24
|
+
skills](docs/skills.md) and [ADR 0007](docs/adr/0007-profile-skill-selection.md).
|
|
25
|
+
Issue #46.
|
|
26
|
+
- Compatibility change: with Pi SDK 1.1.0, skills that an allowlisted
|
|
27
|
+
extension package bundles reached a session although `noSkills` was set. An
|
|
28
|
+
extension allowlist alone now makes none of its skills available. A profile
|
|
29
|
+
that relied on them needs a catalog entry with the absolute path of the
|
|
30
|
+
package's `SKILL.md` and a `skills` selection; see the migration section of
|
|
31
|
+
[Pi skills](docs/skills.md).
|
|
32
|
+
- Prompt templates and themes that extension packages ship are now closed
|
|
33
|
+
through the SDK's public overrides as well. They were already never expanded
|
|
34
|
+
for MCP prompts.
|
|
35
|
+
- The package ships three skills under `skills/`: `pi-session-orchestration`
|
|
36
|
+
for the controlling Claude Code or Codex agent, and `pi-session-implementation`
|
|
37
|
+
and `pi-session-review` for Pi workers. The example configuration registers
|
|
38
|
+
the two worker skills with `builtin:` references, so a copied example needs
|
|
39
|
+
no path edits for skills.
|
|
40
|
+
- Skill library and `builtin:`. A `skillCatalog` value `builtin:<name>` names a
|
|
41
|
+
skill of the library: the operator's override
|
|
42
|
+
`<configuration directory>/skills/<name>/SKILL.md` if it exists, else the
|
|
43
|
+
skill shipped in the package. Resolution happens when the configuration
|
|
44
|
+
loads, so adding or removing an override needs a restart. A name that matches
|
|
45
|
+
neither keeps the shipped location, so Doctor reports `skill_missing` and a
|
|
46
|
+
session start fails with `skill_load_failed`. A malformed `builtin:` name makes
|
|
47
|
+
the configuration invalid.
|
|
48
|
+
- `pi-session-mcp-setup --customize-skill <name>` (a dry-run; `--apply` writes)
|
|
49
|
+
copies a shipped skill into the override directory as a starting point and
|
|
50
|
+
records the shipped fingerprint in `.pi-session-mcp-bases.json`. It never
|
|
51
|
+
overwrites an existing override. Findings use the target
|
|
52
|
+
`skills/override/<name>` and the new codes `skill_customize_planned`,
|
|
53
|
+
`skill_customized`, `skill_override_current`, `skill_override_outdated`,
|
|
54
|
+
`skill_base_updated`, `skill_not_shipped`, `skill_bases_corrupt`,
|
|
55
|
+
`skill_override_invalid` and `skill_customize_failed`. After a package
|
|
56
|
+
update changed a shipped skill, `--apply --customize-skill <name>` moves only
|
|
57
|
+
the recorded base once you reviewed the change; it never touches your files.
|
|
58
|
+
A new skill needs no command: create `<override dir>/<name>/SKILL.md` and
|
|
59
|
+
reference it as `builtin:<name>`.
|
|
60
|
+
- `pi-session-mcp-setup --apply` now also installs the controller skill
|
|
61
|
+
`pi-session-orchestration` for each target, after the registration, as a real
|
|
62
|
+
copy of the effective skill (override, else shipped): Codex `~/.agents/skills/`,
|
|
63
|
+
Claude Code `user` and `local` `~/.claude/skills/` (one shared directory),
|
|
64
|
+
Claude Code `project` `<project>/.claude/skills/`. The ownership record holds
|
|
65
|
+
only a content fingerprint. Setup never replaces or deletes a modified copy, a
|
|
66
|
+
foreign skill of the same name, hidden operator files inside the copy, or a
|
|
67
|
+
linked or non-directory entry (`skill_divergent`); a link to exactly the
|
|
68
|
+
effective skill is accepted as the operator's own (`skill_linked`). Inside a
|
|
69
|
+
project setup never follows a link, and a project copy is bound to its
|
|
70
|
+
project. A setup result has a second finding per target with the alias
|
|
71
|
+
`pi-session-orchestration` whenever the skill step runs (not for an apply
|
|
72
|
+
that the plan refuses or a registration that failed); the new codes are `skill_absent`,
|
|
73
|
+
`skill_equivalent`, `skill_linked`, `skill_update_planned`, `skill_source_invalid`,
|
|
74
|
+
`skill_installed`, `skill_already_equivalent`, `skill_updated`,
|
|
75
|
+
`skill_install_failed`, `skill_verified`, `skill_missing`,
|
|
76
|
+
`skill_not_equivalent`, `skill_removed`, `skill_kept_shared`,
|
|
77
|
+
`skill_not_owned` and `skill_divergent`. A divergent skill does not block the
|
|
78
|
+
registration but makes the overall status `failed`. `--remove` and
|
|
79
|
+
`--rollback` remove an unmodified copy, and a retry still cleans up a skill
|
|
80
|
+
that setup owns after the registration is gone. Manual symlinking of the
|
|
81
|
+
skill is no longer needed.
|
|
82
|
+
- Doctor gains the warning `skill_override_outdated` (subject `skill:<name>`,
|
|
83
|
+
or `skill:bases` for a corrupt bases file) when a shipped skill changed since
|
|
84
|
+
the operator copied it.
|
|
85
|
+
- Maintainers get `scripts/multi-session-acceptance.mjs`, a live check in which
|
|
86
|
+
a real Claude Code or Codex controller drives two concurrent Pi sessions
|
|
87
|
+
with selected skills, including a targeted abort. It is not part of the npm
|
|
88
|
+
package; see [acceptance](docs/acceptance.md#multi-session-skill-acceptance).
|
|
89
|
+
|
|
5
90
|
## 0.7.1 — 2026-10-11
|
|
6
91
|
|
|
7
92
|
The public MCP contract is unchanged.
|