pi-session-mcp 0.8.0 → 0.8.2

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 (45) hide show
  1. package/.pi-session-mcp-cli/doctor.js +14 -7
  2. package/.pi-session-mcp-cli/override-lock.js +125 -0
  3. package/.pi-session-mcp-cli/setup-platform.js +195 -0
  4. package/.pi-session-mcp-cli/skill-customize.js +131 -46
  5. package/.pi-session-mcp-cli/skill-library.js +72 -4
  6. package/CHANGELOG.md +159 -0
  7. package/CONTRIBUTING.md +5 -3
  8. package/README.md +5 -3
  9. package/dist/doctor.d.ts +3 -0
  10. package/dist/doctor.js +14 -7
  11. package/dist/override-lock.d.ts +34 -0
  12. package/dist/override-lock.js +125 -0
  13. package/dist/pi-adapter.d.ts +6 -4
  14. package/dist/sdk-pi-adapter.js +28 -5
  15. package/dist/session-registry.d.ts +8 -6
  16. package/dist/session-registry.js +3 -2
  17. package/dist/setup-ownership.d.ts +4 -1
  18. package/dist/setup-ownership.js +6 -3
  19. package/dist/setup-skills.d.ts +46 -1
  20. package/dist/setup-skills.js +349 -78
  21. package/dist/setup.js +21 -4
  22. package/dist/skill-customize.d.ts +19 -1
  23. package/dist/skill-customize.js +131 -46
  24. package/dist/skill-library.d.ts +46 -1
  25. package/dist/skill-library.js +72 -4
  26. package/docs/acceptance.md +747 -26
  27. package/docs/adr/0007-profile-skill-selection.md +11 -0
  28. package/docs/architecture.md +1 -1
  29. package/docs/client-setup-design.md +157 -24
  30. package/docs/client-setup.md +38 -7
  31. package/docs/doctor-design.md +1 -1
  32. package/docs/installation.md +8 -2
  33. package/docs/pi-extensions-design.md +11 -0
  34. package/docs/releasing.md +25 -10
  35. package/docs/reviews/2026-10-13-post-0.8.1-audit.md +393 -0
  36. package/docs/roadmap.md +127 -0
  37. package/docs/skills.md +173 -16
  38. package/docs/sprints/2026-10-13-s1-correctness.md +355 -0
  39. package/docs/sprints/2026-10-13-s2-evidence.md +431 -0
  40. package/docs/sprints/2026-10-13-s3-live-usage.md +267 -0
  41. package/docs/sprints/2026-10-13-s4-decision-inputs.md +241 -0
  42. package/docs/threat-model.md +3 -3
  43. package/docs/tool-contracts.md +20 -2
  44. package/package.json +1 -1
  45. package/skills/pi-session-orchestration/SKILL.md +8 -4
@@ -8,7 +8,10 @@ export interface OwnershipRecord {
8
8
  readonly transactionId: string;
9
9
  /**
10
10
  * Skill records only, while pending: the fingerprint of setup's own copy that the pending
11
- * update replaces, so an interrupted update can still be completed or removed.
11
+ * update replaces, so an interrupted update can still be completed or removed. The
12
+ * transaction id also names the update's intermediate directories, so recovery needs no path.
13
+ * Skill fingerprints are `v2:` values; a bare 64-hex value is a record that 0.8.0 wrote and
14
+ * names a tree only once a trusted reference proves it.
12
15
  */
13
16
  readonly replaces?: string;
14
17
  }
@@ -4,7 +4,7 @@ import { randomUUID } from "node:crypto";
4
4
  import { dirname } from "node:path";
5
5
  import { acquireSetupGuardian } from "./setup-process.js";
6
6
  import { assertSupportedFilesystem, currentSetupPlatform, openLockedDarwin } from "./setup-platform.js";
7
- import { SKILL_NAME_PATTERN } from "./skill-library.js";
7
+ import { LEGACY_SKILL_TREE_FINGERPRINT_PATTERN, SKILL_NAME_PATTERN, SKILL_TREE_FINGERPRINT_PATTERN } from "./skill-library.js";
8
8
  /** Safe default for one process; callers needing recovery inject a durable store. */
