pi-session-mcp 0.8.0 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.pi-session-mcp-cli/doctor.js +14 -7
- package/.pi-session-mcp-cli/override-lock.js +125 -0
- package/.pi-session-mcp-cli/setup-platform.js +195 -0
- package/.pi-session-mcp-cli/skill-customize.js +131 -46
- package/.pi-session-mcp-cli/skill-library.js +72 -4
- package/CHANGELOG.md +91 -0
- package/README.md +1 -1
- package/dist/doctor.d.ts +3 -0
- package/dist/doctor.js +14 -7
- package/dist/override-lock.d.ts +34 -0
- package/dist/override-lock.js +125 -0
- package/dist/pi-adapter.d.ts +6 -4
- package/dist/sdk-pi-adapter.js +28 -5
- package/dist/session-registry.d.ts +8 -6
- package/dist/session-registry.js +3 -2
- package/dist/setup-ownership.d.ts +4 -1
- package/dist/setup-ownership.js +6 -3
- package/dist/setup-skills.d.ts +40 -1
- package/dist/setup-skills.js +311 -78
- package/dist/skill-customize.d.ts +19 -1
- package/dist/skill-customize.js +131 -46
- package/dist/skill-library.d.ts +46 -1
- package/dist/skill-library.js +72 -4
- package/docs/acceptance.md +365 -15
- package/docs/adr/0007-profile-skill-selection.md +11 -0
- package/docs/architecture.md +1 -1
- package/docs/client-setup-design.md +106 -20
- package/docs/client-setup.md +19 -7
- package/docs/doctor-design.md +1 -1
- package/docs/installation.md +8 -2
- package/docs/pi-extensions-design.md +11 -0
- package/docs/releasing.md +18 -9
- package/docs/skills.md +145 -16
- package/docs/threat-model.md +3 -3
- package/docs/tool-contracts.md +2 -2
- package/package.json +1 -1
- package/skills/pi-session-orchestration/SKILL.md +5 -4
package/dist/skill-customize.js
CHANGED
|
@@ -1,18 +1,24 @@
|
|
|
1
1
|
import { randomBytes } from "node:crypto";
|
|
2
2
|
import { constants } from "node:fs";
|
|
3
|
-
import { lstat, mkdir, open, readFile, rename, rm, unlink
|
|
3
|
+
import { lstat, mkdir, open, readFile, rename, rm, unlink } from "node:fs/promises";
|
|
4
4
|
import { dirname, join } from "node:path";
|
|
5
|
+
import { acquireOverrideLock, OverrideLockError } from "./override-lock.js";
|
|
5
6
|
import { result } from "./setup-result.js";
|
|
6
|
-
import { readSkillTree, shippedSkillsDirectory, SKILL_NAME_PATTERN, SkillTreeError, skillOverrideDirectory } from "./skill-library.js";
|
|
7
|
+
import { LEGACY_SKILL_TREE_FINGERPRINT_PATTERN, readSkillTree, releasedSkillFingerprint, shippedSkillsDirectory, SKILL_NAME_PATTERN, SKILL_TREE_FINGERPRINT_PATTERN, SkillTreeError, skillOverrideDirectory, } from "./skill-library.js";
|
|
7
8
|
/**
|
|
8
9
|
* `--customize-skill <name>`: copies a shipped skill into the operator's override directory as a
|
|
9
10
|
* starting point, and records the shipped fingerprint it was based on. The operator's files are
|
|
10
11
|
* never overwritten or edited; a changed original is only reported and acknowledged by moving
|
|
11
12
|
* the recorded base. Reports carry the skill name and a code, never a path or file content.
|
|
13
|
+
*
|
|
14
|
+
* Applying is a cross-process transaction: everything from reading the bases file to replacing it
|
|
15
|
+
* runs under the kernel lock of `override-lock.ts`, so two runs for different skills cannot lose
|
|
16
|
+
* each other's base. A dry run takes no lock and writes nothing.
|
|
12
17
|
*/
|
|
13
18
|
export const SKILL_BASES_FILE = ".pi-session-mcp-bases.json";
|
|
14
19
|
export const MAX_SKILL_BASES_BYTES = 64 * 1024;
|
|
15
|
-
|
|
20
|
+
/** Written by this version; version 1 (0.8.0) holds only legacy fingerprints and is still read. */
|
|
21
|
+
const BASES_SCHEMA_VERSION = 2;
|
|
16
22
|
export class SkillBasesError extends Error {
|
|
17
23
|
constructor() {
|
|
18
24
|
super("Skill bases file is not usable");
|
|
@@ -46,13 +52,17 @@ export async function readSkillBases(overrideDirectory) {
|
|
|
46
52
|
catch {
|
|
47
53
|
throw new SkillBasesError();
|
|
48
54
|
}
|
|
49
|
-
if (!isPlainObject(parsed) || Object.keys(parsed).sort().join() !== "bases,schemaVersion"
|
|
55
|
+
if (!isPlainObject(parsed) || Object.keys(parsed).sort().join() !== "bases,schemaVersion")
|
|
56
|
+
throw new SkillBasesError();
|
|
57
|
+
const version = parsed.schemaVersion;
|
|
58
|
+
if (version !== 1 && version !== BASES_SCHEMA_VERSION)
|
|
50
59
|
throw new SkillBasesError();
|
|
51
60
|
const bases = parsed.bases;
|
|
52
61
|
if (!isPlainObject(bases))
|
|
53
62
|
throw new SkillBasesError();
|
|
54
63
|
const entries = Object.entries(bases);
|
|
55
|
-
|
|
64
|
+
const validValue = (value) => typeof value === "string" && (LEGACY_SKILL_TREE_FINGERPRINT_PATTERN.test(value) || (version === BASES_SCHEMA_VERSION && SKILL_TREE_FINGERPRINT_PATTERN.test(value)));
|
|
65
|
+
if (!entries.every(([name, value]) => SKILL_NAME_PATTERN.test(name) && validValue(value)))
|
|
56
66
|
throw new SkillBasesError();
|
|
57
67
|
return Object.freeze(Object.fromEntries(entries));
|
|
58
68
|
}
|
|
@@ -62,10 +72,25 @@ function isPlainObject(value) {
|
|
|
62
72
|
const prototype = Object.getPrototypeOf(value);
|
|
63
73
|
return prototype === Object.prototype || prototype === null;
|
|
64
74
|
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
75
|
+
async function syncDirectory(path) {
|
|
76
|
+
const handle = await open(path, "r");
|
|
77
|
+
try {
|
|
78
|
+
await handle.sync();
|
|
79
|
+
}
|
|
80
|
+
finally {
|
|
81
|
+
await handle.close();
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Atomic replacement: a private temporary file in the same directory, fsynced, then a rename and a
|
|
86
|
+
* directory fsync. Every provable legacy entry is migrated to its version 2 value on the way; an
|
|
87
|
+
* entry that no released tree names stays verbatim. A failure before the rename leaves the
|
|
88
|
+
* previous file untouched.
|
|
89
|
+
*/
|
|
90
|
+
async function writeSkillBases(overrideDirectory, bases, released, beforeRename) {
|
|
91
|
+
const migrated = Object.entries(bases).map(([name, value]) => [name, LEGACY_SKILL_TREE_FINGERPRINT_PATTERN.test(value) ? releasedSkillFingerprint(name, value, released) ?? value : value]);
|
|
92
|
+
const sorted = Object.fromEntries(migrated.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)));
|
|
93
|
+
const content = Buffer.from(`${JSON.stringify({ schemaVersion: BASES_SCHEMA_VERSION, bases: sorted })}\n`, "utf8");
|
|
69
94
|
if (content.length > MAX_SKILL_BASES_BYTES)
|
|
70
95
|
throw new SkillBasesError();
|
|
71
96
|
const temporary = join(overrideDirectory, `.${SKILL_BASES_FILE.slice(1)}.${randomBytes(6).toString("hex")}.tmp`);
|
|
@@ -78,12 +103,14 @@ async function writeSkillBases(overrideDirectory, bases) {
|
|
|
78
103
|
finally {
|
|
79
104
|
await handle.close();
|
|
80
105
|
}
|
|
106
|
+
await beforeRename?.();
|
|
81
107
|
await rename(temporary, join(overrideDirectory, SKILL_BASES_FILE));
|
|
82
108
|
}
|
|
83
109
|
catch (error) {
|
|
84
110
|
await unlink(temporary).catch(() => undefined);
|
|
85
111
|
throw error;
|
|
86
112
|
}
|
|
113
|
+
await syncDirectory(overrideDirectory);
|
|
87
114
|
}
|
|
88
115
|
function isMissing(error) {
|
|
89
116
|
return typeof error === "object" && error !== null && error.code === "ENOENT";
|
|
@@ -96,7 +123,40 @@ function report(request, status, code) {
|
|
|
96
123
|
// A planned change reports `planned` overall, like a registration dry-run.
|
|
97
124
|
return result(request.apply ? "apply" : "dry-run", [finding], status === "planned" ? "planned" : undefined);
|
|
98
125
|
}
|
|
99
|
-
|
|
126
|
+
const OUTDATED = Object.freeze({ kind: "record", planned: "skill_override_outdated", applied: "skill_base_updated" });
|
|
127
|
+
/** Pure with respect to the library: reads the override tree, never changes anything. */
|
|
128
|
+
async function decide(name, overridePath, shipped, bases, released) {
|
|
129
|
+
try {
|
|
130
|
+
if (!(await lstat(overridePath)).isDirectory())
|
|
131
|
+
return { kind: "failed", code: "skill_override_invalid" };
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
if (!isMissing(error))
|
|
135
|
+
throw error;
|
|
136
|
+
return { kind: "copy", planned: "skill_customize_planned", applied: "skill_customized" };
|
|
137
|
+
}
|
|
138
|
+
let override;
|
|
139
|
+
try {
|
|
140
|
+
override = await readSkillTree(overridePath);
|
|
141
|
+
}
|
|
142
|
+
catch (error) {
|
|
143
|
+
if (error instanceof SkillTreeError)
|
|
144
|
+
return { kind: "failed", code: "skill_override_invalid" };
|
|
145
|
+
throw error;
|
|
146
|
+
}
|
|
147
|
+
const base = Object.hasOwn(bases, name) ? bases[name] : undefined;
|
|
148
|
+
// Without a base an identical override is a customization whose bases commit failed earlier.
|
|
149
|
+
if (base === undefined)
|
|
150
|
+
return override.fingerprint === shipped.fingerprint ? { kind: "record", planned: "skill_customize_planned", applied: "skill_customized" } : OUTDATED;
|
|
151
|
+
if (SKILL_TREE_FINGERPRINT_PATTERN.test(base))
|
|
152
|
+
return base === shipped.fingerprint ? { kind: "current" } : OUTDATED;
|
|
153
|
+
// A legacy base only names a released original when a trusted reference tree has that value.
|
|
154
|
+
const original = releasedSkillFingerprint(name, base, released);
|
|
155
|
+
if (original === undefined)
|
|
156
|
+
return { kind: "record", planned: "skill_base_unverified", applied: "skill_base_updated" };
|
|
157
|
+
return original === shipped.fingerprint ? { kind: "record", planned: "skill_base_migration_planned", applied: "skill_base_migrated" } : OUTDATED;
|
|
158
|
+
}
|
|
159
|
+
export async function customizeSkill(request, dependencies = {}) {
|
|
100
160
|
const done = (status, code) => report(request, status, code);
|
|
101
161
|
if (!SKILL_NAME_PATTERN.test(request.name))
|
|
102
162
|
return done("failed", "skill_not_shipped");
|
|
@@ -112,69 +172,94 @@ export async function customizeSkill(request) {
|
|
|
112
172
|
return done("failed", "skill_customize_failed");
|
|
113
173
|
}
|
|
114
174
|
const overrideDirectory = skillOverrideDirectory(request.configPath);
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
175
|
+
const overridePath = join(overrideDirectory, request.name);
|
|
176
|
+
const read = async () => {
|
|
177
|
+
try {
|
|
178
|
+
return await readSkillBases(overrideDirectory);
|
|
179
|
+
}
|
|
180
|
+
catch (error) {
|
|
181
|
+
if (error instanceof SkillBasesError)
|
|
182
|
+
return undefined;
|
|
183
|
+
throw error;
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
if (!request.apply) {
|
|
187
|
+
// No lock and no write: the report is a snapshot of what an apply would decide.
|
|
188
|
+
const bases = await read();
|
|
189
|
+
if (bases === undefined)
|
|
121
190
|
return done("failed", "skill_bases_corrupt");
|
|
122
|
-
|
|
191
|
+
const decision = await decide(request.name, overridePath, shipped, bases, dependencies.released);
|
|
192
|
+
if (decision.kind === "failed")
|
|
193
|
+
return done("failed", decision.code);
|
|
194
|
+
return decision.kind === "current" ? done("unchanged", "skill_override_current") : done("planned", decision.planned);
|
|
123
195
|
}
|
|
124
|
-
|
|
125
|
-
let
|
|
196
|
+
await mkdir(overrideDirectory, { recursive: true, mode: 0o700 });
|
|
197
|
+
let lock;
|
|
126
198
|
try {
|
|
127
|
-
|
|
128
|
-
if (!info.isDirectory())
|
|
129
|
-
return done("failed", "skill_override_invalid");
|
|
199
|
+
lock = await acquireOverrideLock(overrideDirectory, dependencies.lock);
|
|
130
200
|
}
|
|
131
201
|
catch (error) {
|
|
132
|
-
if (
|
|
133
|
-
|
|
134
|
-
|
|
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");
|
|
202
|
+
if (error instanceof OverrideLockError)
|
|
203
|
+
return done("failed", error.code === "override_lock_busy" ? "skill_customize_lock_busy" : "skill_customize_lock_unavailable");
|
|
204
|
+
throw error;
|
|
142
205
|
}
|
|
143
206
|
try {
|
|
144
|
-
await
|
|
207
|
+
await dependencies.afterLock?.();
|
|
208
|
+
// Read only now: a snapshot from before the lock may miss a base another run just wrote.
|
|
209
|
+
const bases = await read();
|
|
210
|
+
if (bases === undefined)
|
|
211
|
+
return done("failed", "skill_bases_corrupt");
|
|
212
|
+
const decision = await decide(request.name, overridePath, shipped, bases, dependencies.released);
|
|
213
|
+
if (decision.kind === "failed")
|
|
214
|
+
return done("failed", decision.code);
|
|
215
|
+
if (decision.kind === "current")
|
|
216
|
+
return done("unchanged", "skill_override_current");
|
|
217
|
+
if (decision.kind === "copy")
|
|
218
|
+
await copyShippedSkill(shipped.files, overrideDirectory, overridePath, request.name);
|
|
219
|
+
await writeSkillBases(overrideDirectory, { ...bases, [request.name]: shipped.fingerprint }, dependencies.released, dependencies.beforeBasesRename);
|
|
220
|
+
return done("ok", decision.applied);
|
|
145
221
|
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
return done("failed", "skill_override_invalid");
|
|
149
|
-
throw error;
|
|
222
|
+
finally {
|
|
223
|
+
await lock.release();
|
|
150
224
|
}
|
|
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
225
|
}
|
|
159
226
|
catch {
|
|
160
227
|
return done("failed", "skill_customize_failed");
|
|
161
228
|
}
|
|
162
229
|
}
|
|
163
230
|
async function copyShippedSkill(files, overrideDirectory, overridePath, name) {
|
|
164
|
-
await mkdir(overrideDirectory, { recursive: true, mode: 0o700 });
|
|
165
231
|
const temporary = join(overrideDirectory, `.${name}.${randomBytes(6).toString("hex")}.tmp`);
|
|
166
232
|
try {
|
|
167
233
|
await mkdir(temporary, { mode: 0o755 });
|
|
168
234
|
for (const file of files) {
|
|
169
235
|
const destination = join(temporary, ...file.path.split("/"));
|
|
170
236
|
await mkdir(dirname(destination), { recursive: true });
|
|
171
|
-
await
|
|
237
|
+
const handle = await open(destination, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o644);
|
|
238
|
+
try {
|
|
239
|
+
await handle.writeFile(file.bytes);
|
|
240
|
+
await handle.sync();
|
|
241
|
+
}
|
|
242
|
+
finally {
|
|
243
|
+
await handle.close();
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
// Every directory entry of the temporary tree is durable before the rename and the bases commit.
|
|
247
|
+
const directories = new Set([temporary]);
|
|
248
|
+
for (const file of files) {
|
|
249
|
+
let cursor = dirname(join(temporary, ...file.path.split("/")));
|
|
250
|
+
while (cursor !== temporary) {
|
|
251
|
+
directories.add(cursor);
|
|
252
|
+
cursor = dirname(cursor);
|
|
253
|
+
}
|
|
172
254
|
}
|
|
255
|
+
for (const directory of [...directories].sort((left, right) => right.length - left.length))
|
|
256
|
+
await syncDirectory(directory);
|
|
173
257
|
// The override must not appear while the copy runs, and an operator's directory is never replaced.
|
|
174
258
|
if (await lstat(overridePath).then(() => true, (error) => { if (isMissing(error))
|
|
175
259
|
return false; throw error; }))
|
|
176
260
|
throw new Error("override appeared");
|
|
177
261
|
await rename(temporary, overridePath);
|
|
262
|
+
await syncDirectory(overrideDirectory);
|
|
178
263
|
}
|
|
179
264
|
catch (error) {
|
|
180
265
|
await rm(temporary, { recursive: true, force: true }).catch(() => undefined);
|
package/dist/skill-library.d.ts
CHANGED
|
@@ -37,13 +37,58 @@ export interface SkillTreeFile {
|
|
|
37
37
|
}
|
|
38
38
|
export interface SkillTree {
|
|
39
39
|
readonly files: readonly SkillTreeFile[];
|
|
40
|
-
/** SHA-256 hex
|
|
40
|
+
/** Version 2 fingerprint (`v2:` and SHA-256 hex) of the paths and contents; path-free and stable. */
|
|
41
41
|
readonly fingerprint: string;
|
|
42
|
+
/**
|
|
43
|
+
* The version 1 fingerprint that 0.8.0 recorded, only to recognize such records. It is
|
|
44
|
+
* ambiguous and never proves identity on its own; see `provesLegacyFingerprint`.
|
|
45
|
+
*/
|
|
46
|
+
readonly legacyFingerprint: string;
|
|
42
47
|
}
|
|
43
48
|
export declare class SkillTreeError extends Error {
|
|
44
49
|
readonly code: "skill_tree_missing" | "skill_tree_invalid" | "skill_tree_too_large";
|
|
45
50
|
constructor(code: "skill_tree_missing" | "skill_tree_invalid" | "skill_tree_too_large");
|
|
46
51
|
}
|
|
52
|
+
/** A version 2 skill tree fingerprint as it is stored. */
|
|
53
|
+
export declare const SKILL_TREE_FINGERPRINT_PATTERN: RegExp;
|
|
54
|
+
/** A version 1 fingerprint as 0.8.0 stored it: bare SHA-256 hex. */
|
|
55
|
+
export declare const LEGACY_SKILL_TREE_FINGERPRINT_PATTERN: RegExp;
|
|
56
|
+
/**
|
|
57
|
+
* Version 2: a domain and version tag, the file count, then per file in byte order of the path
|
|
58
|
+
* the length-prefixed UTF-8 path and the length-prefixed content. Every field has an explicit
|
|
59
|
+
* length, so two different trees never share an encoding.
|
|
60
|
+
*/
|
|
61
|
+
export declare function skillTreeFingerprint(files: readonly SkillTreeFile[]): string;
|
|
62
|
+
/**
|
|
63
|
+
* Version 1, as 0.8.0 computed it: `path NUL content NUL` per file in reading order. File
|
|
64
|
+
* content may contain NUL bytes, so two different trees can share this value.
|
|
65
|
+
*/
|
|
66
|
+
export declare function legacySkillTreeFingerprint(files: readonly SkillTreeFile[]): string;
|
|
67
|
+
export interface ReleasedSkillTree {
|
|
68
|
+
readonly name: string;
|
|
69
|
+
readonly release: string;
|
|
70
|
+
readonly legacyFingerprint: string;
|
|
71
|
+
readonly fingerprint: string;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* The shipped skills of every release that recorded version 1 fingerprints (0.8.0 only), as
|
|
75
|
+
* trusted references for recognizing such records. Computed from the `v0.8.0` tag and checked
|
|
76
|
+
* against the published npm package.
|
|
77
|
+
*/
|
|
78
|
+
export declare const RELEASED_SKILL_TREES: readonly ReleasedSkillTree[];
|
|
79
|
+
/**
|
|
80
|
+
* Whether a version 1 fingerprint from a record proves that `candidate` is the tree it names:
|
|
81
|
+
* a trusted reference, a released shipped tree of this name or one of `references` (such as
|
|
82
|
+
* the current library tree), has exactly this version 1 fingerprint and is byte-identical to
|
|
83
|
+
* `candidate` by its version 2 fingerprint. The version 1 fingerprint alone never suffices.
|
|
84
|
+
*/
|
|
85
|
+
export declare function provesLegacyFingerprint(name: string, legacyFingerprint: string, candidate: Pick<SkillTree, "fingerprint">, references?: readonly Pick<SkillTree, "fingerprint" | "legacyFingerprint">[], released?: readonly ReleasedSkillTree[]): boolean;
|
|
86
|
+
/**
|
|
87
|
+
* The version 2 fingerprint of the released shipped skill that a version 1 fingerprint names,
|
|
88
|
+
* or undefined. Only released trees qualify: the current shipped tree could share a version 1
|
|
89
|
+
* fingerprint with an older original without being that original.
|
|
90
|
+
*/
|
|
91
|
+
export declare function releasedSkillFingerprint(name: string, legacyFingerprint: string, released?: readonly ReleasedSkillTree[]): string | undefined;
|
|
47
92
|
export interface ReadSkillTreeOptions {
|
|
48
93
|
/**
|
|
49
94
|
* `skip` (default) ignores entries whose name starts with `.`, as for a source tree. `reject`
|
package/dist/skill-library.js
CHANGED
|
@@ -59,6 +59,77 @@ export class SkillTreeError extends Error {
|
|
|
59
59
|
this.name = "SkillTreeError";
|
|
60
60
|
}
|
|
61
61
|
}
|
|
62
|
+
/** A version 2 skill tree fingerprint as it is stored. */
|
|
63
|
+
export const SKILL_TREE_FINGERPRINT_PATTERN = /^v2:[0-9a-f]{64}$/;
|
|
64
|
+
/** A version 1 fingerprint as 0.8.0 stored it: bare SHA-256 hex. */
|
|
65
|
+
export const LEGACY_SKILL_TREE_FINGERPRINT_PATTERN = /^[0-9a-f]{64}$/;
|
|
66
|
+
const FINGERPRINT_DOMAIN = "pi-session-mcp skill tree v2\n";
|
|
67
|
+
function uint32(value) {
|
|
68
|
+
const bytes = Buffer.alloc(4);
|
|
69
|
+
bytes.writeUInt32BE(value);
|
|
70
|
+
return bytes;
|
|
71
|
+
}
|
|
72
|
+
function uint64(value) {
|
|
73
|
+
const bytes = Buffer.alloc(8);
|
|
74
|
+
bytes.writeBigUInt64BE(BigInt(value));
|
|
75
|
+
return bytes;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Version 2: a domain and version tag, the file count, then per file in byte order of the path
|
|
79
|
+
* the length-prefixed UTF-8 path and the length-prefixed content. Every field has an explicit
|
|
80
|
+
* length, so two different trees never share an encoding.
|
|
81
|
+
*/
|
|
82
|
+
export function skillTreeFingerprint(files) {
|
|
83
|
+
const entries = files.map((file) => ({ path: Buffer.from(file.path, "utf8"), bytes: file.bytes }))
|
|
84
|
+
.sort((left, right) => Buffer.compare(left.path, right.path));
|
|
85
|
+
const hash = createHash("sha256").update(FINGERPRINT_DOMAIN, "utf8").update(uint32(entries.length));
|
|
86
|
+
entries.forEach((entry, index) => {
|
|
87
|
+
if (index > 0 && Buffer.compare(entries[index - 1].path, entry.path) === 0)
|
|
88
|
+
throw new SkillTreeError("skill_tree_invalid");
|
|
89
|
+
hash.update(uint32(entry.path.length)).update(entry.path).update(uint64(entry.bytes.length)).update(entry.bytes);
|
|
90
|
+
});
|
|
91
|
+
return `v2:${hash.digest("hex")}`;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Version 1, as 0.8.0 computed it: `path NUL content NUL` per file in reading order. File
|
|
95
|
+
* content may contain NUL bytes, so two different trees can share this value.
|
|
96
|
+
*/
|
|
97
|
+
export function legacySkillTreeFingerprint(files) {
|
|
98
|
+
const hash = createHash("sha256");
|
|
99
|
+
for (const file of files)
|
|
100
|
+
hash.update(file.path).update("\0").update(file.bytes).update("\0");
|
|
101
|
+
return hash.digest("hex");
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* The shipped skills of every release that recorded version 1 fingerprints (0.8.0 only), as
|
|
105
|
+
* trusted references for recognizing such records. Computed from the `v0.8.0` tag and checked
|
|
106
|
+
* against the published npm package.
|
|
107
|
+
*/
|
|
108
|
+
export const RELEASED_SKILL_TREES = Object.freeze([
|
|
109
|
+
Object.freeze({ name: "pi-session-orchestration", release: "0.8.0", legacyFingerprint: "e2a72b558ea14748a4badd0058ff65b8731cc125033a666706b103842784673a", fingerprint: "v2:39edc4c4783dec7f90345b22844a17e5e48b465722bb0f1b20059d6ee51e1523" }),
|
|
110
|
+
Object.freeze({ name: "pi-session-implementation", release: "0.8.0", legacyFingerprint: "5f719b44da266d3f66dd5bf3c18f3e3e04ca7df357e2fe2eedbf0409e6c1bf64", fingerprint: "v2:f8ce75eff83a5c952f6ca51c04e1e8025cb7c2c558c30d692f291a3f6594eb12" }),
|
|
111
|
+
Object.freeze({ name: "pi-session-review", release: "0.8.0", legacyFingerprint: "8cf58ccb8d473a3eedf12ce6b902b1e91dd9e2fef83ad110c4d8e2e6164cb257", fingerprint: "v2:610c0b4829eeb1ce6c9b2000ad73f8e88ca230707eef7f7b1708a80767952bf0" }),
|
|
112
|
+
]);
|
|
113
|
+
/**
|
|
114
|
+
* Whether a version 1 fingerprint from a record proves that `candidate` is the tree it names:
|
|
115
|
+
* a trusted reference, a released shipped tree of this name or one of `references` (such as
|
|
116
|
+
* the current library tree), has exactly this version 1 fingerprint and is byte-identical to
|
|
117
|
+
* `candidate` by its version 2 fingerprint. The version 1 fingerprint alone never suffices.
|
|
118
|
+
*/
|
|
119
|
+
export function provesLegacyFingerprint(name, legacyFingerprint, candidate, references = [], released = RELEASED_SKILL_TREES) {
|
|
120
|
+
if (!LEGACY_SKILL_TREE_FINGERPRINT_PATTERN.test(legacyFingerprint) || !SKILL_TREE_FINGERPRINT_PATTERN.test(candidate.fingerprint))
|
|
121
|
+
return false;
|
|
122
|
+
const trusted = [...released.filter((tree) => tree.name === name), ...references];
|
|
123
|
+
return trusted.some((tree) => tree.legacyFingerprint === legacyFingerprint && tree.fingerprint === candidate.fingerprint);
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* The version 2 fingerprint of the released shipped skill that a version 1 fingerprint names,
|
|
127
|
+
* or undefined. Only released trees qualify: the current shipped tree could share a version 1
|
|
128
|
+
* fingerprint with an older original without being that original.
|
|
129
|
+
*/
|
|
130
|
+
export function releasedSkillFingerprint(name, legacyFingerprint, released = RELEASED_SKILL_TREES) {
|
|
131
|
+
return released.find((tree) => tree.name === name && tree.legacyFingerprint === legacyFingerprint)?.fingerprint;
|
|
132
|
+
}
|
|
62
133
|
/**
|
|
63
134
|
* Reads the regular files below a skill directory in a stable order. Entries whose name starts
|
|
64
135
|
* with `.` are skipped or rejected (see options). A symbolic link or special file inside the
|
|
@@ -113,8 +184,5 @@ export async function readSkillTree(directory, options = {}) {
|
|
|
113
184
|
await visit(root, "", 0);
|
|
114
185
|
if (!files.some((file) => file.path === "SKILL.md"))
|
|
115
186
|
throw new SkillTreeError("skill_tree_missing");
|
|
116
|
-
|
|
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") });
|
|
187
|
+
return Object.freeze({ files: Object.freeze(files), fingerprint: skillTreeFingerprint(files), legacyFingerprint: legacySkillTreeFingerprint(files) });
|
|
120
188
|
}
|