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/setup-skills.js
CHANGED
|
@@ -1,16 +1,33 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
-
import { lstat, mkdir, open, realpath, rename, rm } from "node:fs/promises";
|
|
3
|
-
import { basename, dirname, join } from "node:path";
|
|
4
|
-
import { CONTROLLER_SKILLS, readSkillTree, resolveLibrarySkill, skillOverrideDirectory } from "./skill-library.js";
|
|
5
|
-
|
|
2
|
+
import { lstat, mkdir, open, readdir, readFile, realpath, rename, rm, rmdir } from "node:fs/promises";
|
|
3
|
+
import { basename, dirname, join, posix } from "node:path";
|
|
4
|
+
import { CONTROLLER_SKILLS, LEGACY_SKILL_TREE_FINGERPRINT_PATTERN, MAX_SKILL_TREE_BYTES, MAX_SKILL_TREE_FILES, provesLegacyFingerprint, RELEASED_SKILL_TREES, readSkillTree, resolveLibrarySkill, SKILL_TREE_FINGERPRINT_PATTERN, skillOverrideDirectory, } from "./skill-library.js";
|
|
5
|
+
const TRANSACTION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
6
|
+
const MAX_TREE_DEPTH = 8;
|
|
7
|
+
const MAX_TREE_ENTRIES = 256;
|
|
8
|
+
/** Whether a version 2 fingerprint is setup's own copy: the owned one, or the one a pending update replaces. */
|
|
6
9
|
function ownsFingerprint(record, fingerprint) {
|
|
7
10
|
return fingerprint === record.fingerprint || (record.phase === "pending" && record.replaces === fingerprint);
|
|
8
11
|
}
|
|
12
|
+
const isLegacy = (value) => value !== undefined && LEGACY_SKILL_TREE_FINGERPRINT_PATTERN.test(value);
|
|
13
|
+
const hasLegacy = (record) => isLegacy(record.fingerprint) || isLegacy(record.replaces);
|
|
14
|
+
/**
|
|
15
|
+
* Every intermediate directory of an update is named after the pending record's transaction id
|
|
16
|
+
* in the slot's parent, so recovery derives them from persisted state and never searches.
|
|
17
|
+
*/
|
|
18
|
+
function intermediates(path, name, transactionId) {
|
|
19
|
+
if (!TRANSACTION_ID.test(transactionId))
|
|
20
|
+
throw new Error("skill_transaction_invalid");
|
|
21
|
+
const base = join(dirname(path), `.pi-session-mcp-${name}-${transactionId}`);
|
|
22
|
+
return { staging: `${base}.staging`, backup: `${base}.backup`, trash: `${base}.trash` };
|
|
23
|
+
}
|
|
9
24
|
/** Paths remain internal. Ownership identifies a client slot, never its location. */
|
|
10
25
|
export class FileSetupSkillInstaller {
|
|
11
26
|
roots;
|
|
12
|
-
|
|
27
|
+
dependencies;
|
|
28
|
+
constructor(roots, dependencies = {}) {
|
|
13
29
|
this.roots = roots;
|
|
30
|
+
this.dependencies = dependencies;
|
|
14
31
|
}
|
|
15
32
|
async run(operation, target, ownership) {
|
|
16
33
|
const findings = [];
|
|
@@ -29,6 +46,7 @@ export class FileSetupSkillInstaller {
|
|
|
29
46
|
return true;
|
|
30
47
|
return false;
|
|
31
48
|
}
|
|
49
|
+
async step(step) { await this.dependencies.checkpoint?.(step); }
|
|
32
50
|
/**
|
|
33
51
|
* The slot path and its ownership key. A project may be untrusted: only its root is
|
|
34
52
|
* canonicalized, and a link below it (for example `.claude` pointing elsewhere) makes the
|
|
@@ -50,6 +68,16 @@ export class FileSetupSkillInstaller {
|
|
|
50
68
|
library(name) {
|
|
51
69
|
return resolveLibrarySkill(name, { overrideDirectory: skillOverrideDirectory(this.roots.configPath), packageRoot: this.roots.packageRoot });
|
|
52
70
|
}
|
|
71
|
+
/**
|
|
72
|
+
* Whether the record names this installed tree: its version 2 value, or a 0.8.0 value that a
|
|
73
|
+
* trusted reference proves for exactly these bytes. A bare legacy value never proves identity.
|
|
74
|
+
*/
|
|
75
|
+
ownsTree(name, record, slot, references) {
|
|
76
|
+
if (ownsFingerprint(record, slot.fingerprint))
|
|
77
|
+
return true;
|
|
78
|
+
const named = record.phase === "pending" ? [record.fingerprint, record.replaces] : [record.fingerprint];
|
|
79
|
+
return named.some((value) => isLegacy(value) && provesLegacyFingerprint(name, value, slot, references, this.dependencies.released ?? RELEASED_SKILL_TREES));
|
|
80
|
+
}
|
|
53
81
|
async skill(operation, target, name, ownership) {
|
|
54
82
|
const finding = (code, status) => ({ target: { ...target, alias: name }, code, status });
|
|
55
83
|
let location;
|
|
@@ -63,9 +91,10 @@ export class FileSetupSkillInstaller {
|
|
|
63
91
|
}
|
|
64
92
|
const { path, owned: ownedTarget } = location;
|
|
65
93
|
const library = this.library(name);
|
|
66
|
-
// Only a usable library skill can stand behind an accepted link; otherwise verify and apply would disagree.
|
|
67
|
-
const
|
|
68
|
-
const linkTarget =
|
|
94
|
+
// Only a usable library skill can stand behind an accepted link or a legacy proof; otherwise verify and apply would disagree.
|
|
95
|
+
const desired = library ? await readSkillTree(library.directory).catch(() => undefined) : undefined;
|
|
96
|
+
const linkTarget = desired ? await realpath(library.directory).catch(() => undefined) : undefined;
|
|
97
|
+
const references = desired ? [desired] : [];
|
|
69
98
|
if (operation === "remove" || operation === "rollback") {
|
|
70
99
|
try {
|
|
71
100
|
const owned = await ownership.get(ownedTarget);
|
|
@@ -76,6 +105,9 @@ export class FileSetupSkillInstaller {
|
|
|
76
105
|
if (other?.phase === "owned")
|
|
77
106
|
return finding("skill_kept_shared", "unchanged");
|
|
78
107
|
}
|
|
108
|
+
if (owned.phase === "pending" && SKILL_TREE_FINGERPRINT_PATTERN.test(owned.fingerprint)
|
|
109
|
+
&& await this.recover(operation, path, name, owned, desired, linkTarget, ownership) === "retained")
|
|
110
|
+
return finding("skill_recovery_retained", "divergent");
|
|
79
111
|
const current = await inspectSlot(path, linkTarget);
|
|
80
112
|
if (current.state === "absent") {
|
|
81
113
|
healthy(ownership);
|
|
@@ -90,8 +122,10 @@ export class FileSetupSkillInstaller {
|
|
|
90
122
|
healthy(ownership);
|
|
91
123
|
return finding("skill_not_owned", "unchanged");
|
|
92
124
|
}
|
|
93
|
-
if (current.state !== "tree"
|
|
125
|
+
if (current.state !== "tree")
|
|
94
126
|
return finding("skill_divergent", "divergent");
|
|
127
|
+
if (!this.ownsTree(name, owned, current, references))
|
|
128
|
+
return finding(hasLegacy(owned) ? "skill_legacy_unverified" : "skill_divergent", "divergent");
|
|
95
129
|
healthy(ownership);
|
|
96
130
|
if (!await sameSlot(path, current, linkTarget))
|
|
97
131
|
return finding("skill_divergent", "divergent");
|
|
@@ -114,29 +148,23 @@ export class FileSetupSkillInstaller {
|
|
|
114
148
|
return finding("skill_missing", "failed");
|
|
115
149
|
if (current.state === "linked")
|
|
116
150
|
return finding("skill_verified", "ok");
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
throw new Error("skill_source_invalid");
|
|
120
|
-
const desired = await readSkillTree(library.directory);
|
|
121
|
-
return current.state === "tree" && current.fingerprint === desired.fingerprint
|
|
122
|
-
? finding("skill_verified", "ok") : finding("skill_not_equivalent", "failed");
|
|
123
|
-
}
|
|
124
|
-
catch {
|
|
125
|
-
return finding("skill_not_equivalent", "failed");
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
let desired;
|
|
129
|
-
try {
|
|
130
|
-
if (!library)
|
|
131
|
-
throw new Error("skill_source_invalid");
|
|
132
|
-
desired = await readSkillTree(library.directory);
|
|
151
|
+
return desired && current.state === "tree" && current.fingerprint === desired.fingerprint
|
|
152
|
+
? finding("skill_verified", "ok") : finding("skill_not_equivalent", "failed");
|
|
133
153
|
}
|
|
134
|
-
|
|
154
|
+
if (!desired)
|
|
135
155
|
return finding("skill_source_invalid", "failed");
|
|
136
|
-
}
|
|
137
156
|
try {
|
|
157
|
+
let owned = await ownership.get(ownedTarget);
|
|
158
|
+
let recovered = false;
|
|
159
|
+
if (operation === "apply" && owned?.phase === "pending" && SKILL_TREE_FINGERPRINT_PATTERN.test(owned.fingerprint)) {
|
|
160
|
+
const outcome = await this.recover("apply", path, name, owned, desired, linkTarget, ownership);
|
|
161
|
+
if (outcome === "retained")
|
|
162
|
+
return finding("skill_recovery_retained", "divergent");
|
|
163
|
+
recovered = outcome === "recovered";
|
|
164
|
+
}
|
|
165
|
+
// A dry-run never recovers; it announces the settlement apply would perform for a version 2 transaction.
|
|
166
|
+
const recoveryPlanned = operation === "dry-run" && owned?.phase === "pending" && SKILL_TREE_FINGERPRINT_PATTERN.test(owned.fingerprint) && !hasLegacy(owned);
|
|
138
167
|
const current = await inspectSlot(path, linkTarget);
|
|
139
|
-
const owned = await ownership.get(ownedTarget);
|
|
140
168
|
if (current.state === "linked") {
|
|
141
169
|
if (operation === "apply" && owned) {
|
|
142
170
|
healthy(ownership);
|
|
@@ -147,6 +175,17 @@ export class FileSetupSkillInstaller {
|
|
|
147
175
|
}
|
|
148
176
|
if (current.state === "divergent")
|
|
149
177
|
return finding("skill_divergent", "divergent");
|
|
178
|
+
if (owned && current.state === "tree" && hasLegacy(owned)) {
|
|
179
|
+
// A record from 0.8.0: it names the slot only if a trusted reference proves these exact bytes.
|
|
180
|
+
if (!this.ownsTree(name, owned, current, references))
|
|
181
|
+
return finding("skill_legacy_unverified", "divergent");
|
|
182
|
+
owned = { target: ownedTarget, fingerprint: current.fingerprint, previous: owned.previous, phase: "owned", transactionId: owned.transactionId };
|
|
183
|
+
if (operation === "apply") {
|
|
184
|
+
healthy(ownership);
|
|
185
|
+
await ownership.put(owned);
|
|
186
|
+
healthy(ownership);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
150
189
|
// A copy setup did not write, or its own copy modified since, is never replaced.
|
|
151
190
|
if (current.state === "tree" && (!owned || !ownsFingerprint(owned, current.fingerprint))) {
|
|
152
191
|
if (!owned && current.fingerprint === desired.fingerprint)
|
|
@@ -154,17 +193,26 @@ export class FileSetupSkillInstaller {
|
|
|
154
193
|
return finding("skill_divergent", "divergent");
|
|
155
194
|
}
|
|
156
195
|
if (current.state === "tree" && current.fingerprint === desired.fingerprint) {
|
|
196
|
+
if (recoveryPlanned)
|
|
197
|
+
return finding("skill_recovery_planned", "planned");
|
|
157
198
|
if (operation === "apply" && owned?.phase === "pending") {
|
|
158
199
|
healthy(ownership);
|
|
159
200
|
await ownership.put(ownedRecord(owned, desired.fingerprint));
|
|
160
201
|
healthy(ownership);
|
|
202
|
+
return finding("skill_recovered", "ok");
|
|
161
203
|
}
|
|
204
|
+
if (operation === "apply" && recovered)
|
|
205
|
+
return finding("skill_recovered", "ok");
|
|
162
206
|
return finding(operation === "apply" ? "skill_already_equivalent" : "skill_equivalent", "unchanged");
|
|
163
207
|
}
|
|
164
|
-
if (operation === "dry-run")
|
|
208
|
+
if (operation === "dry-run") {
|
|
209
|
+
if (recoveryPlanned)
|
|
210
|
+
return finding("skill_recovery_planned", "planned");
|
|
165
211
|
return current.state === "absent" ? finding("skill_absent", "absent") : finding("skill_update_planned", "planned");
|
|
166
|
-
|
|
167
|
-
//
|
|
212
|
+
}
|
|
213
|
+
// The pending record is persisted before any intermediate exists. It names the intermediates
|
|
214
|
+
// (transaction id), the target and the replaced copy, so an interrupted update can be
|
|
215
|
+
// recovered, finished or removed from persisted state alone.
|
|
168
216
|
const pending = {
|
|
169
217
|
target: ownedTarget, fingerprint: desired.fingerprint, previous: "absent", phase: "pending", transactionId: randomUUID(),
|
|
170
218
|
...(current.state === "tree" ? { replaces: current.fingerprint } : {}),
|
|
@@ -172,11 +220,12 @@ export class FileSetupSkillInstaller {
|
|
|
172
220
|
healthy(ownership);
|
|
173
221
|
await ownership.put(pending);
|
|
174
222
|
healthy(ownership);
|
|
175
|
-
await this.replace(path, name, desired, current, ownership);
|
|
223
|
+
await this.replace(path, name, desired, current, pending, ownership);
|
|
176
224
|
healthy(ownership);
|
|
177
225
|
const after = await inspectSlot(path);
|
|
178
226
|
if (after.state !== "tree" || after.fingerprint !== desired.fingerprint)
|
|
179
227
|
throw new Error("skill_post_install_mismatch");
|
|
228
|
+
await this.step("before-ownership");
|
|
180
229
|
healthy(ownership);
|
|
181
230
|
await ownership.put(ownedRecord(pending, desired.fingerprint));
|
|
182
231
|
healthy(ownership);
|
|
@@ -186,68 +235,149 @@ export class FileSetupSkillInstaller {
|
|
|
186
235
|
return finding(operation === "apply" ? "skill_install_failed" : "skill_source_invalid", "failed");
|
|
187
236
|
}
|
|
188
237
|
}
|
|
189
|
-
|
|
238
|
+
/**
|
|
239
|
+
* Installs the new tree through the transaction's own intermediates. A failure leaves them for
|
|
240
|
+
* recovery; nothing here deletes by guess.
|
|
241
|
+
*/
|
|
242
|
+
async replace(path, name, desired, current, record, ownership) {
|
|
190
243
|
const parent = dirname(path);
|
|
244
|
+
const names = intermediates(path, name, record.transactionId);
|
|
191
245
|
healthy(ownership);
|
|
192
246
|
await ensureParents(parent, ownership);
|
|
193
247
|
healthy(ownership);
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
try {
|
|
198
|
-
healthy(ownership);
|
|
199
|
-
await mkdir(temporary, { mode: 0o755 });
|
|
200
|
-
staged = true;
|
|
201
|
-
for (const file of desired.files) {
|
|
202
|
-
healthy(ownership);
|
|
203
|
-
const destination = join(temporary, ...file.path.split("/"));
|
|
204
|
-
await ensureParents(dirname(destination), ownership);
|
|
205
|
-
healthy(ownership);
|
|
206
|
-
const handle = await open(destination, "wx", 0o644);
|
|
207
|
-
try {
|
|
208
|
-
await handle.writeFile(file.bytes);
|
|
209
|
-
await handle.sync();
|
|
210
|
-
}
|
|
211
|
-
finally {
|
|
212
|
-
await handle.close();
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
await syncTreeDirectories(temporary, desired, ownership);
|
|
248
|
+
healthy(ownership);
|
|
249
|
+
await mkdir(names.staging, { mode: 0o755 });
|
|
250
|
+
for (const file of desired.files) {
|
|
216
251
|
healthy(ownership);
|
|
217
|
-
|
|
218
|
-
|
|
252
|
+
const destination = join(names.staging, ...file.path.split("/"));
|
|
253
|
+
await ensureParents(dirname(destination), ownership);
|
|
219
254
|
healthy(ownership);
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
await
|
|
224
|
-
|
|
255
|
+
const handle = await open(destination, "wx", 0o644);
|
|
256
|
+
try {
|
|
257
|
+
await handle.writeFile(file.bytes);
|
|
258
|
+
await handle.sync();
|
|
259
|
+
}
|
|
260
|
+
finally {
|
|
261
|
+
await handle.close();
|
|
225
262
|
}
|
|
226
|
-
|
|
227
|
-
|
|
263
|
+
}
|
|
264
|
+
await syncTreeDirectories(names.staging, desired, ownership);
|
|
265
|
+
await this.step("staged");
|
|
266
|
+
healthy(ownership);
|
|
267
|
+
if (!await sameSlot(path, current))
|
|
268
|
+
throw new Error("skill_slot_changed");
|
|
269
|
+
healthy(ownership);
|
|
270
|
+
if (current.state === "tree") {
|
|
271
|
+
// A rename onto an existing empty directory would replace it silently.
|
|
272
|
+
if (await present(names.backup))
|
|
273
|
+
throw new Error("skill_backup_exists");
|
|
274
|
+
await rename(path, names.backup);
|
|
228
275
|
healthy(ownership);
|
|
229
276
|
await syncDirectory(parent);
|
|
230
277
|
healthy(ownership);
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
278
|
+
await this.step("backup-renamed");
|
|
279
|
+
}
|
|
280
|
+
await rename(names.staging, path);
|
|
281
|
+
healthy(ownership);
|
|
282
|
+
await syncDirectory(parent);
|
|
283
|
+
healthy(ownership);
|
|
284
|
+
await this.step("installed");
|
|
285
|
+
const after = await inspectSlot(path);
|
|
286
|
+
if (after.state !== "tree" || after.fingerprint !== desired.fingerprint)
|
|
287
|
+
throw new Error("skill_post_install_mismatch");
|
|
288
|
+
if (current.state === "tree")
|
|
289
|
+
await this.discardBackup(names, current, parent, ownership);
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Deletes the backup only after it is verified to be the exact old copy: it is renamed to the
|
|
293
|
+
* transaction's trash name first, so the deletion never leaves an unverifiable partial tree
|
|
294
|
+
* under the backup name.
|
|
295
|
+
*/
|
|
296
|
+
async discardBackup(names, backup, parent, ownership) {
|
|
297
|
+
if (await present(names.trash))
|
|
298
|
+
throw new Error("skill_trash_exists");
|
|
299
|
+
if (!await sameSlot(names.backup, backup))
|
|
300
|
+
throw new Error("skill_backup_changed");
|
|
301
|
+
healthy(ownership);
|
|
302
|
+
await rename(names.backup, names.trash);
|
|
303
|
+
healthy(ownership);
|
|
304
|
+
await syncDirectory(parent);
|
|
305
|
+
healthy(ownership);
|
|
306
|
+
await this.step("trash-renamed");
|
|
307
|
+
await this.discardTrash(names, parent, ownership);
|
|
308
|
+
await this.step("trash-deleted");
|
|
309
|
+
}
|
|
310
|
+
/** Deletes the transaction's trash entry by entry, so a crash leaves only a smaller partial tree. */
|
|
311
|
+
async discardTrash(names, parent, ownership) {
|
|
312
|
+
await removeEntries(names.trash, parent, ownership, () => this.step("trash-entry-removed"));
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* Settles an interrupted update of the pending record's transaction, inside the ownership
|
|
316
|
+
* transaction. Only directories with this transaction's exact names are examined, and only
|
|
317
|
+
* verified ones are deleted: the backup must equal the replaced copy, the staging directory
|
|
318
|
+
* the target or an interrupted write of it. Anything else stays and is reported.
|
|
319
|
+
*
|
|
320
|
+
* apply, slot absent: a complete staging directory is renamed into the slot (forward);
|
|
321
|
+
* otherwise a verified backup is moved back. remove and rollback do not restore, they delete
|
|
322
|
+
* what is verified and leave the slot to the caller.
|
|
323
|
+
*/
|
|
324
|
+
async recover(operation, path, name, record, library, linkTarget, ownership) {
|
|
325
|
+
const parent = dirname(path);
|
|
326
|
+
const names = intermediates(path, name, record.transactionId);
|
|
327
|
+
const target = record.fingerprint;
|
|
328
|
+
const previous = record.replaces !== undefined && SKILL_TREE_FINGERPRINT_PATTERN.test(record.replaces) ? record.replaces : undefined;
|
|
329
|
+
const slot = await inspectSlot(path, linkTarget);
|
|
330
|
+
// Only an empty slot, a link or one of setup's two copies can have intermediates of this transaction settled.
|
|
331
|
+
if (slot.state === "divergent" || (slot.state === "tree" && slot.fingerprint !== target && slot.fingerprint !== previous))
|
|
332
|
+
return "untouched";
|
|
333
|
+
// A trash directory with the transaction's name was verified before it was renamed, so on
|
|
334
|
+
// recovery it may be partial. It is deleted after a structural recheck: a real directory,
|
|
335
|
+
// not a link, only regular files and directories, no hidden entries, within the tree limits.
|
|
336
|
+
let changed = false;
|
|
337
|
+
if (await present(names.trash)) {
|
|
338
|
+
if (await collectPlain(names.trash) === undefined)
|
|
339
|
+
return "retained";
|
|
340
|
+
await this.discardTrash(names, parent, ownership);
|
|
341
|
+
changed = true;
|
|
342
|
+
}
|
|
343
|
+
let backup = await inspectSlot(names.backup);
|
|
344
|
+
if (backup.state === "divergent" || (backup.state === "tree" && backup.fingerprint !== previous))
|
|
345
|
+
return "retained";
|
|
346
|
+
let staging = await classifyStaging(names.staging, target, library);
|
|
347
|
+
if (staging === "foreign")
|
|
348
|
+
return "retained";
|
|
349
|
+
if (operation === "apply" && slot.state === "absent") {
|
|
350
|
+
if (staging === "complete" || backup.state === "tree") {
|
|
351
|
+
const source = staging === "complete" ? names.staging : names.backup;
|
|
352
|
+
const expected = staging === "complete" ? target : previous;
|
|
353
|
+
healthy(ownership);
|
|
354
|
+
if (!await sameSlot(path, slot))
|
|
355
|
+
throw new Error("skill_slot_changed");
|
|
238
356
|
healthy(ownership);
|
|
239
|
-
await
|
|
357
|
+
await rename(source, path);
|
|
240
358
|
healthy(ownership);
|
|
241
359
|
await syncDirectory(parent);
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
finally {
|
|
245
|
-
// An unhealthy fence retains staged/backup content for conservative recovery.
|
|
246
|
-
if (staged) {
|
|
247
360
|
healthy(ownership);
|
|
248
|
-
|
|
361
|
+
const restored = await inspectSlot(path);
|
|
362
|
+
if (restored.state !== "tree" || restored.fingerprint !== expected)
|
|
363
|
+
throw new Error("skill_recovery_mismatch");
|
|
364
|
+
changed = true;
|
|
365
|
+
if (staging === "complete")
|
|
366
|
+
staging = "absent";
|
|
367
|
+
else
|
|
368
|
+
backup = { state: "absent" };
|
|
249
369
|
}
|
|
250
370
|
}
|
|
371
|
+
if (backup.state === "tree") {
|
|
372
|
+
await this.discardBackup(names, backup, parent, ownership);
|
|
373
|
+
changed = true;
|
|
374
|
+
}
|
|
375
|
+
if (staging !== "absent") {
|
|
376
|
+
healthy(ownership);
|
|
377
|
+
await removeEntries(names.staging, parent, ownership);
|
|
378
|
+
changed = true;
|
|
379
|
+
}
|
|
380
|
+
return changed ? "recovered" : "settled";
|
|
251
381
|
}
|
|
252
382
|
}
|
|
253
383
|
function healthy(ownership) { ownership.assertHealthy?.(); }
|
|
@@ -310,6 +440,109 @@ async function sameSlot(path, expected, linkTarget) {
|
|
|
310
440
|
return expected.state === "absent" ? actual.state === "absent" :
|
|
311
441
|
expected.state === "tree" && actual.state === "tree" && actual.fingerprint === expected.fingerprint && actual.dev === expected.dev && actual.ino === expected.ino;
|
|
312
442
|
}
|
|
443
|
+
async function present(path) {
|
|
444
|
+
try {
|
|
445
|
+
await lstat(path);
|
|
446
|
+
return true;
|
|
447
|
+
}
|
|
448
|
+
catch (error) {
|
|
449
|
+
if (error.code === "ENOENT")
|
|
450
|
+
return false;
|
|
451
|
+
throw error;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* The regular files and directories (relative, `/` separated) below a directory, or undefined
|
|
456
|
+
* unless it is a real directory with only regular files and directories, no hidden entry, and
|
|
457
|
+
* within the tree limits. A link or special file is never followed or counted as plain.
|
|
458
|
+
*/
|
|
459
|
+
async function collectPlain(root) {
|
|
460
|
+
const files = [];
|
|
461
|
+
const directories = [];
|
|
462
|
+
let bytes = 0;
|
|
463
|
+
let entries = 0;
|
|
464
|
+
const visit = async (current, prefix, depth) => {
|
|
465
|
+
if (depth > MAX_TREE_DEPTH)
|
|
466
|
+
return false;
|
|
467
|
+
for (const entry of await readdir(current)) {
|
|
468
|
+
if (entry.startsWith(".") || ++entries > MAX_TREE_ENTRIES)
|
|
469
|
+
return false;
|
|
470
|
+
const path = join(current, entry);
|
|
471
|
+
const relative = prefix === "" ? entry : `${prefix}/${entry}`;
|
|
472
|
+
const info = await lstat(path);
|
|
473
|
+
if (info.isDirectory()) {
|
|
474
|
+
directories.push(relative);
|
|
475
|
+
if (!await visit(path, relative, depth + 1))
|
|
476
|
+
return false;
|
|
477
|
+
continue;
|
|
478
|
+
}
|
|
479
|
+
if (!info.isFile())
|
|
480
|
+
return false;
|
|
481
|
+
bytes += info.size;
|
|
482
|
+
if (files.length + 1 > MAX_SKILL_TREE_FILES || bytes > MAX_SKILL_TREE_BYTES)
|
|
483
|
+
return false;
|
|
484
|
+
files.push(relative);
|
|
485
|
+
}
|
|
486
|
+
return true;
|
|
487
|
+
};
|
|
488
|
+
try {
|
|
489
|
+
const info = await lstat(root);
|
|
490
|
+
return info.isDirectory() && await visit(root, "", 0) ? { files, directories } : undefined;
|
|
491
|
+
}
|
|
492
|
+
catch {
|
|
493
|
+
return undefined;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
/**
|
|
497
|
+
* How a staging directory of the pending record relates to its target: `complete` (its tree
|
|
498
|
+
* equals the target fingerprint), `partial` (an interrupted write: every entry is a regular file
|
|
499
|
+
* of the target tree whose content equals that file or a prefix of it, or a directory on the path
|
|
500
|
+
* of one, checked only against the current library tree when it is the record's target),
|
|
501
|
+
* `absent`, or `foreign`, which is never deleted.
|
|
502
|
+
*/
|
|
503
|
+
async function classifyStaging(staging, target, library) {
|
|
504
|
+
const staged = await inspectSlot(staging);
|
|
505
|
+
if (staged.state === "absent")
|
|
506
|
+
return "absent";
|
|
507
|
+
if (staged.state === "tree" && staged.fingerprint === target)
|
|
508
|
+
return "complete";
|
|
509
|
+
if (!library || library.fingerprint !== target)
|
|
510
|
+
return "foreign";
|
|
511
|
+
const plain = await collectPlain(staging);
|
|
512
|
+
if (!plain)
|
|
513
|
+
return "foreign";
|
|
514
|
+
const expected = new Map(library.files.map((file) => [file.path, file.bytes]));
|
|
515
|
+
const ancestors = new Set();
|
|
516
|
+
for (const file of library.files)
|
|
517
|
+
for (let cursor = posix.dirname(file.path); cursor !== "."; cursor = posix.dirname(cursor))
|
|
518
|
+
ancestors.add(cursor);
|
|
519
|
+
if (!plain.directories.every((directory) => ancestors.has(directory)))
|
|
520
|
+
return "foreign";
|
|
521
|
+
for (const file of plain.files) {
|
|
522
|
+
const bytes = expected.get(file);
|
|
523
|
+
if (!bytes)
|
|
524
|
+
return "foreign";
|
|
525
|
+
const content = await readFile(join(staging, ...file.split("/")));
|
|
526
|
+
if (content.length > bytes.length || !bytes.subarray(0, content.length).equals(content))
|
|
527
|
+
return "foreign";
|
|
528
|
+
}
|
|
529
|
+
return "partial";
|
|
530
|
+
}
|
|
531
|
+
/** Removes a verified directory entry by entry and then itself, with directory fsyncs, so a crash leaves a smaller partial tree. */
|
|
532
|
+
async function removeEntries(directory, parent, ownership, afterEntry) {
|
|
533
|
+
for (const entry of await readdir(directory)) {
|
|
534
|
+
healthy(ownership);
|
|
535
|
+
await rm(join(directory, entry), { recursive: true });
|
|
536
|
+
healthy(ownership);
|
|
537
|
+
await syncDirectory(directory);
|
|
538
|
+
await afterEntry?.();
|
|
539
|
+
}
|
|
540
|
+
healthy(ownership);
|
|
541
|
+
await rmdir(directory);
|
|
542
|
+
healthy(ownership);
|
|
543
|
+
await syncDirectory(parent);
|
|
544
|
+
healthy(ownership);
|
|
545
|
+
}
|
|
313
546
|
async function ensureParents(directory, ownership) {
|
|
314
547
|
if (dirname(directory) !== directory)
|
|
315
548
|
await ensureParents(dirname(directory), ownership);
|
|
@@ -1,15 +1,22 @@
|
|
|
1
|
+
import { type OverrideLockOptions } from "./override-lock.js";
|
|
1
2
|
import { type SetupResult } from "./setup-result.js";
|
|
3
|
+
import { type ReleasedSkillTree } from "./skill-library.js";
|
|
2
4
|
/**
|
|
3
5
|
* `--customize-skill <name>`: copies a shipped skill into the operator's override directory as a
|
|
4
6
|
* starting point, and records the shipped fingerprint it was based on. The operator's files are
|
|
5
7
|
* never overwritten or edited; a changed original is only reported and acknowledged by moving
|
|
6
8
|
* the recorded base. Reports carry the skill name and a code, never a path or file content.
|
|
9
|
+
*
|
|
10
|
+
* Applying is a cross-process transaction: everything from reading the bases file to replacing it
|
|
11
|
+
* runs under the kernel lock of `override-lock.ts`, so two runs for different skills cannot lose
|
|
12
|
+
* each other's base. A dry run takes no lock and writes nothing.
|
|
7
13
|
*/
|
|
8
14
|
export declare const SKILL_BASES_FILE = ".pi-session-mcp-bases.json";
|
|
9
15
|
export declare const MAX_SKILL_BASES_BYTES: number;
|
|
10
16
|
export declare class SkillBasesError extends Error {
|
|
11
17
|
constructor();
|
|
12
18
|
}
|
|
19
|
+
/** Per skill, a version 2 fingerprint or a legacy (version 1) one that could not be migrated. */
|
|
13
20
|
export type SkillBases = Readonly<Record<string, string>>;
|
|
14
21
|
/** Strictly validated contents of the bases file; an absent file or override directory is an empty record. */
|
|
15
22
|
export declare function readSkillBases(overrideDirectory: string): Promise<SkillBases>;
|
|
@@ -20,4 +27,15 @@ export interface SkillCustomizeRequest {
|
|
|
20
27
|
/** Package root that holds the shipped `skills/` directory; defaults to this package. */
|
|
21
28
|
readonly packageRoot?: string;
|
|
22
29
|
}
|
|
23
|
-
|
|
30
|
+
/** Injection points for tests; production callers pass none. */
|
|
31
|
+
export interface SkillCustomizeDependencies {
|
|
32
|
+
/** Reference trees that resolve legacy bases; defaults to the released shipped skills. */
|
|
33
|
+
readonly released?: readonly ReleasedSkillTree[];
|
|
34
|
+
/** Lock acquisition bound and platform seams. */
|
|
35
|
+
readonly lock?: OverrideLockOptions;
|
|
36
|
+
/** Runs while the lock is held, before the bases file is read. */
|
|
37
|
+
readonly afterLock?: () => Promise<void> | void;
|
|
38
|
+
/** Runs while the lock is held, after the new bases content is durable and before it replaces the old file. */
|
|
39
|
+
readonly beforeBasesRename?: () => Promise<void> | void;
|
|
40
|
+
}
|
|
41
|
+
export declare function customizeSkill(request: SkillCustomizeRequest, dependencies?: SkillCustomizeDependencies): Promise<SetupResult>;
|