9
9
  export class MemoryOwnershipStore {
10
10
  entries = new Map();
@@ -231,10 +231,13 @@ export function isRecord(value) {
231
231
  const skill = skillAlias !== null && SKILL_NAME_PATTERN.test(skillAlias[1]) &&
232
232
  (client === "codex" ? scope === "user" && skillAlias[2] === undefined
233
233
  : client === "claude-code" && ((scope === "user" && skillAlias[2] === undefined) || (scope === "project" && skillAlias[2] !== undefined)));
234
+ // A registration fingerprint is a snapshot hash; a skill fingerprint is a version 2 tree value or a 0.8.0 legacy one.
235
+ const validFingerprint = (value) => typeof value === "string" &&
236
+ (skill ? SKILL_TREE_FINGERPRINT_PATTERN.test(value) || LEGACY_SKILL_TREE_FINGERPRINT_PATTERN.test(value) : /^[a-f0-9]{64}$/.test(value));
234
237
  // `replaces` belongs only to a pending skill record.
235
- if ("replaces" in x && !(skill && x.phase === "pending" && typeof x.replaces === "string" && /^[a-f0-9]{64}$/.test(x.replaces)))
238
+ if ("replaces" in x && !(skill && x.phase === "pending" && validFingerprint(x.replaces)))
236
239
  return false;
237
- return (registration || skill) && typeof x.fingerprint === "string" && /^[a-f0-9]{64}$/.test(x.fingerprint) &&
240
+ return (registration || skill) && validFingerprint(x.fingerprint) &&
238
241
  (x.phase === "pending" || x.phase === "owned") && x.previous === "absent" &&
239
242
  typeof x.transactionId === "string" && /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(x.transactionId);
240
243
  }
@@ -1,3 +1,4 @@
1
+ import { type ReleasedSkillTree } from "./skill-library.js";
1
2
  import type { OwnershipAccess } from "./setup-ownership.js";
2
3
  import type { SetupFinding, SetupOperation, SetupTarget } from "./setup-result.js";
3
4
  export interface SetupSkillInstaller {
@@ -15,12 +16,22 @@ export interface SetupSkillRoots {
15
16
  readonly home: string;
16
17
  readonly cwd: string;
17
18
  }
19
+ /** The crash-relevant points of an update, in order. Only a test seam observes them. */
20
+ export type SkillCheckpoint = "staged" | "backup-renamed" | "installed" | "trash-renamed" | "trash-entry-removed" | "trash-deleted" | "before-ownership";
21
+ export interface SetupSkillDependencies {
22
+ /** Trusted released trees that prove 0.8.0 records; defaults to the shipped table. */
23
+ readonly released?: readonly ReleasedSkillTree[];
24
+ /** Test seam, called after each step in `SkillCheckpoint` completed; defaults to a no-op. */
25
+ readonly checkpoint?: (step: SkillCheckpoint) => void | Promise<void>;
26
+ }
18
27
  /** Paths remain internal. Ownership identifies a client slot, never its location. */
19
28
  export declare class FileSetupSkillInstaller implements SetupSkillInstaller {
20
29
  private readonly roots;
21
- constructor(roots: SetupSkillRoots);
30
+ private readonly dependencies;
31
+ constructor(roots: SetupSkillRoots, dependencies?: SetupSkillDependencies);
22
32
  run(operation: SetupOperation, target: SetupTarget, ownership: OwnershipAccess): Promise<readonly SetupFinding[]>;
23
33
  owns(target: SetupTarget, ownership: OwnershipAccess): Promise<boolean>;
34
+ private step;
24
35
  /**
25
36
  * The slot path and its ownership key. A project may be untrusted: only its root is
26
37
  * canonicalized, and a link below it (for example `.claude` pointing elsewhere) makes the
@@ -32,6 +43,40 @@ export declare class FileSetupSkillInstaller implements SetupSkillInstaller {
32
43
  */
33
44
  private location;
34
45
  private library;
46
+ /**
47
+ * Whether the record names this installed tree: its version 2 value, or a 0.8.0 value that a
48
+ * trusted reference proves for exactly these bytes. A bare legacy value never proves identity.
49
+ */
50
+ private ownsTree;
35
51
  private skill;
52
+ /**
53
+ * Installs the new tree through the transaction's own intermediates. A failure leaves them for
54
+ * recovery; nothing here deletes by guess.
55
+ */
36
56
  private replace;
57
+ /**
58
+ * Deletes the backup only after it is verified to be the exact old copy: it is renamed to the
59
+ * transaction's trash name first, so the deletion never leaves an unverifiable partial tree
60
+ * under the backup name.
61
+ */
62
+ private discardBackup;
63
+ /**
64
+ * Deletes the transaction's trash entry by entry, so a crash leaves only a smaller partial tree.
65
+ * Callers must have proven the trash to be the complete replaced copy; a later recovery retains
66
+ * a partial tree instead of deleting it.
67
+ */
68
+ private discardTrash;
69
+ /**
70
+ * Settles an interrupted update of the pending record's transaction, inside the ownership
71
+ * transaction. Only directories with this transaction's exact names are examined, and only
72
+ * verified ones are deleted: the backup must equal the replaced copy, the staging directory
73
+ * the target or an interrupted write of it, the trash the complete replaced copy. Anything
74
+ * else stays and is reported, including a partial or changed trash.
75
+ * A proven trash is still deleted when a later intermediate (backup or staging) is retained.
76
+ *
77
+ * apply, slot absent: a complete staging directory is renamed into the slot (forward);
78
+ * otherwise a verified backup is moved back. remove and rollback do not restore, they delete
79
+ * what is verified and leave the slot to the caller.
80
+ */
81
+ private recover;
37
82
  }
@@ -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,26 @@ 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
- }
151
+ return desired && current.state === "tree" && current.fingerprint === desired.fingerprint
152
+ ? finding("skill_verified", "ok") : finding("skill_not_equivalent", "failed");
127
153
  }
