pi-session-mcp 0.7.1 → 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.
Files changed (66) hide show
  1. package/.pi-session-mcp-cli/config.js +47 -7
  2. package/.pi-session-mcp-cli/doctor.js +75 -2
  3. package/.pi-session-mcp-cli/execution-profile.js +1 -0
  4. package/.pi-session-mcp-cli/override-lock.js +125 -0
  5. package/.pi-session-mcp-cli/setup-platform.js +195 -0
  6. package/.pi-session-mcp-cli/setup-result.js +8 -0
  7. package/.pi-session-mcp-cli/skill-catalog.js +35 -0
  8. package/.pi-session-mcp-cli/skill-customize.js +268 -0
  9. package/.pi-session-mcp-cli/skill-library.js +188 -0
  10. package/CHANGELOG.md +176 -0
  11. package/README.md +15 -3
  12. package/dist/capabilities.d.ts +1 -0
  13. package/dist/capabilities.js +2 -1
  14. package/dist/client-adapters/mcp-verifier.js +6 -2
  15. package/dist/config.d.ts +4 -0
  16. package/dist/config.js +47 -7
  17. package/dist/doctor.d.ts +7 -1
  18. package/dist/doctor.js +75 -2
  19. package/dist/execution-profile.d.ts +4 -0
  20. package/dist/execution-profile.js +1 -0
  21. package/dist/override-lock.d.ts +34 -0
  22. package/dist/override-lock.js +125 -0
  23. package/dist/pi-adapter.d.ts +7 -5
  24. package/dist/pi-adapter.js +1 -0
  25. package/dist/pi-skills.d.ts +17 -0
  26. package/dist/pi-skills.js +64 -0
  27. package/dist/sdk-pi-adapter.d.ts +7 -3
  28. package/dist/sdk-pi-adapter.js +82 -16
  29. package/dist/server.js +1 -0
  30. package/dist/session-registry.d.ts +8 -6
  31. package/dist/session-registry.js +3 -2
  32. package/dist/setup-cli.d.ts +2 -0
  33. package/dist/setup-cli.js +16 -0
  34. package/dist/setup-main.d.ts +1 -1
  35. package/dist/setup-main.js +18 -3
  36. package/dist/setup-ownership.d.ts +9 -0
  37. package/dist/setup-ownership.js +32 -5
  38. package/dist/setup-skills.d.ts +76 -0
  39. package/dist/setup-skills.js +586 -0
  40. package/dist/setup.d.ts +7 -0
  41. package/dist/setup.js +49 -6
  42. package/dist/skill-catalog.d.ts +26 -0
  43. package/dist/skill-catalog.js +35 -0
  44. package/dist/skill-customize.d.ts +41 -0
  45. package/dist/skill-customize.js +268 -0
  46. package/dist/skill-library.d.ts +106 -0
  47. package/dist/skill-library.js +188 -0
  48. package/docs/acceptance.md +593 -1
  49. package/docs/adr/0006-pi-extension-allowlist.md +3 -0
  50. package/docs/adr/0007-profile-skill-selection.md +228 -0
  51. package/docs/architecture.md +10 -6
  52. package/docs/client-setup-design.md +219 -1
  53. package/docs/client-setup.md +86 -3
  54. package/docs/doctor-design.md +9 -1
  55. package/docs/installation.md +128 -5
  56. package/docs/pi-extensions-design.md +28 -4
  57. package/docs/releasing.md +18 -9
  58. package/docs/session-mcp.md +7 -2
  59. package/docs/skills.md +616 -0
  60. package/docs/threat-model.md +19 -2
  61. package/docs/tool-contracts.md +9 -5
  62. package/package.json +2 -1
  63. package/pi-session-mcp.example.json +9 -3
  64. package/skills/pi-session-implementation/SKILL.md +42 -0
  65. package/skills/pi-session-orchestration/SKILL.md +94 -0
  66. package/skills/pi-session-review/SKILL.md +58 -0
