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
|
@@ -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
|
+
}
|
package/docs/acceptance.md
CHANGED
|
@@ -97,7 +97,12 @@ env -i HOME="$DISPOSABLE_HOME" PATH="$PATH" PI_SESSION_MCP_CONFIG="$ACCEPTANCE_C
|
|
|
97
97
|
### Pass criteria
|
|
98
98
|
|
|
99
99
|
- All nine operations report the expected overall status, finding and exit
|
|
100
|
-
status.
|
|
100
|
+
status. From the change that tracks #46 on, every operation also reports the
|
|
101
|
+
controller skill finding (`skill_absent` twice, `skill_installed`,
|
|
102
|
+
`skill_already_equivalent`, `skill_verified`, `skill_removed`,
|
|
103
|
+
`skill_installed`, `skill_verified`, `skill_removed`); the skill file exists
|
|
104
|
+
after steps 3 and 7 and is gone after steps 6 and 9; a foreign skill placed
|
|
105
|
+
in the same skill directory stays unchanged throughout.
|
|
101
106
|
- Steps 1 and 2 leave the client state unchanged.
|
|
102
107
|
- After step 9 the `pi-session-mcp` registration is absent, the sentinel is still
|
|
103
108
|
present, and the ownership record set is empty. The ownership directory has
|
|
@@ -323,6 +328,99 @@ Manual, on a machine with Pi, `pi-cursor-sdk@0.5.3` and a stored Cursor key. Run
|
|
|
323
328
|
|
|
324
329
|
Record date, source commit, versions and outcome under Status. No secret, path or prompt content goes into the record.
|
|
325
330
|
|
|
331
|
+
## Multi-session skill acceptance
|
|
332
|
+
|
|
333
|
+
A targeted live check for profile skill selection ([skills](skills.md)) and for
|
|
334
|
+
several Pi sessions that one real controller drives at the same time. It is not
|
|
335
|
+
the ten-task Level 3 cycle and does not replace it.
|
|
336
|
+
|
|
337
|
+
The maintainer script `scripts/multi-session-acceptance.mjs` prepares the run,
|
|
338
|
+
starts the controller and checks the outcome. The controller itself calls the
|
|
339
|
+
Pi Session MCP tools; the script never does.
|
|
340
|
+
|
|
341
|
+
### Preparation
|
|
342
|
+
|
|
343
|
+
- Candidate: a separate detached checkout of the commit under test, built with
|
|
344
|
+
`npm ci`, `npm run build` and `npm run build:cli`. `prepare` records the
|
|
345
|
+
commit, whether the checkout is clean, the SHA-256 of `dist/main.js` and,
|
|
346
|
+
from the harness of `cc810c9` on, a SHA-256 over every built module in
|
|
347
|
+
`dist/`; `run` refuses a changed build.
|
|
348
|
+
- Configuration: `prepare --base-config <operator config> --from-profile
|
|
349
|
+
<alias>` copies only provider, model and thinking level of one working profile
|
|
350
|
+
without extensions or MCP servers into a temporary configuration with two
|
|
351
|
+
profiles: `accept-read` (default, `read-only`, skill `review`) and
|
|
352
|
+
`accept-code` (`coding`, skills `implementation` and `handshake`). From the
|
|
353
|
+
harness of `3f98104` on, all three are `builtin:` references: the two worker
|
|
354
|
+
skills ship with the candidate, and the `handshake` skill sits in the
|
|
355
|
+
override directory `skills/` next to the run configuration, so the run also
|
|
356
|
+
exercises library resolution. The `handshake` skill is generated per run and
|
|
357
|
+
holds a random token that no controller prompt contains.
|
|
358
|
+
- Fixtures: four small synthetic git repositories per client (`slug`, `stats`,
|
|
359
|
+
`wait`, `range`), each a separate workspace alias (`cc-*` for Claude Code,
|
|
360
|
+
`cx-*` for Codex). Both clients use the same configuration and build.
|
|
361
|
+
- Controller skill: from the harness of `3f98104` on, `prepare` installs the
|
|
362
|
+
Claude Code controller skill the way an operator does, with the candidate's
|
|
363
|
+
`pi-session-mcp-setup --apply --target claude-code:project:pi-session-mcp` in
|
|
364
|
+
the controller's directory. The setup step runs with a disposable `HOME`, so
|
|
365
|
+
setup state and client files of that step never touch the real home; it must
|
|
366
|
+
report `applied` and `skill_installed` and leave a regular copy identical to
|
|
367
|
+
the shipped skill. Codex installs skills only in the user's home, which the
|
|
368
|
+
run must not change, so the Codex controller gets one run-scoped symbolic link
|
|
369
|
+
in `.agents/skills` of its directory. Earlier runs linked both. No global
|
|
370
|
+
skill directory changes.
|
|
371
|
+
- Clients: Claude Code runs as `claude -p` with `--mcp-config`,
|
|
372
|
+
`--strict-mcp-config`, `--setting-sources project,local`,
|
|
373
|
+
`--permission-mode dontAsk` and an `--allowedTools` list of the eight tools
|
|
374
|
+
plus read-only file tools, `sleep`, `node --test` and `git -C`. Codex runs as
|
|
375
|
+
`codex exec --ignore-user-config --sandbox read-only` with the server
|
|
376
|
+
defined through `-c mcp_servers.pi-candidate.*` and the three per-tool
|
|
377
|
+
approvals from [client setup](client-setup.md#run-codex-without-prompts).
|
|
378
|
+
Neither run changes a persistent registration, `HOME` or `CODEX_HOME`, and
|
|
379
|
+
both use the existing authentication.
|
|
380
|
+
|
|
381
|
+
### Scenarios
|
|
382
|
+
|
|
383
|
+
Each client runs two invocations, one per scenario:
|
|
384
|
+
|
|
385
|
+
| Scenario | What the controller does | What is checked |
|
|
386
|
+
| --- | --- | --- |
|
|
387
|
+
| `parallel` | Capabilities first; two sessions of `accept-code` on `slug` and `stats`; both first turns started before waiting; one follow-up in the `slug` session that adds a test and completes the handshake from the worker's skills; own verification; close both | Candidate version and configuration fingerprint seen through capabilities; `skills` of `accept-code`; first turns overlap by the server's `startedAt` and `completedAt`; follow-up completed; tests pass and only allowed files changed; handshake file holds the token although no prompt contained it |
|
|
388
|
+
| `abort` | Two sessions on `wait` (a bounded `sleep 120` command) and `range` (a bounded `sleep 45`, then the change); abort `wait` about 15 seconds after both turns started, while `range` runs; verify and close both | Every abort result is `ok` (an `abort_timeout` stays on record even after a retry); the first successful abort comes before `pi_turn_get` shows the `wait` turn `aborted`, and both before its session is closed; its abort time lies inside the running interval of `range`; `range` completed and verified; at the end of the run no late file exists and no worker `sleep` remains |
|
|
389
|
+
|
|
390
|
+
### Independent checks
|
|
391
|
+
|
|
392
|
+
- The script watches `/proc` during the run and records every process of the
|
|
393
|
+
controller tree, every server of this run (entry point and configuration
|
|
394
|
+
path) and every process whose working directory lies inside the run, by PID
|
|
395
|
+
and start time. After the controller exits and the server's 15-second
|
|
396
|
+
shutdown bound has passed, none of them may remain.
|
|
397
|
+
- From the controller transcript it extracts only tool names, aliases, states,
|
|
398
|
+
timestamps and counts. It scans every Pi Session MCP result, with
|
|
399
|
+
`assistantText` removed, for local paths, the token and credential markers,
|
|
400
|
+
and counts mentions in `assistantText` separately.
|
|
401
|
+
- Fixture oracles run on the workspace after the controller exits: `node
|
|
402
|
+
--test` and `git status` per fixture. Every changed file must have been last
|
|
403
|
+
written during a worker turn of its task's session, and `HANDSHAKE.txt`
|
|
404
|
+
during the follow-up turn, by the server's turn timestamps. This catches a
|
|
405
|
+
write outside the worker turns, not one during them.
|
|
406
|
+
- Controller writes are limited by the client restrictions: the Codex
|
|
407
|
+
controller runs in a read-only sandbox, and the Claude Code controller has no
|
|
408
|
+
write or edit tool, although its allowed `git -C` could still change a
|
|
409
|
+
fixture.
|
|
410
|
+
- The handshake token must appear neither in a worker prompt nor anywhere in
|
|
411
|
+
the controller transcript, and no worker prompt may name a private path or a
|
|
412
|
+
skill file. The controller's own reads and commands must not name the run's
|
|
413
|
+
private directory or its files; this is a marker check, not a sandbox.
|
|
414
|
+
- The harness records its own SHA-256 at `prepare` and refuses to run or
|
|
415
|
+
re-evaluate a run with another version.
|
|
416
|
+
- `evidence` writes one sanitized JSON record and refuses to write a local path
|
|
417
|
+
or the token. Transcripts and the configuration stay in the run's private
|
|
418
|
+
directory and are never published.
|
|
419
|
+
|
|
420
|
+
A controller that reports success does not count; only the checks above do.
|
|
421
|
+
If a client or its authentication is not available, record that client's part
|
|
422
|
+
as blocked.
|
|
423
|
+
|
|
326
424
|
## Evidence rules
|
|
327
425
|
|
|
328
426
|
- Record only sanitized evidence: statuses, booleans, counts, durations,
|
|
@@ -612,3 +710,147 @@ Pi extension live check with the operator's configuration on 2026-10-11:
|
|
|
612
710
|
prompt.
|
|
613
711
|
- Two Cursor sessions completed in parallel.
|
|
614
712
|
- No process remained, and no foreign line reached the MCP stdout.
|
|
713
|
+
|
|
714
|
+
Profile skill selection (#46) passed a multi-session skill acceptance with Claude
|
|
715
|
+
Code and Codex on 2026-10-12, on Linux (`x64`) with Node.js `26.11.1`, Claude
|
|
716
|
+
Code `2.1.296`, Codex CLI `0.162.1` and Pi SDK `1.1.0`:
|
|
717
|
+
- Candidate: detached checkouts of `bcef6ba` (runs 1 to 3), `a086339` (run 4)
|
|
718
|
+
and `b6cf624` (run 5), each clean and built with `npm ci`, `npm run build`
|
|
719
|
+
and `npm run build:cli`. `dist/main.js` has the same SHA-256 in all five
|
|
720
|
+
runs, `47875d38886528ace6b242df2c6ad79d624c51e330e63c2f401576eadc449ed7`:
|
|
721
|
+
the commits after `bcef6ba` change only the orchestration skill text, the
|
|
722
|
+
acceptance harness and documentation.
|
|
723
|
+
- Level 1 on `bcef6ba`, from the command output of the maintainer's gate run:
|
|
724
|
+
every command exited with `0`. The test run reported
|
|
725
|
+
40 files, 761 passed and 5 skipped; `npm audit` reported 0 vulnerabilities;
|
|
726
|
+
`npm pack --dry-run` listed the three skills and no harness. The test suite
|
|
727
|
+
passed again on `a086339` with the same counts.
|
|
728
|
+
- Configuration: the same bytes in every run, fingerprint
|
|
729
|
+
`sha256:b754f0f5e4702d81d99183817abb379eeed2fb5733f93373852eb827ec16d687`,
|
|
730
|
+
with the profiles `accept-read` and `accept-code` derived from an OpenAI
|
|
731
|
+
`gpt-6-luna` profile with thinking level `low`, and the fixture aliases
|
|
732
|
+
`cc-slug`, `cc-stats`, `cc-wait`, `cc-range`, `cx-slug`, `cx-stats`, `cx-wait`
|
|
733
|
+
and `cx-range`. Offline Doctor reported no finding for it.
|
|
734
|
+
- Every invocation confirmed the candidate's version and this fingerprint
|
|
735
|
+
through `pi_capabilities_get` as its first call, and saw the skills
|
|
736
|
+
`implementation` and `handshake` on `accept-code`. Exactly one server of the
|
|
737
|
+
run was observed per invocation, and no process of the controller tree, the
|
|
738
|
+
server or the fixtures remained afterwards. No Pi Session MCP result
|
|
739
|
+
contained a local path, the token or a credential marker, and no
|
|
740
|
+
`assistantText` mentioned one.
|
|
741
|
+
|
|
742
|
+
Run 5, on `b6cf624` with the harness of that commit (SHA-256
|
|
743
|
+
`ca851130bbade9f0b2700a43059b7ac3ce349dad40ad2b647133f950a408c719`), passed
|
|
744
|
+
every check of all four invocations:
|
|
745
|
+
|
|
746
|
+
| Client | Scenario | Result |
|
|
747
|
+
| --- | --- | --- |
|
|
748
|
+
| Claude Code | `parallel` | First turns overlapped by 25.1 s; follow-up in the same session completed; both fixtures pass their tests with only the allowed files changed, each last written during a worker turn; `HANDSHAKE.txt` holds the token and was written during the follow-up turn, although no worker prompt contained the token and the token never appeared in the controller transcript; no controller action named the private run directory; both sessions closed |
|
|
749
|
+
| Claude Code | `abort` | First turns overlapped by 23.7 s; one abort, result `ok`; `aborted` seen through `pi_turn_get` before close; the abort fell inside the running interval of the neighbor, which then completed and passes its tests; the worker's `sleep 120` was observed running, and at the end of the run it was gone and had written no file; both sessions closed |
|
|
750
|
+
| Codex | `parallel` | As for Claude Code, with an overlap of 24.8 s |
|
|
751
|
+
| Codex | `abort` | As for Claude Code, with an overlap of 22.4 s |
|
|
752
|
+
|
|
753
|
+
The controllers made 1 to 6 `pi_turn_get` calls per session and waited between
|
|
754
|
+
polls with `sleep`. Each invocation took 93 to 156 seconds. The run's
|
|
755
|
+
transcripts were also checked afterwards against the criteria the harness
|
|
756
|
+
gained after run 5: in both `abort` invocations the first successful abort came
|
|
757
|
+
before the first `aborted` observation, and no worker prompt named a private
|
|
758
|
+
path or a skill file. Run 5 used the harness of `b6cf624`; the head's harness
|
|
759
|
+
adds these two checks and the run-time checksum check, and changes nothing
|
|
760
|
+
else.
|
|
761
|
+
|
|
762
|
+
Earlier attempts on the same build, kept on record:
|
|
763
|
+
- Run 1 (Claude Code only, with the harness of `bcef6ba`, which did not record
|
|
764
|
+
its checksum yet): `parallel` passed
|
|
765
|
+
every check. In `abort` the abort itself passed (result `ok`, `aborted` seen
|
|
766
|
+
before close, the worker's `sleep` ended, no late file), but the neighbor
|
|
767
|
+
turn had completed about three seconds before the abort, so the run shows no
|
|
768
|
+
neighbor that keeps running. The neighbor task now starts with a bounded
|
|
769
|
+
`sleep 45` and the abort comes after about 15 seconds.
|
|
770
|
+
- Run 2: three invocations passed every check. In Codex `parallel` every
|
|
771
|
+
functional check passed, including the handshake written during the
|
|
772
|
+
follow-up turn with a token that no prompt contained, but the controller
|
|
773
|
+
then read the run's private handshake skill to confirm the token. The
|
|
774
|
+
controller prompt now confines reads and commands to the fixtures.
|
|
775
|
+
- Run 3: three invocations passed every check. In Codex `abort` the abort, the
|
|
776
|
+
running neighbor and cleanup passed, but the controller told both workers to
|
|
777
|
+
"follow configured handshake instructions" because capabilities listed a
|
|
778
|
+
`handshake` skill, so both fixtures got an extra `HANDSHAKE.txt`. The
|
|
779
|
+
orchestration skill now says that a listed skill is not a task.
|
|
780
|
+
- Run 4 (`a086339`): three invocations passed every check. The Codex
|
|
781
|
+
`parallel` controller read the fixtures-only rule as excluding its own skill
|
|
782
|
+
directory, worked from `./fixtures`, and stopped after
|
|
783
|
+
`pi_capabilities_get` without starting a session. The rule now allows the
|
|
784
|
+
controller's own skill directory.
|
|
785
|
+
|
|
786
|
+
No run showed a Pi Session MCP defect. The deviations were controller
|
|
787
|
+
behavior and harness wording.
|
|
788
|
+
|
|
789
|
+
This is a targeted check of skill selection and of two concurrent sessions
|
|
790
|
+
per client. It is not the ten-task Level 3 cycle. It covers neither the
|
|
791
|
+
extension profile path live (the automated SDK contract tests cover it), nor
|
|
792
|
+
`read-only` workers, nor more than two concurrent sessions, nor macOS.
|
|
793
|
+
|
|
794
|
+
After the owner asked on 2026-10-12 that skills install without manual linking
|
|
795
|
+
and can be adjusted and extended, the same change gained the skill library,
|
|
796
|
+
`builtin:` references, `--customize-skill` and the controller-skill
|
|
797
|
+
installation through `setup`. Its final candidate `0ebbaa0` passed on
|
|
798
|
+
2026-10-12, on Linux (`x64`) with Node.js `26.11.1`, Claude Code `2.1.296`,
|
|
799
|
+
Codex CLI `0.162.1` and Pi SDK `1.1.0`:
|
|
800
|
+
- Level 1 on a clean detached checkout: every command exited with `0`. The
|
|
801
|
+
test run reported 43 files, 884 passed and 5 skipped; `npm audit` reported 0
|
|
802
|
+
vulnerabilities; `npm pack --dry-run` listed 125 files with the three skills
|
|
803
|
+
and no acceptance harness. `git diff --check` from `0cb3c60` reported nothing.
|
|
804
|
+
- Level 2 through `scripts/setup-acceptance.mjs` with the setup of that
|
|
805
|
+
checkout: 160 of 160 checks passed. All nine steps passed on all four
|
|
806
|
+
targets, including the controller skill finding of every step, the skill file
|
|
807
|
+
after steps 3 and 7 and its absence after steps 6 and 9, and a foreign skill
|
|
808
|
+
that stayed unchanged. The real client registrations were unchanged, no
|
|
809
|
+
process remained, no report leaked a path, and the build was unchanged.
|
|
810
|
+
Earlier candidates `24ab13d` and `37034dd` of the same change had passed
|
|
811
|
+
Level 2 with 160 of 160 as well, before the review fixes that followed them.
|
|
812
|
+
- Multi-session skill acceptance, run 7, with the harness of that checkout
|
|
813
|
+
(SHA-256 `42d46f86ae617c229568437e6d96c93f965c27778c3e2fde51e1c449ef35fb12`)
|
|
814
|
+
and the dist tree SHA-256
|
|
815
|
+
`957dfac67bf0c3c179bff6584b3bda49bedad2fe1642790537f6790b31f5ef37`:
|
|
816
|
+
- `prepare` installed the Claude Code controller skill with the candidate's
|
|
817
|
+
`setup --apply` for the controller's project scope: `applied` and
|
|
818
|
+
`skill_installed`, a regular copy identical to the shipped skill.
|
|
819
|
+
- The configuration (fingerprint
|
|
820
|
+
`sha256:b8a341c9ff48ecd7c02f8959b8e4e696f305431945b659ea35d2d98485e948c3`)
|
|
821
|
+
referenced all three skills with `builtin:`: the two shipped worker skills
|
|
822
|
+
and the `handshake` skill from the override directory next to it.
|
|
823
|
+
- All four invocations passed every check: Claude Code `parallel` (first
|
|
824
|
+
turns overlapped by 21.5 s) and `abort` (22.1 s), Codex `parallel` (22.9 s)
|
|
825
|
+
and `abort` (18.9 s). In both `parallel` invocations the worker wrote the
|
|
826
|
+
handshake token, which existed only in the override-directory skill,
|
|
827
|
+
during the follow-up turn. In both `abort` invocations the one abort
|
|
828
|
+
returned `ok`, came before the `aborted` observation and fell inside the
|
|
829
|
+
running neighbor, which completed and passed its tests. No process
|
|
830
|
+
remained, and no result leaked a path, the token or a credential marker.
|
|
831
|
+
- Run 6 on `cc810c9`, before the last review fixes, had passed every check of
|
|
832
|
+
all four invocations with the same configuration fingerprint and the
|
|
833
|
+
setup-installed Claude Code skill.
|
|
834
|
+
|
|
835
|
+
These runs do not cover macOS, Codex skill discovery from the user's home
|
|
836
|
+
(setup installs it there, and Level 2 checks the file, but no live Codex
|
|
837
|
+
controller read it from there), the extension profile path live, or more than
|
|
838
|
+
two concurrent sessions.
|
|
839
|
+
|
|
840
|
+
Before its release, `v0.8.0` passed Level 1 and the working-tree Level 2 on
|
|
841
|
+
its release candidate `6d197a0` on Linux (`x64`), with Node.js `26.11.1`, Codex
|
|
842
|
+
CLI `0.162.1` and Claude Code `2.1.296`, on 2026-10-12:
|
|
843
|
+
- Level 1: every command exited with `0`.
|
|
844
|
+
- The test run reported 43 files, 884 passed and 5 skipped.
|
|
845
|
+
- `npm audit` and `npm audit --omit=dev` reported 0 vulnerabilities.
|
|
846
|
+
- `npm pack --dry-run` listed 125 files, including the three skills.
|
|
847
|
+
- Doctor reported `packageVersion` `0.8.0`. With the example
|
|
848
|
+
configuration its only finding was the missing placeholder workspace.
|
|
849
|
+
- Level 2 ran through `scripts/setup-acceptance.mjs` on the candidate's
|
|
850
|
+
working tree: 160 of 160 checks passed. All nine steps passed on all four
|
|
851
|
+
targets, including the controller skill finding of every step. The real
|
|
852
|
+
client registrations were unchanged, no process remained, and the built
|
|
853
|
+
files were unchanged.
|
|
854
|
+
- Apart from the version, the candidate's sources equal the merge of #48,
|
|
855
|
+
whose final candidate `0ebbaa0` passed the multi-session skill acceptance
|
|
856
|
+
recorded above.
|
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
**Status:** Accepted (2026-10-11). Issue #34. Design:
|
|
4
4
|
[Pi extensions design](../pi-extensions-design.md).
|
|
5
5
|
|
|
6
|
+
Amended by [ADR 0007](0007-profile-skill-selection.md) for skills that extension
|
|
7
|
+
packages ship.
|
|
8
|
+
|
|
6
9
|
## Context
|
|
7
10
|
|
|
8
11
|
Embedded sessions loaded no Pi extensions. Providers that exist only as Pi
|