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.
@@ -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
- /** Whether a fingerprint is setup's own copy: the owned one, or the one a pending update replaces. */
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
- constructor(roots) {
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 usable = library ? await readSkillTree(library.directory).then(() => true, () => false) : false;
68
- const linkTarget = usable ? await realpath(library.directory).catch(() => undefined) : undefined;
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" || !ownsFingerprint(owned, current.fingerprint))
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
- try {
118
- if (!library)
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
- catch {
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
- // The pending record keeps the replaced fingerprint, so an interrupted update leaves a copy
167
- // that setup can still finish or remove.
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
- async replace(path, name, desired, current, ownership) {
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
- const temporary = join(parent, `.pi-session-mcp-${name}-${randomUUID()}`);
195
- const backup = join(parent, `.pi-session-mcp-${name}-${randomUUID()}`);
196
- let staged = false;
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
- if (!await sameSlot(path, current))
218
- throw new Error("skill_slot_changed");
252
+ const destination = join(names.staging, ...file.path.split("/"));
253
+ await ensureParents(dirname(destination), ownership);
219
254
  healthy(ownership);
220
- if (current.state === "tree") {
221
- await rename(path, backup);
222
- healthy(ownership);
223
- await syncDirectory(parent);
224
- healthy(ownership);
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
- await rename(temporary, path);
227
- staged = false;
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
- const after = await inspectSlot(path);
232
- if (after.state !== "tree" || after.fingerprint !== desired.fingerprint)
233
- throw new Error("skill_post_install_mismatch");
234
- if (current.state === "tree") {
235
- // Delete only the exact old tree moved by this transaction.
236
- if (!await sameSlot(backup, current))
237
- throw new Error("skill_backup_changed");
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 rm(backup, { recursive: true });
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
- await rm(temporary, { recursive: true, force: true });
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
- export declare function customizeSkill(request: SkillCustomizeRequest): Promise<SetupResult>;
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>;