@@ -0,0 +1,586 @@
1
+ import { createHash, randomUUID } from "node:crypto";
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. */
9
+ function ownsFingerprint(record, fingerprint) {
10
+ return fingerprint === record.fingerprint || (record.phase === "pending" && record.replaces === fingerprint);
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
+ }
24
+ /** Paths remain internal. Ownership identifies a client slot, never its location. */
25
+ export class FileSetupSkillInstaller {
26
+ roots;
27
+ dependencies;
28
+ constructor(roots, dependencies = {}) {
29
+ this.roots = roots;
30
+ this.dependencies = dependencies;
31
+ }
32
+ async run(operation, target, ownership) {
33
+ const findings = [];
34
+ for (const name of CONTROLLER_SKILLS)
35
+ findings.push(await this.skill(operation, target, name, ownership));
36
+ return findings;
37
+ }
38
+ async owns(target, ownership) {
39
+ // Claude Code user and local share one slot: while the other registration exists, its skill
40
+ // is not this target's to clean up.
41
+ if (target.client === "claude-code" && target.scope !== "project"
42
+ && await ownership.get({ ...target, scope: target.scope === "local" ? "user" : "local" }))
43
+ return false;
44
+ for (const name of CONTROLLER_SKILLS)
45
+ if (await ownership.get((await this.location(target, name)).owned))
46
+ return true;
47
+ return false;
48
+ }
49
+ async step(step) { await this.dependencies.checkpoint?.(step); }
50
+ /**
51
+ * The slot path and its ownership key. A project may be untrusted: only its root is
52
+ * canonicalized, and a link below it (for example `.claude` pointing elsewhere) makes the
53
+ * slot divergent, so setup never writes outside the project. In the operator's home the
54
+ * client's skills directory may be the operator's own link, for example into a dotfiles
55
+ * checkout, so it is followed. The skill entry itself is never followed in either place.
56
+ * A project slot's ownership is bound to an opaque, path-free identity of the project, so a
57
+ * remove in another project never touches this project's copy.
58
+ */
59
+ async location(target, name) {
60
+ const client = target.client === "codex" ? ".agents" : ".claude";
61
+ if (target.scope === "project") {
62
+ const project = await realpath(this.roots.cwd);
63
+ const identity = createHash("sha256").update(project).digest("hex").slice(0, 16);
64
+ return { path: join(project, client, "skills", name), owned: { client: target.client, scope: "project", alias: `skill:${name}@${identity}` } };
65
+ }
66
+ return { path: join(await canonicalExisting(join(this.roots.home, client, "skills")), name), owned: { client: target.client, scope: "user", alias: `skill:${name}` } };
67
+ }
68
+ library(name) {
69
+ return resolveLibrarySkill(name, { overrideDirectory: skillOverrideDirectory(this.roots.configPath), packageRoot: this.roots.packageRoot });
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
+ }
81
+ async skill(operation, target, name, ownership) {
82
+ const finding = (code, status) => ({ target: { ...target, alias: name }, code, status });
83
+ let location;
84
+ try {
85
+ location = await this.location(target, name);
86
+ }
87
+ catch {
88
+ return finding(operation === "verify" ? "skill_missing" :
89
+ operation === "dry-run" ? "skill_source_invalid" :
90
+ operation === "apply" ? "skill_install_failed" : "skill_divergent", operation === "remove" || operation === "rollback" ? "divergent" : "failed");
91
+ }
92
+ const { path, owned: ownedTarget } = location;
93
+ const library = this.library(name);
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] : [];
98
+ if (operation === "remove" || operation === "rollback") {
99
+ try {
100
+ const owned = await ownership.get(ownedTarget);
101
+ if (!owned)
102
+ return finding("skill_not_owned", "unchanged");
103
+ if (target.client === "claude-code" && target.scope !== "project") {
104
+ const other = await ownership.get({ ...target, scope: target.scope === "local" ? "user" : "local" });
105
+ if (other?.phase === "owned")
106
+ return finding("skill_kept_shared", "unchanged");
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");
111
+ const current = await inspectSlot(path, linkTarget);
112
+ if (current.state === "absent") {
113
+ healthy(ownership);
114
+ await ownership.delete(ownedTarget);
115
+ healthy(ownership);
116
+ return finding("skill_absent", "ok");
117
+ }
118
+ if (current.state === "linked") {
119
+ // The operator replaced setup's copy with their own link: the record is stale, the link stays.
120
+ healthy(ownership);
121
+ await ownership.delete(ownedTarget);
122
+ healthy(ownership);
123
+ return finding("skill_not_owned", "unchanged");
124
+ }
125
+ if (current.state !== "tree")
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");
129
+ healthy(ownership);
130
+ if (!await sameSlot(path, current, linkTarget))
131
+ return finding("skill_divergent", "divergent");
132
+ healthy(ownership);
133
+ await rm(path, { recursive: true });
134
+ healthy(ownership);
135
+ await syncDirectory(dirname(path));
136
+ healthy(ownership);
137
+ await ownership.delete(ownedTarget);
138
+ healthy(ownership);
139
+ return finding("skill_removed", "ok");
140
+ }
141
+ catch {
142
+ return finding("skill_divergent", "divergent");
143
+ }
144
+ }
145
+ if (operation === "verify") {
146
+ const current = await inspectSlot(path, linkTarget);
147
+ if (current.state === "absent")
148
+ return finding("skill_missing", "failed");
149
+ if (current.state === "linked")
150
+ return finding("skill_verified", "ok");
151
+ return desired && current.state === "tree" && current.fingerprint === desired.fingerprint
152
+ ? finding("skill_verified", "ok") : finding("skill_not_equivalent", "failed");
153
+ }
154
+ if (!desired)
155
+ return finding("skill_source_invalid", "failed");
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);
167
+ const current = await inspectSlot(path, linkTarget);
168
+ if (current.state === "linked") {
169
+ if (operation === "apply" && owned) {
170
+ healthy(ownership);
171
+ await ownership.delete(ownedTarget);
172
+ healthy(ownership);
173
+ }
174
+ return finding("skill_linked", "unchanged");
175
+ }
176
+ if (current.state === "divergent")
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
+ }
189
+ // A copy setup did not write, or its own copy modified since, is never replaced.
190
+ if (current.state === "tree" && (!owned || !ownsFingerprint(owned, current.fingerprint))) {
191
+ if (!owned && current.fingerprint === desired.fingerprint)
192
+ return finding(operation === "apply" ? "skill_already_equivalent" : "skill_equivalent", "unchanged");
193
+ return finding("skill_divergent", "divergent");
194
+ }
195
+ if (current.state === "tree" && current.fingerprint === desired.fingerprint) {
196
+ if (recoveryPlanned)
197
+ return finding("skill_recovery_planned", "planned");
198
+ if (operation === "apply" && owned?.phase === "pending") {
199
+ healthy(ownership);
200
+ await ownership.put(ownedRecord(owned, desired.fingerprint));
201
+ healthy(ownership);
202
+ return finding("skill_recovered", "ok");
203
+ }
204
+ if (operation === "apply" && recovered)
205
+ return finding("skill_recovered", "ok");
206
+ return finding(operation === "apply" ? "skill_already_equivalent" : "skill_equivalent", "unchanged");
207
+ }
208
+ if (operation === "dry-run") {
209
+ if (recoveryPlanned)
210
+ return finding("skill_recovery_planned", "planned");
211
+ return current.state === "absent" ? finding("skill_absent", "absent") : finding("skill_update_planned", "planned");
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.
216
+ const pending = {
217
+ target: ownedTarget, fingerprint: desired.fingerprint, previous: "absent", phase: "pending", transactionId: randomUUID(),
218
+ ...(current.state === "tree" ? { replaces: current.fingerprint } : {}),
219
+ };
220
+ healthy(ownership);
221
+ await ownership.put(pending);
222
+ healthy(ownership);
223
+ await this.replace(path, name, desired, current, pending, ownership);
224
+ healthy(ownership);
225
+ const after = await inspectSlot(path);
226
+ if (after.state !== "tree" || after.fingerprint !== desired.fingerprint)
227
+ throw new Error("skill_post_install_mismatch");
228
+ await this.step("before-ownership");
229
+ healthy(ownership);
230
+ await ownership.put(ownedRecord(pending, desired.fingerprint));
231
+ healthy(ownership);
232
+ return finding(current.state === "absent" ? "skill_installed" : "skill_updated", "ok");
233
+ }
234
+ catch {
235
+ return finding(operation === "apply" ? "skill_install_failed" : "skill_source_invalid", "failed");
236
+ }
237
+ }
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) {
243
+ const parent = dirname(path);
244
+ const names = intermediates(path, name, record.transactionId);
245
+ healthy(ownership);
246
+ await ensureParents(parent, ownership);
247
+ healthy(ownership);
248
+ healthy(ownership);
249
+ await mkdir(names.staging, { mode: 0o755 });
250
+ for (const file of desired.files) {
251
+ healthy(ownership);
252
+ const destination = join(names.staging, ...file.path.split("/"));
253
+ await ensureParents(dirname(destination), ownership);
254
+ 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();
262
+ }
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);
275
+ healthy(ownership);
276
+ await syncDirectory(parent);
277
+ healthy(ownership);
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");
356
+ healthy(ownership);
357
+ await rename(source, path);
358
+ healthy(ownership);
359
+ await syncDirectory(parent);
360
+ healthy(ownership);
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" };
369
+ }
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";
381
+ }
382
+ }
383
+ function healthy(ownership) { ownership.assertHealthy?.(); }
384
+ /** The owned form of a record, without the replaced fingerprint of a finished update. */
385
+ function ownedRecord(record, fingerprint) {
386
+ return { target: record.target, fingerprint, previous: record.previous, phase: "owned", transactionId: record.transactionId };
387
+ }
388
+ /** The real path of the nearest existing ancestor, with the not yet existing tail appended. */
389
+ async function canonicalExisting(path) {
390
+ const missing = [];
391
+ let current = path;
392
+ for (;;) {
393
+ try {
394
+ return join(await realpath(current), ...missing);
395
+ }
396
+ catch (error) {
397
+ if (error.code !== "ENOENT" || dirname(current) === current)
398
+ throw error;
399
+ missing.unshift(basename(current));
400
+ current = dirname(current);
401
+ }
402
+ }
403
+ }
404
+ /** Reject symlink parents too, so inspecting a slot never traverses one. */
405
+ async function safeParents(directory) {
406
+ if (dirname(directory) !== directory)
407
+ await safeParents(dirname(directory));
408
+ try {
409
+ const info = await lstat(directory);
410
+ if (!info.isDirectory() || info.isSymbolicLink())
411
+ throw new Error("skill_parent_invalid");
412
+ }
413
+ catch (error) {
414
+ if (error.code !== "ENOENT")
415
+ throw error;
416
+ }
417
+ }
418
+ async function inspectSlot(path, linkTarget) {
419
+ try {
420
+ await safeParents(dirname(path));
421
+ const info = await lstat(path);
422
+ if (info.isSymbolicLink()) {
423
+ return linkTarget !== undefined && await realpath(path).then((target) => target === linkTarget, () => false) ? { state: "linked" } : { state: "divergent" };
424
+ }
425
+ if (!info.isDirectory())
426
+ return { state: "divergent" };
427
+ // Hidden entries are operator content: setup never writes them, and the fingerprint would not protect them.
428
+ const tree = await readSkillTree(path, { hidden: "reject" });
429
+ const after = await lstat(path);
430
+ if (!after.isDirectory() || after.isSymbolicLink() || after.dev !== info.dev || after.ino !== info.ino)
431
+ return { state: "divergent" };
432
+ return { state: "tree", fingerprint: tree.fingerprint, dev: info.dev, ino: info.ino };
433
+ }
434
+ catch (error) {
435
+ return { state: error.code === "ENOENT" ? "absent" : "divergent" };
436
+ }
437
+ }
438
+ async function sameSlot(path, expected, linkTarget) {
439
+ const actual = await inspectSlot(path, linkTarget);
440
+ return expected.state === "absent" ? actual.state === "absent" :
441
+ expected.state === "tree" && actual.state === "tree" && actual.fingerprint === expected.fingerprint && actual.dev === expected.dev && actual.ino === expected.ino;
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
+ }
546
+ async function ensureParents(directory, ownership) {
547
+ if (dirname(directory) !== directory)
548
+ await ensureParents(dirname(directory), ownership);
549
+ try {
550
+ const info = await lstat(directory);
551
+ if (!info.isDirectory() || info.isSymbolicLink())
552
+ throw new Error("skill_parent_invalid");
553
+ }
554
+ catch (error) {
555
+ if (error.code !== "ENOENT")
556
+ throw error;
557
+ healthy(ownership);
558
+ await mkdir(directory, { mode: 0o755 });
559
+ healthy(ownership);
560
+ await syncDirectory(directory);
561
+ await syncDirectory(dirname(directory));
562
+ }
563
+ }
564
+ async function syncDirectory(directory) {
565
+ const handle = await open(directory, "r");
566
+ try {
567
+ await handle.sync();
568
+ }
569
+ finally {
570
+ await handle.close();
571
+ }
572
+ }
573
+ async function syncTreeDirectories(root, tree, ownership) {
574
+ const directories = new Set([root]);
575
+ for (const file of tree.files) {
576
+ let cursor = dirname(join(root, file.path));
577
+ while (cursor !== root) {
578
+ directories.add(cursor);
579
+ cursor = dirname(cursor);
580
+ }
581
+ }
582
+ for (const directory of [...directories].sort((a, b) => b.length - a.length)) {
583
+ healthy(ownership);
584
+ await syncDirectory(directory);
585
+ }
586
+ }
package/dist/setup.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { CommandExecution, CommandRunner, RegistrationIntent, RegistrationState } from "./client-adapters/types.js";
2
2
  import { type OwnershipStore } from "./setup-ownership.js";