128
- let desired;
129
- try {
130
- if (!library)
131
- throw new Error("skill_source_invalid");
132
- desired = await readSkillTree(library.directory);
133
- }
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, or its retention.
166
+ const recoveryPlanned = operation === "dry-run" && owned?.phase === "pending" && SKILL_TREE_FINGERPRINT_PATTERN.test(owned.fingerprint) && !hasLegacy(owned);
167
+ // The classification apply's recovery acts on, read-only: an intermediate apply would retain is not announced as planned.
168
+ if (recoveryPlanned && (await classifyIntermediates(path, name, owned, desired, linkTarget)).state === "retained")
169
+ return finding("skill_recovery_retained", "divergent");
138
170
  const current = await inspectSlot(path, linkTarget);
139
- const owned = await ownership.get(ownedTarget);
140
171
  if (current.state === "linked") {
141
172
  if (operation === "apply" && owned) {
142
173
  healthy(ownership);
@@ -147,6 +178,17 @@ export class FileSetupSkillInstaller {
147
178
  }
148
179
  if (current.state === "divergent")
149
180
  return finding("skill_divergent", "divergent");
181
+ if (owned && current.state === "tree" && hasLegacy(owned)) {
182
+ // A record from 0.8.0: it names the slot only if a trusted reference proves these exact bytes.
183
+ if (!this.ownsTree(name, owned, current, references))
184
+ return finding("skill_legacy_unverified", "divergent");
185
+ owned = { target: ownedTarget, fingerprint: current.fingerprint, previous: owned.previous, phase: "owned", transactionId: owned.transactionId };
186
+ if (operation === "apply") {
187
+ healthy(ownership);
188
+ await ownership.put(owned);
189
+ healthy(ownership);
190
+ }
191
+ }
150
192
  // A copy setup did not write, or its own copy modified since, is never replaced.
151
193
  if (current.state === "tree" && (!owned || !ownsFingerprint(owned, current.fingerprint))) {
152
194
  if (!owned && current.fingerprint === desired.fingerprint)
@@ -154,17 +196,26 @@ export class FileSetupSkillInstaller {
154
196
  return finding("skill_divergent", "divergent");
155
197
  }
156
198
  if (current.state === "tree" && current.fingerprint === desired.fingerprint) {
199
+ if (recoveryPlanned)
200
+ return finding("skill_recovery_planned", "planned");
157
201
  if (operation === "apply" && owned?.phase === "pending") {
158
202
  healthy(ownership);
159
203
  await ownership.put(ownedRecord(owned, desired.fingerprint));
160
204
  healthy(ownership);
205
+ return finding("skill_recovered", "ok");
161
206
  }
207
+ if (operation === "apply" && recovered)
208
+ return finding("skill_recovered", "ok");
162
209
  return finding(operation === "apply" ? "skill_already_equivalent" : "skill_equivalent", "unchanged");
163
210
  }
164
- if (operation === "dry-run")
211
+ if (operation === "dry-run") {
212
+ if (recoveryPlanned)
213
+ return finding("skill_recovery_planned", "planned");
165
214
  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.
215
+ }
216
+ // The pending record is persisted before any intermediate exists. It names the intermediates
217
+ // (transaction id), the target and the replaced copy, so an interrupted update can be
218
+ // recovered, finished or removed from persisted state alone.
168
219
  const pending = {
169
220
  target: ownedTarget, fingerprint: desired.fingerprint, previous: "absent", phase: "pending", transactionId: randomUUID(),
170
221
  ...(current.state === "tree" ? { replaces: current.fingerprint } : {}),
@@ -172,11 +223,12 @@ export class FileSetupSkillInstaller {
172
223
  healthy(ownership);
173
224
  await ownership.put(pending);
174
225
  healthy(ownership);
175
- await this.replace(path, name, desired, current, ownership);
226
+ await this.replace(path, name, desired, current, pending, ownership);
176
227
  healthy(ownership);
177
228
  const after = await inspectSlot(path);
178
229
  if (after.state !== "tree" || after.fingerprint !== desired.fingerprint)
179
230
  throw new Error("skill_post_install_mismatch");
231
+ await this.step("before-ownership");
180
232
  healthy(ownership);
181
233
  await ownership.put(ownedRecord(pending, desired.fingerprint));
182
234
  healthy(ownership);
@@ -186,69 +238,185 @@ export class FileSetupSkillInstaller {
186
238
  return finding(operation === "apply" ? "skill_install_failed" : "skill_source_invalid", "failed");
187
239
  }
188
240
  }
189
- async replace(path, name, desired, current, ownership) {
241
+ /**
242
+ * Installs the new tree through the transaction's own intermediates. A failure leaves them for
243
+ * recovery; nothing here deletes by guess.
244
+ */
245
+ async replace(path, name, desired, current, record, ownership) {
190
246
  const parent = dirname(path);
247
+ const names = intermediates(path, name, record.transactionId);
191
248
  healthy(ownership);
192
249
  await ensureParents(parent, ownership);
193
250
  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);
251
+ healthy(ownership);
252
+ await mkdir(names.staging, { mode: 0o755 });
253
+ for (const file of desired.files) {
216
254
  healthy(ownership);
217
- if (!await sameSlot(path, current))
218
- throw new Error("skill_slot_changed");
255
+ const destination = join(names.staging, ...file.path.split("/"));
256
+ await ensureParents(dirname(destination), ownership);
219
257
  healthy(ownership);
220
- if (current.state === "tree") {
221
- await rename(path, backup);
222
- healthy(ownership);
223
- await syncDirectory(parent);
224
- healthy(ownership);
258
+ const handle = await open(destination, "wx", 0o644);
259
+ try {
260
+ await handle.writeFile(file.bytes);
261
+ await handle.sync();
225
262
  }
226
- await rename(temporary, path);
227
- staged = false;
263
+ finally {
264
+ await handle.close();
265
+ }
266
+ }
267
+ await syncTreeDirectories(names.staging, desired, ownership);
268
+ await this.step("staged");
269
+ healthy(ownership);
270
+ if (!await sameSlot(path, current))
271
+ throw new Error("skill_slot_changed");
272
+ healthy(ownership);
273
+ if (current.state === "tree") {
274
+ // A rename onto an existing empty directory would replace it silently.
275
+ if (await present(names.backup))
276
+ throw new Error("skill_backup_exists");
277
+ await rename(path, names.backup);
228
278
  healthy(ownership);
229
279
  await syncDirectory(parent);
230
280
  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");
281
+ await this.step("backup-renamed");
282
+ }
283
+ await rename(names.staging, path);
284
+ healthy(ownership);
285
+ await syncDirectory(parent);
286
+ healthy(ownership);
287
+ await this.step("installed");
288
+ const after = await inspectSlot(path);
289
+ if (after.state !== "tree" || after.fingerprint !== desired.fingerprint)
290
+ throw new Error("skill_post_install_mismatch");
291
+ if (current.state === "tree")
292
+ await this.discardBackup(names, current, parent, ownership);
293
+ }
294
+ /**
295
+ * Deletes the backup only after it is verified to be the exact old copy: it is renamed to the
296
+ * transaction's trash name first, so the deletion never leaves an unverifiable partial tree
297
+ * under the backup name.
298
+ */
299
+ async discardBackup(names, backup, parent, ownership) {
300
+ if (await present(names.trash))
301
+ throw new Error("skill_trash_exists");
302
+ if (!await sameSlot(names.backup, backup))
303
+ throw new Error("skill_backup_changed");
304
+ healthy(ownership);
305
+ await rename(names.backup, names.trash);
306
+ healthy(ownership);
307
+ await syncDirectory(parent);
308
+ healthy(ownership);
309
+ await this.step("trash-renamed");
310
+ await this.discardTrash(names, parent, ownership);
311
+ await this.step("trash-deleted");
312
+ }
313
+ /**
314
+ * Deletes the transaction's trash entry by entry, so a crash leaves only a smaller partial tree.
315
+ * Callers must have proven the trash to be the complete replaced copy; a later recovery retains
316
+ * a partial tree instead of deleting it.
317
+ */
318
+ async discardTrash(names, parent, ownership) {
319
+ await removeEntries(names.trash, parent, ownership, () => this.step("trash-entry-removed"));
320
+ }
321
+ /**
322
+ * Settles an interrupted update of the pending record's transaction, inside the ownership
323
+ * transaction. Only directories with this transaction's exact names are examined, and only
324
+ * verified ones are deleted: the backup must equal the replaced copy, the staging directory
325
+ * the target or an interrupted write of it, the trash the complete replaced copy. Anything
326
+ * else stays and is reported, including a partial or changed trash.
327
+ * A proven trash is still deleted when a later intermediate (backup or staging) is retained.
328
+ *
329
+ * apply, slot absent: a complete staging directory is renamed into the slot (forward);
330
+ * otherwise a verified backup is moved back. remove and rollback do not restore, they delete
331
+ * what is verified and leave the slot to the caller.
332
+ */
333
+ async recover(operation, path, name, record, library, linkTarget, ownership) {
334
+ const parent = dirname(path);
335
+ const names = intermediates(path, name, record.transactionId);
336
+ const target = record.fingerprint;
337
+ const previous = record.replaces !== undefined && SKILL_TREE_FINGERPRINT_PATTERN.test(record.replaces) ? record.replaces : undefined;
338
+ let found = await classifyIntermediates(path, name, record, library, linkTarget);
339
+ if (found.state === "untouched")
340
+ return "untouched";
341
+ let changed = false;
342
+ if (found.deleteTrash) {
343
+ await this.discardTrash(names, parent, ownership);
344
+ changed = true;
345
+ // Backup and staging are read again once the trash is gone; the slot stays the one first read.
346
+ found = await classifyIntermediates(path, name, record, library, linkTarget, found.slot);
347
+ }
348
+ if (found.state !== "settle")
349
+ return found.state;
350
+ const { slot } = found;
351
+ let { backup, staging } = found;
352
+ if (operation === "apply" && slot.state === "absent") {
353
+ if (staging === "complete" || backup.state === "tree") {
354
+ const source = staging === "complete" ? names.staging : names.backup;
355
+ const expected = staging === "complete" ? target : previous;
238
356
  healthy(ownership);
239
- await rm(backup, { recursive: true });
357
+ if (!await sameSlot(path, slot))
358
+ throw new Error("skill_slot_changed");
359
+ healthy(ownership);
360
+ await rename(source, path);
240
361
  healthy(ownership);
241
362
  await syncDirectory(parent);
242
- }
243
- }
244
- finally {
245
- // An unhealthy fence retains staged/backup content for conservative recovery.
246
- if (staged) {
247
363
  healthy(ownership);
248
- await rm(temporary, { recursive: true, force: true });
364
+ const restored = await inspectSlot(path);
365
+ if (restored.state !== "tree" || restored.fingerprint !== expected)
366
+ throw new Error("skill_recovery_mismatch");
367
+ changed = true;
368
+ if (staging === "complete")
369
+ staging = "absent";
370
+ else
371
+ backup = { state: "absent" };
249
372
  }
250
373
  }
374
+ if (backup.state === "tree") {
375
+ await this.discardBackup(names, backup, parent, ownership);
376
+ changed = true;
377
+ }
378
+ if (staging !== "absent") {
379
+ healthy(ownership);
380
+ await removeEntries(names.staging, parent, ownership);
381
+ changed = true;
382
+ }
383
+ return changed ? "recovered" : "settled";
384
+ }
385
+ }
386
+ /**
387
+ * Reads a pending version 2 transaction's slot, trash, backup and staging directory, by their
388
+ * exact names, and never writes. The slot is only inspected when `known` is not given. Shared by
389
+ * recovery and the dry-run, so both apply the same rules.
390
+ *
391
+ * Only an empty slot, a link or one of setup's two copies can have intermediates settled. A trash
392
+ * is deletable only while it is the complete replaced copy; a partial or changed trash, a backup
393
+ * that is not the replaced copy and a foreign staging directory are retained.
394
+ */
395
+ async function classifyIntermediates(path, name, record, library, linkTarget, known) {
396
+ const names = intermediates(path, name, record.transactionId);
397
+ const target = record.fingerprint;
398
+ const previous = record.replaces !== undefined && SKILL_TREE_FINGERPRINT_PATTERN.test(record.replaces) ? record.replaces : undefined;
399
+ const slot = known ?? await inspectSlot(path, linkTarget);
400
+ if (slot.state === "divergent" || (slot.state === "tree" && slot.fingerprint !== target && slot.fingerprint !== previous))
401
+ return { state: "untouched" };
402
+ // A trash directory with the transaction's name holds the verified replaced copy, but only
403
+ // while it is complete: it is deleted only when it still equals the record's replaced
404
+ // fingerprint. A partial trash (an interrupted deletion) or any changed content cannot be
405
+ // proven to be setup's own, so it is retained and reported for the operator to settle.
406
+ let deleteTrash = false;
407
+ if (await present(names.trash)) {
408
+ const trash = await inspectSlot(names.trash);
409
+ if (trash.state !== "tree" || previous === undefined || trash.fingerprint !== previous)
410
+ return { state: "retained", slot, deleteTrash };
411
+ deleteTrash = true;
251
412
  }
413
+ const backup = await inspectSlot(names.backup);
414
+ if (backup.state === "divergent" || (backup.state === "tree" && backup.fingerprint !== previous))
415
+ return { state: "retained", slot, deleteTrash };
416
+ const staging = await classifyStaging(names.staging, target, library);
417
+ if (staging === "foreign")
418
+ return { state: "retained", slot, deleteTrash };
419
+ return { state: "settle", slot, deleteTrash, backup, staging };
252
420
  }
253
421
  function healthy(ownership) { ownership.assertHealthy?.(); }
254
422
  /** The owned form of a record, without the replaced fingerprint of a finished update. */
@@ -310,6 +478,109 @@ async function sameSlot(path, expected, linkTarget) {
310
478
  return expected.state === "absent" ? actual.state === "absent" :
311
479
  expected.state === "tree" && actual.state === "tree" && actual.fingerprint === expected.fingerprint && actual.dev === expected.dev && actual.ino === expected.ino;
312
480
  }
481
+ async function present(path) {
482
+ try {
483
+ await lstat(path);
484
+ return true;
485
+ }
486
+ catch (error) {
487
+ if (error.code === "ENOENT")
488
+ return false;
489
+ throw error;
490
+ }
491
+ }
492
+ /**
493
+ * The regular files and directories (relative, `/` separated) below a directory, or undefined
494
+ * unless it is a real directory with only regular files and directories, no hidden entry, and
495
+ * within the tree limits. A link or special file is never followed or counted as plain.
496
+ */
497
+ async function collectPlain(root) {
498
+ const files = [];
499
+ const directories = [];
500
+ let bytes = 0;
501
+ let entries = 0;
502
+ const visit = async (current, prefix, depth) => {
503
+ if (depth > MAX_TREE_DEPTH)
504
+ return false;
505
+ for (const entry of await readdir(current)) {
506
+ if (entry.startsWith(".") || ++entries > MAX_TREE_ENTRIES)
507
+ return false;
508
+ const path = join(current, entry);
509
+ const relative = prefix === "" ? entry : `${prefix}/${entry}`;
510
+ const info = await lstat(path);
511
+ if (info.isDirectory()) {
512
+ directories.push(relative);
513
+ if (!await visit(path, relative, depth + 1))
514
+ return false;
515
+ continue;
516
+ }
517
+ if (!info.isFile())
518
+ return false;
519
+ bytes += info.size;
520
+ if (files.length + 1 > MAX_SKILL_TREE_FILES || bytes > MAX_SKILL_TREE_BYTES)
521
+ return false;
522
+ files.push(relative);
523
+ }
524
+ return true;
525
+ };
526
+ try {
527
+ const info = await lstat(root);
528
+ return info.isDirectory() && await visit(root, "", 0) ? { files, directories } : undefined;
529
+ }
530
+ catch {
531
+ return undefined;
532
+ }
533
+ }
534
+ /**
535
+ * How a staging directory of the pending record relates to its target: `complete` (its tree
536
+ * equals the target fingerprint), `partial` (an interrupted write: every entry is a regular file
537
+ * of the target tree whose content equals that file or a prefix of it, or a directory on the path
538
+ * of one, checked only against the current library tree when it is the record's target),
539
+ * `absent`, or `foreign`, which is never deleted.
540
+ */
541
+ async function classifyStaging(staging, target, library) {
542
+ const staged = await inspectSlot(staging);
543
+ if (staged.state === "absent")
544
+ return "absent";
545
+ if (staged.state === "tree" && staged.fingerprint === target)
546
+ return "complete";
547
+ if (!library || library.fingerprint !== target)
548
+ return "foreign";
549
+ const plain = await collectPlain(staging);
550
+ if (!plain)
551
+ return "foreign";
552
+ const expected = new Map(library.files.map((file) => [file.path, file.bytes]));
553
+ const ancestors = new Set();
554
+ for (const file of library.files)
555
+ for (let cursor = posix.dirname(file.path); cursor !== "."; cursor = posix.dirname(cursor))
556
+ ancestors.add(cursor);
557
+ if (!plain.directories.every((directory) => ancestors.has(directory)))
558
+ return "foreign";
559
+ for (const file of plain.files) {
560
+ const bytes = expected.get(file);
561
+ if (!bytes)
562
+ return "foreign";
563
+ const content = await readFile(join(staging, ...file.split("/")));
564
+ if (content.length > bytes.length || !bytes.subarray(0, content.length).equals(content))
565
+ return "foreign";
566
+ }
567
+ return "partial";
568
+ }
569
+ /** Removes a verified directory entry by entry and then itself, with directory fsyncs, so a crash leaves a smaller partial tree. */
570
+ async function removeEntries(directory, parent, ownership, afterEntry) {
571
+ for (const entry of await readdir(directory)) {
572
+ healthy(ownership);
573
+ await rm(join(directory, entry), { recursive: true });
574
+ healthy(ownership);
575
+ await syncDirectory(directory);
576
+ await afterEntry?.();
577
+ }
578
+ healthy(ownership);
579
+ await rmdir(directory);
580
+ healthy(ownership);
581
+ await syncDirectory(parent);
582
+ healthy(ownership);
583
+ }
313
584
  async function ensureParents(directory, ownership) {
314
585
  if (dirname(directory) !== directory)
315
586
  await ensureParents(dirname(directory), ownership);