3
+ import { type SetupSkillInstaller } from "./setup-skills.js";
3
4
  import { type SetupOperation, type SetupResult, type SetupTarget } from "./setup-result.js";
4
5
  export interface SetupClientAdapter {
5
6
  readonly name: string;
@@ -25,6 +26,7 @@ export interface SetupDependencies {
25
26
  readonly adapters?: readonly SetupClientAdapter[];
26
27
  readonly doctor?: SetupDoctor;
27
28
  readonly ownership?: OwnershipStore;
29
+ readonly skills?: SetupSkillInstaller;
28
30
  readonly intendedFingerprint: string;
29
31
  }
30
32
  export interface SetupRequest {
@@ -35,8 +37,13 @@ export declare const isSupportedTarget: (target: SetupTarget) => boolean;
35
37
  export declare class SetupOrchestrator {
36
38
  private readonly dependencies;
37
39
  private readonly ownership;
40
+ /** Apply results that the up-front plan refused: nothing was applied, so no skill is touched. */
41
+ private readonly refusedApplies;
38
42
  constructor(dependencies: SetupDependencies);
39
43
  run(request: SetupRequest): Promise<SetupResult>;
44
+ /** Registration planning gates apply; skill divergence must not block registration. */
45
+ private withSkills;
46
+ private restoreWithSkills;
40
47
  private adapter;
41
48
  private assertOwnershipHealthy;
42
49
  private signal;