psyclaw 0.24.0

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 (270) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +67 -0
  3. package/dist/apps/panel/index.html +3416 -0
  4. package/dist/src/adapters/pi/extension.d.ts +19 -0
  5. package/dist/src/adapters/pi/extension.js +735 -0
  6. package/dist/src/adapters/pi/extension.js.map +1 -0
  7. package/dist/src/adapters/pi/model.d.ts +72 -0
  8. package/dist/src/adapters/pi/model.js +152 -0
  9. package/dist/src/adapters/pi/model.js.map +1 -0
  10. package/dist/src/adapters/pi/resources.d.ts +23 -0
  11. package/dist/src/adapters/pi/resources.js +54 -0
  12. package/dist/src/adapters/pi/resources.js.map +1 -0
  13. package/dist/src/adapters/pi/rpc.d.ts +56 -0
  14. package/dist/src/adapters/pi/rpc.js +248 -0
  15. package/dist/src/adapters/pi/rpc.js.map +1 -0
  16. package/dist/src/adapters/pi/session.d.ts +30 -0
  17. package/dist/src/adapters/pi/session.js +64 -0
  18. package/dist/src/adapters/pi/session.js.map +1 -0
  19. package/dist/src/agents/catalog.d.ts +30 -0
  20. package/dist/src/agents/catalog.js +118 -0
  21. package/dist/src/agents/catalog.js.map +1 -0
  22. package/dist/src/agents/discover.d.ts +28 -0
  23. package/dist/src/agents/discover.js +127 -0
  24. package/dist/src/agents/discover.js.map +1 -0
  25. package/dist/src/agents/import.d.ts +67 -0
  26. package/dist/src/agents/import.js +801 -0
  27. package/dist/src/agents/import.js.map +1 -0
  28. package/dist/src/analysis/hooks.d.ts +81 -0
  29. package/dist/src/analysis/hooks.js +139 -0
  30. package/dist/src/analysis/hooks.js.map +1 -0
  31. package/dist/src/analysis/pre-registration.d.ts +18 -0
  32. package/dist/src/analysis/pre-registration.js +86 -0
  33. package/dist/src/analysis/pre-registration.js.map +1 -0
  34. package/dist/src/branding.d.ts +43 -0
  35. package/dist/src/branding.js +127 -0
  36. package/dist/src/branding.js.map +1 -0
  37. package/dist/src/chat.d.ts +16 -0
  38. package/dist/src/chat.js +67 -0
  39. package/dist/src/chat.js.map +1 -0
  40. package/dist/src/cli.d.ts +2 -0
  41. package/dist/src/cli.js +316 -0
  42. package/dist/src/cli.js.map +1 -0
  43. package/dist/src/core/citations.d.ts +44 -0
  44. package/dist/src/core/citations.js +48 -0
  45. package/dist/src/core/citations.js.map +1 -0
  46. package/dist/src/core/contracts.d.ts +119 -0
  47. package/dist/src/core/contracts.js +2 -0
  48. package/dist/src/core/contracts.js.map +1 -0
  49. package/dist/src/core/docx.d.ts +11 -0
  50. package/dist/src/core/docx.js +46 -0
  51. package/dist/src/core/docx.js.map +1 -0
  52. package/dist/src/core/doi.d.ts +41 -0
  53. package/dist/src/core/doi.js +82 -0
  54. package/dist/src/core/doi.js.map +1 -0
  55. package/dist/src/core/evidence-policy.d.ts +24 -0
  56. package/dist/src/core/evidence-policy.js +308 -0
  57. package/dist/src/core/evidence-policy.js.map +1 -0
  58. package/dist/src/core/filekind.d.ts +15 -0
  59. package/dist/src/core/filekind.js +82 -0
  60. package/dist/src/core/filekind.js.map +1 -0
  61. package/dist/src/core/hash.d.ts +2 -0
  62. package/dist/src/core/hash.js +17 -0
  63. package/dist/src/core/hash.js.map +1 -0
  64. package/dist/src/core/pricing.d.ts +38 -0
  65. package/dist/src/core/pricing.js +110 -0
  66. package/dist/src/core/pricing.js.map +1 -0
  67. package/dist/src/core/redact.d.ts +12 -0
  68. package/dist/src/core/redact.js +48 -0
  69. package/dist/src/core/redact.js.map +1 -0
  70. package/dist/src/core/references.d.ts +55 -0
  71. package/dist/src/core/references.js +102 -0
  72. package/dist/src/core/references.js.map +1 -0
  73. package/dist/src/core/schemas.d.ts +162 -0
  74. package/dist/src/core/schemas.js +160 -0
  75. package/dist/src/core/schemas.js.map +1 -0
  76. package/dist/src/extension.d.ts +1 -0
  77. package/dist/src/extension.js +2 -0
  78. package/dist/src/extension.js.map +1 -0
  79. package/dist/src/index.d.ts +72 -0
  80. package/dist/src/index.js +73 -0
  81. package/dist/src/index.js.map +1 -0
  82. package/dist/src/install/installer.d.ts +147 -0
  83. package/dist/src/install/installer.js +743 -0
  84. package/dist/src/install/installer.js.map +1 -0
  85. package/dist/src/integrations/contracts.d.ts +35 -0
  86. package/dist/src/integrations/contracts.js +27 -0
  87. package/dist/src/integrations/contracts.js.map +1 -0
  88. package/dist/src/integrations/mcp.d.ts +155 -0
  89. package/dist/src/integrations/mcp.js +826 -0
  90. package/dist/src/integrations/mcp.js.map +1 -0
  91. package/dist/src/knowledge-map/contracts.d.ts +20 -0
  92. package/dist/src/knowledge-map/contracts.js +2 -0
  93. package/dist/src/knowledge-map/contracts.js.map +1 -0
  94. package/dist/src/knowledge-map/index.d.ts +4 -0
  95. package/dist/src/knowledge-map/index.js +39 -0
  96. package/dist/src/knowledge-map/index.js.map +1 -0
  97. package/dist/src/literature/contracts.d.ts +106 -0
  98. package/dist/src/literature/contracts.js +2 -0
  99. package/dist/src/literature/contracts.js.map +1 -0
  100. package/dist/src/literature/fulltext.d.ts +7 -0
  101. package/dist/src/literature/fulltext.js +24 -0
  102. package/dist/src/literature/fulltext.js.map +1 -0
  103. package/dist/src/literature/knowledge-map.d.ts +2 -0
  104. package/dist/src/literature/knowledge-map.js +20 -0
  105. package/dist/src/literature/knowledge-map.js.map +1 -0
  106. package/dist/src/literature/ledger.d.ts +3 -0
  107. package/dist/src/literature/ledger.js +17 -0
  108. package/dist/src/literature/ledger.js.map +1 -0
  109. package/dist/src/literature/metadata.d.ts +6 -0
  110. package/dist/src/literature/metadata.js +32 -0
  111. package/dist/src/literature/metadata.js.map +1 -0
  112. package/dist/src/memory/store.d.ts +22 -0
  113. package/dist/src/memory/store.js +73 -0
  114. package/dist/src/memory/store.js.map +1 -0
  115. package/dist/src/orchestration/contracts.d.ts +61 -0
  116. package/dist/src/orchestration/contracts.js +2 -0
  117. package/dist/src/orchestration/contracts.js.map +1 -0
  118. package/dist/src/orchestration/pi-executor.d.ts +28 -0
  119. package/dist/src/orchestration/pi-executor.js +169 -0
  120. package/dist/src/orchestration/pi-executor.js.map +1 -0
  121. package/dist/src/orchestration/runner.d.ts +196 -0
  122. package/dist/src/orchestration/runner.js +991 -0
  123. package/dist/src/orchestration/runner.js.map +1 -0
  124. package/dist/src/orchestration/scheduler.d.ts +8 -0
  125. package/dist/src/orchestration/scheduler.js +335 -0
  126. package/dist/src/orchestration/scheduler.js.map +1 -0
  127. package/dist/src/panel/events.d.ts +20 -0
  128. package/dist/src/panel/events.js +93 -0
  129. package/dist/src/panel/events.js.map +1 -0
  130. package/dist/src/panel/extension.d.ts +8 -0
  131. package/dist/src/panel/extension.js +114 -0
  132. package/dist/src/panel/extension.js.map +1 -0
  133. package/dist/src/panel/literature-map.d.ts +26 -0
  134. package/dist/src/panel/literature-map.js +34 -0
  135. package/dist/src/panel/literature-map.js.map +1 -0
  136. package/dist/src/panel/projection.d.ts +51 -0
  137. package/dist/src/panel/projection.js +288 -0
  138. package/dist/src/panel/projection.js.map +1 -0
  139. package/dist/src/panel/server.d.ts +46 -0
  140. package/dist/src/panel/server.js +1610 -0
  141. package/dist/src/panel/server.js.map +1 -0
  142. package/dist/src/panel/usage.d.ts +53 -0
  143. package/dist/src/panel/usage.js +150 -0
  144. package/dist/src/panel/usage.js.map +1 -0
  145. package/dist/src/project/bootstrap.d.ts +10 -0
  146. package/dist/src/project/bootstrap.js +83 -0
  147. package/dist/src/project/bootstrap.js.map +1 -0
  148. package/dist/src/project/hitl.d.ts +12 -0
  149. package/dist/src/project/hitl.js +43 -0
  150. package/dist/src/project/hitl.js.map +1 -0
  151. package/dist/src/project/jsonl.d.ts +16 -0
  152. package/dist/src/project/jsonl.js +93 -0
  153. package/dist/src/project/jsonl.js.map +1 -0
  154. package/dist/src/project/manuscript.d.ts +21 -0
  155. package/dist/src/project/manuscript.js +74 -0
  156. package/dist/src/project/manuscript.js.map +1 -0
  157. package/dist/src/project/paths.d.ts +38 -0
  158. package/dist/src/project/paths.js +166 -0
  159. package/dist/src/project/paths.js.map +1 -0
  160. package/dist/src/project/versions.d.ts +10 -0
  161. package/dist/src/project/versions.js +14 -0
  162. package/dist/src/project/versions.js.map +1 -0
  163. package/dist/src/psyclaw-theme.d.ts +88 -0
  164. package/dist/src/psyclaw-theme.js +89 -0
  165. package/dist/src/psyclaw-theme.js.map +1 -0
  166. package/dist/src/research/brief.d.ts +11 -0
  167. package/dist/src/research/brief.js +81 -0
  168. package/dist/src/research/brief.js.map +1 -0
  169. package/dist/src/research/ledger.d.ts +12 -0
  170. package/dist/src/research/ledger.js +43 -0
  171. package/dist/src/research/ledger.js.map +1 -0
  172. package/dist/src/run.d.ts +20 -0
  173. package/dist/src/run.js +34 -0
  174. package/dist/src/run.js.map +1 -0
  175. package/dist/src/setup.d.ts +54 -0
  176. package/dist/src/setup.js +221 -0
  177. package/dist/src/setup.js.map +1 -0
  178. package/dist/src/skills/contracts.d.ts +105 -0
  179. package/dist/src/skills/contracts.js +3 -0
  180. package/dist/src/skills/contracts.js.map +1 -0
  181. package/dist/src/skills/preflight.d.ts +10 -0
  182. package/dist/src/skills/preflight.js +39 -0
  183. package/dist/src/skills/preflight.js.map +1 -0
  184. package/dist/src/skills/registry.d.ts +28 -0
  185. package/dist/src/skills/registry.js +556 -0
  186. package/dist/src/skills/registry.js.map +1 -0
  187. package/dist/src/style/artifact.d.ts +6 -0
  188. package/dist/src/style/artifact.js +61 -0
  189. package/dist/src/style/artifact.js.map +1 -0
  190. package/dist/src/style/cli-ui.d.ts +41 -0
  191. package/dist/src/style/cli-ui.js +119 -0
  192. package/dist/src/style/cli-ui.js.map +1 -0
  193. package/dist/src/style/contracts.d.ts +34 -0
  194. package/dist/src/style/contracts.js +2 -0
  195. package/dist/src/style/contracts.js.map +1 -0
  196. package/dist/src/style/registry.d.ts +16 -0
  197. package/dist/src/style/registry.js +50 -0
  198. package/dist/src/style/registry.js.map +1 -0
  199. package/dist/src/trust/manifest.d.ts +42 -0
  200. package/dist/src/trust/manifest.js +53 -0
  201. package/dist/src/trust/manifest.js.map +1 -0
  202. package/dist/src/trust/sbom.d.ts +54 -0
  203. package/dist/src/trust/sbom.js +77 -0
  204. package/dist/src/trust/sbom.js.map +1 -0
  205. package/dist/src/tui/app.d.ts +3 -0
  206. package/dist/src/tui/app.js +103 -0
  207. package/dist/src/tui/app.js.map +1 -0
  208. package/dist/src/tui/model.d.ts +13 -0
  209. package/dist/src/tui/model.js +25 -0
  210. package/dist/src/tui/model.js.map +1 -0
  211. package/dist/src/updates/check.d.ts +43 -0
  212. package/dist/src/updates/check.js +68 -0
  213. package/dist/src/updates/check.js.map +1 -0
  214. package/dist/src/updates/manifest.d.ts +19 -0
  215. package/dist/src/updates/manifest.js +58 -0
  216. package/dist/src/updates/manifest.js.map +1 -0
  217. package/dist/src/updates/registry.d.ts +21 -0
  218. package/dist/src/updates/registry.js +82 -0
  219. package/dist/src/updates/registry.js.map +1 -0
  220. package/dist/src/updates/skills.d.ts +15 -0
  221. package/dist/src/updates/skills.js +39 -0
  222. package/dist/src/updates/skills.js.map +1 -0
  223. package/dist/src/updates/status.d.ts +7 -0
  224. package/dist/src/updates/status.js +37 -0
  225. package/dist/src/updates/status.js.map +1 -0
  226. package/dist/src/updates/update.d.ts +43 -0
  227. package/dist/src/updates/update.js +126 -0
  228. package/dist/src/updates/update.js.map +1 -0
  229. package/dist/src/wizard.d.ts +7 -0
  230. package/dist/src/wizard.js +90 -0
  231. package/dist/src/wizard.js.map +1 -0
  232. package/dist/src/workflows/analysis-delegation.d.ts +3 -0
  233. package/dist/src/workflows/analysis-delegation.js +96 -0
  234. package/dist/src/workflows/analysis-delegation.js.map +1 -0
  235. package/dist/src/workflows/expert-review-runner.d.ts +29 -0
  236. package/dist/src/workflows/expert-review-runner.js +35 -0
  237. package/dist/src/workflows/expert-review-runner.js.map +1 -0
  238. package/dist/src/workflows/expert-review.d.ts +140 -0
  239. package/dist/src/workflows/expert-review.js +111 -0
  240. package/dist/src/workflows/expert-review.js.map +1 -0
  241. package/dist/src/workflows/institutional-fulltext.d.ts +16 -0
  242. package/dist/src/workflows/institutional-fulltext.js +110 -0
  243. package/dist/src/workflows/institutional-fulltext.js.map +1 -0
  244. package/dist/src/workflows/literature-review.d.ts +3 -0
  245. package/dist/src/workflows/literature-review.js +76 -0
  246. package/dist/src/workflows/literature-review.js.map +1 -0
  247. package/dist/src/workflows/meta-analysis.d.ts +38 -0
  248. package/dist/src/workflows/meta-analysis.js +185 -0
  249. package/dist/src/workflows/meta-analysis.js.map +1 -0
  250. package/dist/src/workflows/publish.d.ts +51 -0
  251. package/dist/src/workflows/publish.js +153 -0
  252. package/dist/src/workflows/publish.js.map +1 -0
  253. package/dist/src/workflows/spec.d.ts +51 -0
  254. package/dist/src/workflows/spec.js +157 -0
  255. package/dist/src/workflows/spec.js.map +1 -0
  256. package/dist/src/workflows/style-contract.d.ts +24 -0
  257. package/dist/src/workflows/style-contract.js +23 -0
  258. package/dist/src/workflows/style-contract.js.map +1 -0
  259. package/dist/src/workflows/writing-review.d.ts +15 -0
  260. package/dist/src/workflows/writing-review.js +82 -0
  261. package/dist/src/workflows/writing-review.js.map +1 -0
  262. package/package.json +73 -0
  263. package/package.json.psyclaw +21 -0
  264. package/skills/core/citation-audit/SKILL.md +9 -0
  265. package/skills/core/evidence-capture/SKILL.md +9 -0
  266. package/skills/core/manifest.json +15 -0
  267. package/skills/core/research-brief/SKILL.md +11 -0
  268. package/skills/core/research-intake/SKILL.md +9 -0
  269. package/skills/recommended/catalog.json +52 -0
  270. package/skills/recommended/mcp-catalog.json +118 -0
@@ -0,0 +1,743 @@
1
+ import { createHash } from "node:crypto";
2
+ import { lstat, mkdir, open, readFile, rename, rm, unlink } from "node:fs/promises";
3
+ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
4
+ import { sha256File, sha256Text } from "../core/hash.js";
5
+ import { appendJsonlIfMissing, readJsonl } from "../project/jsonl.js";
6
+ import { projectPaths } from "../project/paths.js";
7
+ export class InMemoryInstallLedger {
8
+ keys = new Set();
9
+ reservations = new Set();
10
+ async alreadyRecorded(key) {
11
+ return this.keys.has(key);
12
+ }
13
+ async reserve(key) {
14
+ if (this.keys.has(key) || this.reservations.has(key))
15
+ return false;
16
+ this.reservations.add(key);
17
+ return true;
18
+ }
19
+ async record(key) {
20
+ this.keys.add(key);
21
+ this.reservations.delete(key);
22
+ }
23
+ async release(key) {
24
+ this.reservations.delete(key);
25
+ }
26
+ }
27
+ /** Project-local append-only install ledger (`.psyclaw/installs.jsonl`). */
28
+ export class FileInstallLedger {
29
+ root;
30
+ constructor(root) {
31
+ this.root = root;
32
+ }
33
+ path() {
34
+ return join(projectPaths(this.root).root, ".psyclaw", "installs.jsonl");
35
+ }
36
+ lockPath(key) {
37
+ const digest = createHash("sha256").update(key, "utf8").digest("hex");
38
+ return join(projectPaths(this.root).root, ".psyclaw", "install-locks", `${digest}.lock`);
39
+ }
40
+ async alreadyRecorded(key) {
41
+ const rows = await readJsonl(this.path());
42
+ return rows.some((row) => row === key || (typeof row === "object" && row !== null && "key" in row && row.key === key));
43
+ }
44
+ async reserve(key) {
45
+ if (await this.alreadyRecorded(key))
46
+ return false;
47
+ const path = this.lockPath(key);
48
+ await mkdir(dirname(path), { recursive: true });
49
+ try {
50
+ const handle = await open(path, "wx");
51
+ try {
52
+ await handle.writeFile(JSON.stringify({ key, reservedAt: new Date().toISOString() }), "utf8");
53
+ }
54
+ finally {
55
+ await handle.close();
56
+ }
57
+ return true;
58
+ }
59
+ catch (error) {
60
+ if (error.code === "EEXIST")
61
+ return false;
62
+ throw error;
63
+ }
64
+ }
65
+ async record(key) {
66
+ // The key-specific lock makes reservation cross-process; this append is
67
+ // additionally serialized per path within one process.
68
+ await appendJsonlIfMissing(this.path(), key, (value) => typeof value === "string" ? value : JSON.stringify(value));
69
+ }
70
+ async release(key) {
71
+ await unlink(this.lockPath(key)).catch(() => undefined);
72
+ }
73
+ }
74
+ export function planAgentInstall(agent, options = {}) {
75
+ const install = agent.install;
76
+ return {
77
+ schemaVersion: "psyclaw/install-plan/v1",
78
+ id: `install:agent:${agent.id}`,
79
+ kind: "agent",
80
+ targetId: agent.id,
81
+ sourceRef: install?.sourceRef ?? "unknown",
82
+ ref: install?.ref ?? "unpinned",
83
+ command: install?.installCommand ?? "",
84
+ effect: "write",
85
+ ...(options.projectRoot === undefined ? {} : { projectRoot: options.projectRoot }),
86
+ };
87
+ }
88
+ export function planSkillInstall(options) {
89
+ return {
90
+ schemaVersion: "psyclaw/install-plan/v1",
91
+ id: `install:skill:${options.targetId}`,
92
+ kind: "skill",
93
+ targetId: options.targetId,
94
+ sourceRef: options.sourceRef,
95
+ ref: options.ref ?? "legacy",
96
+ command: options.command,
97
+ effect: "write",
98
+ ...(options.target === undefined ? {} : { target: options.target }),
99
+ ...(options.stagingDir === undefined ? {} : { stagingDir: options.stagingDir }),
100
+ contentFile: options.contentFile ?? "SKILL.md",
101
+ ...(options.expectedSha256 === undefined ? {} : { expectedSha256: options.expectedSha256 }),
102
+ ...(options.license === undefined ? {} : { license: options.license }),
103
+ ...(options.licenseFile === undefined ? {} : { licenseFile: options.licenseFile }),
104
+ ...(options.licenseSha256 === undefined ? {} : { licenseSha256: options.licenseSha256 }),
105
+ ...(options.dependencies === undefined ? {} : { dependencies: options.dependencies }),
106
+ ...(options.sbom === undefined ? {} : { sbom: options.sbom }),
107
+ ...(options.projectRoot === undefined ? {} : { projectRoot: options.projectRoot }),
108
+ };
109
+ }
110
+ const SHA256_RE = /^[a-f0-9]{64}$/i;
111
+ const UNPINNED_REFS = new Set(["", "unpinned", "latest", "head", "main", "master", "*", "unknown"]);
112
+ const PROTECTED_SEGMENTS = new Set([".git", "data/raw"]);
113
+ const fallbackReservations = new WeakMap();
114
+ function installKey(plan) {
115
+ // Keep the identity bound to the exact tuple required by the D1 contract.
116
+ return JSON.stringify({
117
+ planId: plan.id,
118
+ contentSha256: plan.expectedSha256 ?? "",
119
+ target: plan.target ?? "",
120
+ });
121
+ }
122
+ function installResultHash(plan, contentSha256 = plan.expectedSha256 ?? "") {
123
+ return sha256Text(JSON.stringify({
124
+ kind: plan.kind,
125
+ targetId: plan.targetId,
126
+ sourceRef: plan.sourceRef,
127
+ ref: plan.ref,
128
+ contentSha256,
129
+ target: plan.target ?? "",
130
+ license: plan.license ?? "",
131
+ licenseSha256: plan.licenseSha256 ?? "",
132
+ dependencies: plan.dependencies ?? [],
133
+ sbom: plan.sbom ?? null,
134
+ }));
135
+ }
136
+ function receipt(plan, now, fields) {
137
+ return {
138
+ schemaVersion: "psyclaw/tool-receipt/v1",
139
+ runId: plan.id,
140
+ taskId: plan.targetId,
141
+ tool: `install:${plan.kind}`,
142
+ effect: plan.effect,
143
+ approval: fields.approval,
144
+ idempotencyKey: plan.id,
145
+ ok: fields.ok,
146
+ ...(fields.reasonCode === undefined ? {} : { reasonCode: fields.reasonCode }),
147
+ ...(fields.resultHash === undefined ? {} : { resultHash: fields.resultHash }),
148
+ startedAt: now,
149
+ finishedAt: fields.finishedAt ?? now,
150
+ };
151
+ }
152
+ function deny(plan, now, reasonCode, reason) {
153
+ return receipt(plan, now, {
154
+ approval: "denied",
155
+ ok: false,
156
+ reasonCode,
157
+ resultHash: sha256Text(reason),
158
+ });
159
+ }
160
+ function fail(plan, startedAt, reasonCode, result = reasonCode, finishedAt) {
161
+ return receipt(plan, startedAt, {
162
+ approval: "approved",
163
+ ok: false,
164
+ reasonCode,
165
+ resultHash: sha256Text(result),
166
+ ...(finishedAt === undefined ? {} : { finishedAt }),
167
+ });
168
+ }
169
+ function already(plan, now) {
170
+ return receipt(plan, now, {
171
+ approval: "approved",
172
+ ok: true,
173
+ reasonCode: "install.already-recorded",
174
+ resultHash: sha256Text("already-recorded"),
175
+ });
176
+ }
177
+ function sourceAndRefFailure(plan, allowLegacyRef) {
178
+ if (plan.sourceRef.trim() === "" || plan.sourceRef.trim().toLowerCase() === "unknown") {
179
+ return { code: "install.source-missing", reason: "source reference is required" };
180
+ }
181
+ if (!allowLegacyRef && UNPINNED_REFS.has(plan.ref.trim().toLowerCase())) {
182
+ return { code: "install.ref-unpinned", reason: "a pinned version/ref is required" };
183
+ }
184
+ if (plan.command.trim() === "") {
185
+ return { code: "install.native-manual", reason: "no automated install command" };
186
+ }
187
+ return undefined;
188
+ }
189
+ async function reserveLedger(ledger, key) {
190
+ if (ledger === undefined)
191
+ return true;
192
+ if (ledger.reserve !== undefined)
193
+ return ledger.reserve(key);
194
+ const reservations = fallbackReservations.get(ledger) ?? new Set();
195
+ if (reservations.has(key))
196
+ return false;
197
+ if (await ledger.alreadyRecorded(key))
198
+ return false;
199
+ reservations.add(key);
200
+ fallbackReservations.set(ledger, reservations);
201
+ return true;
202
+ }
203
+ async function releaseLedger(ledger, key) {
204
+ if (ledger !== undefined && ledger.reserve === undefined)
205
+ fallbackReservations.get(ledger)?.delete(key);
206
+ if (ledger?.release !== undefined)
207
+ await ledger.release(key).catch(() => undefined);
208
+ }
209
+ async function recordLedger(ledger, key) {
210
+ await ledger?.record(key);
211
+ if (ledger !== undefined && ledger.reserve === undefined)
212
+ fallbackReservations.get(ledger)?.delete(key);
213
+ }
214
+ function protectedPath(path) {
215
+ const pieces = path.replaceAll("\\", "/").split("/").filter(Boolean).map((part) => part.toLowerCase());
216
+ for (let index = 0; index < pieces.length; index += 1) {
217
+ const piece = pieces[index];
218
+ const joined = pieces.slice(Math.max(0, index - 1), index + 1).join("/");
219
+ if (PROTECTED_SEGMENTS.has(piece) || PROTECTED_SEGMENTS.has(joined))
220
+ return true;
221
+ if (piece.includes("credential") || piece.includes("secret") || piece.includes("token") || piece.includes("cookie"))
222
+ return true;
223
+ }
224
+ return false;
225
+ }
226
+ function assertContained(root, target, label) {
227
+ const base = resolve(root);
228
+ const candidate = resolve(target);
229
+ const rel = relative(base, candidate).replaceAll("\\", "/");
230
+ if (rel === "" || rel === ".." || rel.startsWith("../") || isAbsolute(rel) || protectedPath(rel)) {
231
+ throw new Error(`${label} escapes the project boundary`);
232
+ }
233
+ return candidate;
234
+ }
235
+ function commonPathAncestor(left, right) {
236
+ let cursor = resolve(left);
237
+ const target = resolve(right);
238
+ while (true) {
239
+ const rel = relative(cursor, target).replaceAll("\\", "/");
240
+ if (rel === "" || rel !== ".." && !rel.startsWith("../") && !isAbsolute(rel))
241
+ return cursor;
242
+ const parent = dirname(cursor);
243
+ if (parent === cursor)
244
+ return cursor;
245
+ cursor = parent;
246
+ }
247
+ }
248
+ async function assertNoSymlinkAncestors(path, label, boundary) {
249
+ const target = resolve(path);
250
+ const base = resolve(boundary);
251
+ const rel = relative(base, target).replaceAll("\\", "/");
252
+ if (rel === ".." || rel.startsWith("../") || isAbsolute(rel))
253
+ throw new Error(`${label} escapes its trusted boundary`);
254
+ let cursor = base;
255
+ const candidates = [cursor, ...rel.split("/").filter(Boolean).map((part) => {
256
+ cursor = join(cursor, part);
257
+ return cursor;
258
+ })];
259
+ for (const candidate of candidates) {
260
+ try {
261
+ const stat = await lstat(candidate);
262
+ if (stat.isSymbolicLink())
263
+ throw new Error(`${label} symlink is not allowed`);
264
+ }
265
+ catch (error) {
266
+ if (error.code !== "ENOENT")
267
+ throw error;
268
+ break;
269
+ }
270
+ }
271
+ }
272
+ async function validateInstallPaths(plan) {
273
+ if (plan.stagingDir === undefined || plan.target === undefined)
274
+ throw new Error("staging and target paths are required");
275
+ const staging = plan.projectRoot === undefined
276
+ ? resolve(plan.stagingDir)
277
+ : assertContained(plan.projectRoot, plan.stagingDir, "staging path");
278
+ const target = plan.projectRoot === undefined
279
+ ? resolve(plan.target)
280
+ : assertContained(plan.projectRoot, plan.target, "target path");
281
+ const stagingRel = relative(staging, target).replaceAll("\\", "/");
282
+ const targetRel = relative(target, staging).replaceAll("\\", "/");
283
+ if (staging === target || !stagingRel.startsWith("..") || !targetRel.startsWith("..")) {
284
+ throw new Error("staging and target paths must be separate");
285
+ }
286
+ if (plan.projectRoot === undefined && (protectedPath(staging) || protectedPath(target))) {
287
+ throw new Error("protected install path");
288
+ }
289
+ const trustedBoundary = plan.projectRoot === undefined
290
+ ? commonPathAncestor(staging, target)
291
+ : resolve(plan.projectRoot);
292
+ await assertNoSymlinkAncestors(staging, "staging path", trustedBoundary);
293
+ await assertNoSymlinkAncestors(target, "target path", trustedBoundary);
294
+ const contentFile = plan.contentFile ?? "SKILL.md";
295
+ if (contentFile.includes("\u0000") || contentFile.trim() === "" || isAbsolute(contentFile) || contentFile.replaceAll("\\", "/").split("/").includes("..")) {
296
+ throw new Error("content path must stay inside staging");
297
+ }
298
+ const contentPath = resolve(staging, contentFile);
299
+ assertContained(staging, contentPath, "content path");
300
+ const sbomPath = plan.sbom === undefined ? undefined : resolve(staging, plan.sbom.path);
301
+ if (sbomPath !== undefined) {
302
+ assertContained(staging, sbomPath, "SBOM path");
303
+ if (plan.sbom.path.includes("\u0000") || isAbsolute(plan.sbom.path) || plan.sbom.path.replaceAll("\\", "/").split("/").includes("..")) {
304
+ throw new Error("SBOM path must stay inside staging");
305
+ }
306
+ }
307
+ const licenseFile = plan.licenseFile;
308
+ const licensePath = licenseFile === undefined ? undefined : resolve(staging, licenseFile);
309
+ if (licensePath !== undefined) {
310
+ assertContained(staging, licensePath, "license path");
311
+ if (licenseFile.includes("\u0000") || isAbsolute(licenseFile) || licenseFile.replaceAll("\\", "/").split("/").includes("..")) {
312
+ throw new Error("license path must stay inside staging");
313
+ }
314
+ }
315
+ return {
316
+ staging,
317
+ target,
318
+ contentPath,
319
+ ...(sbomPath === undefined ? {} : { sbomPath }),
320
+ ...(licensePath === undefined ? {} : { licensePath }),
321
+ };
322
+ }
323
+ function sameFingerprint(left, right) {
324
+ return left.size === right.size && left.mtimeMs === right.mtimeMs && left.ctimeMs === right.ctimeMs &&
325
+ (left.ino === 0 || right.ino === 0 || left.ino === right.ino) &&
326
+ (left.dev === 0 || right.dev === 0 || left.dev === right.dev);
327
+ }
328
+ async function fingerprint(path) {
329
+ const stat = await lstat(path);
330
+ if (!stat.isFile() || stat.isSymbolicLink())
331
+ throw new Error("content is not a regular file");
332
+ return { size: stat.size, mtimeMs: stat.mtimeMs, ctimeMs: stat.ctimeMs, ino: stat.ino, dev: stat.dev };
333
+ }
334
+ async function stableHash(path) {
335
+ let lastActual = "";
336
+ for (let attempt = 0; attempt < 3; attempt += 1) {
337
+ try {
338
+ const before = await fingerprint(path);
339
+ const first = await sha256File(path);
340
+ const middle = await fingerprint(path);
341
+ if (!sameFingerprint(before, middle))
342
+ continue;
343
+ const second = await sha256File(path);
344
+ const after = await fingerprint(path);
345
+ if (!sameFingerprint(middle, after) || first !== second)
346
+ continue;
347
+ lastActual = second;
348
+ return { ok: true, actual: second };
349
+ }
350
+ catch {
351
+ return { ok: false, reason: "could not hash a stable regular file" };
352
+ }
353
+ }
354
+ return lastActual === ""
355
+ ? { ok: false, reason: "file changed during verification" }
356
+ : { ok: false, actual: lastActual, reason: "file changed during verification" };
357
+ }
358
+ /** Verify a downloaded file against an expected SHA-256 before trusting it. */
359
+ export async function verifyFileSha256(path, expected) {
360
+ if (!SHA256_RE.test(expected))
361
+ return { ok: false, reason: "invalid expected sha256" };
362
+ const result = await stableHash(path);
363
+ if (!result.ok)
364
+ return { ok: false, reason: result.reason ?? "could not hash file" };
365
+ return result.actual?.toLowerCase() === expected.toLowerCase()
366
+ ? { ok: true }
367
+ : { ok: false, reason: "sha256 mismatch" };
368
+ }
369
+ async function verifyMetadata(plan, paths) {
370
+ if (plan.kind === "agent")
371
+ return { ok: true };
372
+ const license = plan.license?.trim().toLowerCase();
373
+ if (license === undefined || license === "" || license === "unknown" || license === "noassertion") {
374
+ return { ok: false, code: "install.license-missing", reason: "license is missing or unknown" };
375
+ }
376
+ if (plan.licenseFile !== undefined) {
377
+ if (paths.licensePath === undefined || plan.licenseSha256 === undefined || !SHA256_RE.test(plan.licenseSha256)) {
378
+ return { ok: false, code: "install.license-evidence-missing", reason: "license evidence pin is missing" };
379
+ }
380
+ const licenseVerification = await stableHash(paths.licensePath);
381
+ if (!licenseVerification.ok || licenseVerification.actual?.toLowerCase() !== plan.licenseSha256.toLowerCase()) {
382
+ return { ok: false, code: "install.license-evidence-mismatch", reason: "license evidence hash mismatch" };
383
+ }
384
+ }
385
+ else if (plan.licenseSha256 !== undefined) {
386
+ return { ok: false, code: "install.license-evidence-missing", reason: "license evidence path is missing" };
387
+ }
388
+ if (plan.dependencies === undefined)
389
+ return { ok: false, code: "install.dependency-audit-missing", reason: "dependency audit is missing" };
390
+ for (const dependency of plan.dependencies) {
391
+ const version = dependency.version.trim().toLowerCase();
392
+ if (dependency.name.trim() === "" || version === "" || version === "latest" || version === "unknown" || version === "*" || version.startsWith("^") || version.startsWith("~")) {
393
+ return { ok: false, code: "install.dependency-unpinned", reason: "dependency version is not pinned" };
394
+ }
395
+ if (dependency.status === "missing" || dependency.status === "blocked")
396
+ return { ok: false, code: "install.dependency-blocked", reason: "dependency audit is blocked" };
397
+ const dependencyLicense = dependency.license?.trim().toLowerCase();
398
+ if (dependencyLicense === undefined || dependencyLicense === "" || dependencyLicense === "unknown" || dependencyLicense === "noassertion") {
399
+ return { ok: false, code: "install.dependency-license-missing", reason: "dependency license is missing" };
400
+ }
401
+ if (dependency.sha256 !== undefined && !SHA256_RE.test(dependency.sha256))
402
+ return { ok: false, code: "install.dependency-hash-invalid", reason: "dependency hash is malformed" };
403
+ }
404
+ if (plan.sbom === undefined || paths.sbomPath === undefined)
405
+ return { ok: false, code: "install.sbom-missing", reason: "CycloneDX SBOM is missing" };
406
+ if (plan.sbom.format !== "CycloneDX" || plan.sbom.specVersion !== "1.5" || !SHA256_RE.test(plan.sbom.sha256)) {
407
+ return { ok: false, code: "install.sbom-invalid", reason: "CycloneDX SBOM metadata is invalid" };
408
+ }
409
+ const sbomVerification = await stableHash(paths.sbomPath);
410
+ if (!sbomVerification.ok || sbomVerification.actual?.toLowerCase() !== plan.sbom.sha256.toLowerCase()) {
411
+ return { ok: false, code: "install.sbom-hash-mismatch", reason: "CycloneDX SBOM hash mismatch" };
412
+ }
413
+ try {
414
+ const parsed = JSON.parse(await readFile(paths.sbomPath, "utf8"));
415
+ if (parsed.bomFormat !== "CycloneDX" || parsed.specVersion !== "1.5" || !Array.isArray(parsed.components)) {
416
+ return { ok: false, code: "install.sbom-invalid", reason: "CycloneDX SBOM schema is invalid" };
417
+ }
418
+ const actualComponents = new Set();
419
+ for (const component of parsed.components) {
420
+ if (typeof component !== "object" || component === null)
421
+ return { ok: false, code: "install.sbom-invalid", reason: "CycloneDX SBOM component is invalid" };
422
+ const item = component;
423
+ if (typeof item.name !== "string" || typeof item.version !== "string")
424
+ return { ok: false, code: "install.sbom-invalid", reason: "CycloneDX SBOM component is invalid" };
425
+ actualComponents.add(`${item.name}\u0000${item.version}`);
426
+ }
427
+ for (const dependency of plan.dependencies) {
428
+ if (!actualComponents.has(`${dependency.name}\u0000${dependency.version}`))
429
+ return { ok: false, code: "install.sbom-incomplete", reason: "CycloneDX SBOM dependency set is incomplete" };
430
+ }
431
+ if (actualComponents.size !== plan.dependencies.length)
432
+ return { ok: false, code: "install.sbom-extra-components", reason: "CycloneDX SBOM contains unreviewed components" };
433
+ }
434
+ catch {
435
+ return { ok: false, code: "install.sbom-invalid", reason: "CycloneDX SBOM cannot be parsed" };
436
+ }
437
+ const stableAfterParse = await stableHash(paths.sbomPath);
438
+ if (!stableAfterParse.ok || stableAfterParse.actual?.toLowerCase() !== plan.sbom.sha256.toLowerCase()) {
439
+ return { ok: false, code: "install.sbom-hash-mismatch", reason: "CycloneDX SBOM changed during verification" };
440
+ }
441
+ return { ok: true, sbomSha256: sbomVerification.actual };
442
+ }
443
+ function recordResult(plan, recordedAt, status, toolReceipt, verified, reason, contentSha256, sbomSha256) {
444
+ return {
445
+ schemaVersion: "psyclaw/install-record/v1",
446
+ status,
447
+ plan,
448
+ receipt: toolReceipt,
449
+ verified,
450
+ ...(reason === undefined ? {} : { verificationReason: reason }),
451
+ recordedAt,
452
+ ...(contentSha256 === undefined ? {} : { contentSha256 }),
453
+ ...(sbomSha256 === undefined ? {} : { sbomSha256 }),
454
+ };
455
+ }
456
+ /**
457
+ * Execute an approved agent install through an injectable runner. Reservation
458
+ * happens before the runner starts; an unknown runner exception keeps the
459
+ * reservation so a retry cannot repeat an unobserved side effect.
460
+ */
461
+ export async function runInstall(plan, approval, runner, options = {}) {
462
+ const clock = options.now ?? (() => new Date().toISOString());
463
+ const startedAt = clock();
464
+ if (approval.approved !== true || approval.actor.trim() === "" || approval.reason.trim() === "") {
465
+ return deny(plan, startedAt, "install.approval-required", "Install requires explicit approval");
466
+ }
467
+ const basicFailure = sourceAndRefFailure(plan, false);
468
+ if (basicFailure !== undefined)
469
+ return deny(plan, startedAt, basicFailure.code, basicFailure.reason);
470
+ // A command that has no verifiable artifact pin is discover-only. This also
471
+ // prevents catalog entries from silently becoming "latest" global installs.
472
+ if (plan.expectedSha256 === undefined)
473
+ return deny(plan, startedAt, "install.missing-pin", "content pin is required before execution");
474
+ if (!SHA256_RE.test(plan.expectedSha256))
475
+ return deny(plan, startedAt, "install.malformed-pin", "content pin is malformed");
476
+ // An agent plan currently represents a global npm/pipx command, not a
477
+ // staged artifact whose bytes this module can verify. A caller-supplied
478
+ // 64-character string is therefore not enough evidence to execute it.
479
+ // Keep the catalog discover-only until an artifact-backed activation path is
480
+ // implemented; `installSkillPackage` is the verified staging path.
481
+ if (plan.kind === "agent") {
482
+ return deny(plan, startedAt, "install.agent-staging-required", "agent installs require a verified staged artifact");
483
+ }
484
+ if ((plan.kind === "skill" || plan.kind === "package") && (plan.stagingDir === undefined || plan.target === undefined)) {
485
+ return deny(plan, startedAt, "install.missing-target", "staging and target paths are required before execution");
486
+ }
487
+ const ledger = options.ledger ?? (plan.projectRoot === undefined ? undefined : new FileInstallLedger(plan.projectRoot));
488
+ const key = installKey(plan);
489
+ let reserved = false;
490
+ try {
491
+ reserved = await reserveLedger(ledger, key);
492
+ }
493
+ catch {
494
+ return fail(plan, startedAt, "install.ledger-error");
495
+ }
496
+ if (!reserved)
497
+ return already(plan, startedAt);
498
+ let exitCode;
499
+ try {
500
+ exitCode = (await runner(plan.command, {
501
+ plan,
502
+ ...(plan.stagingDir === undefined ? {} : { stagingDir: plan.stagingDir }),
503
+ ...(plan.target === undefined ? {} : { target: plan.target }),
504
+ })).exitCode;
505
+ }
506
+ catch {
507
+ // Keep the reservation: the command may have changed external state before throwing.
508
+ return fail(plan, startedAt, "install.runner-error", "runner threw", clock());
509
+ }
510
+ const finishedAt = clock();
511
+ if (!Number.isInteger(exitCode) || exitCode !== 0) {
512
+ await releaseLedger(ledger, key);
513
+ return fail(plan, startedAt, "install.failed", `exit ${String(exitCode)}`, finishedAt);
514
+ }
515
+ try {
516
+ await ledger?.record(key);
517
+ }
518
+ catch {
519
+ // Do not release a reservation after a successful external side effect.
520
+ return fail(plan, startedAt, "install.ledger-error", "install ledger write failed", finishedAt);
521
+ }
522
+ return receipt(plan, startedAt, {
523
+ approval: "approved",
524
+ ok: true,
525
+ resultHash: installResultHash(plan),
526
+ finishedAt,
527
+ });
528
+ }
529
+ async function pathExists(path) {
530
+ try {
531
+ const stat = await lstat(path);
532
+ return true && !stat.isSymbolicLink();
533
+ }
534
+ catch {
535
+ return false;
536
+ }
537
+ }
538
+ function blockedRecord(plan, at, code, reason) {
539
+ return recordResult(plan, at, "blocked", deny(plan, at, code, reason), false, reason);
540
+ }
541
+ async function legacyDownload(plan, approval, runner, downloadPath, options) {
542
+ const at = options.now();
543
+ if (approval.approved !== true)
544
+ return blockedRecord(plan, at, "install.approval-required", "not approved");
545
+ if (plan.expectedSha256 === undefined)
546
+ return blockedRecord(plan, at, "install.missing-pin", "no content pin provided");
547
+ if (!SHA256_RE.test(plan.expectedSha256))
548
+ return blockedRecord(plan, at, "install.malformed-pin", "content pin is malformed");
549
+ const sourceFailure = sourceAndRefFailure(plan, true);
550
+ if (sourceFailure !== undefined)
551
+ return blockedRecord(plan, at, sourceFailure.code, sourceFailure.reason);
552
+ try {
553
+ if (plan.projectRoot !== undefined) {
554
+ assertContained(plan.projectRoot, downloadPath, "download path");
555
+ }
556
+ else if (protectedPath(downloadPath)) {
557
+ return blockedRecord(plan, at, "install.path-invalid", "protected download path");
558
+ }
559
+ await assertNoSymlinkAncestors(downloadPath, "download path", plan.projectRoot === undefined ? dirname(resolve(downloadPath)) : resolve(plan.projectRoot));
560
+ }
561
+ catch {
562
+ return blockedRecord(plan, at, "install.path-invalid", "download path is unsafe");
563
+ }
564
+ const key = installKey(plan);
565
+ let reserved = false;
566
+ try {
567
+ reserved = await reserveLedger(options.ledger, key);
568
+ }
569
+ catch {
570
+ return blockedRecord(plan, at, "install.ledger-error", "install ledger unavailable");
571
+ }
572
+ if (!reserved)
573
+ return recordResult(plan, at, "already-recorded", already(plan, at), true, undefined, plan.expectedSha256);
574
+ const before = await verifyFileSha256(downloadPath, plan.expectedSha256);
575
+ if (!before.ok) {
576
+ await releaseLedger(options.ledger, key);
577
+ return blockedRecord(plan, at, "install.hash-mismatch", before.reason ?? "download hash mismatch");
578
+ }
579
+ try {
580
+ const outcome = await runner(plan.command, { plan });
581
+ if (!Number.isInteger(outcome.exitCode) || outcome.exitCode !== 0) {
582
+ await releaseLedger(options.ledger, key);
583
+ return recordResult(plan, at, "failed", fail(plan, at, "install.failed", `exit ${String(outcome.exitCode)}`, options.now()), false);
584
+ }
585
+ }
586
+ catch {
587
+ return recordResult(plan, at, "failed", fail(plan, at, "install.runner-error", "runner threw", options.now()), false);
588
+ }
589
+ const after = await verifyFileSha256(downloadPath, plan.expectedSha256);
590
+ if (!after.ok) {
591
+ await releaseLedger(options.ledger, key);
592
+ return blockedRecord(plan, at, "install.hash-mismatch", after.reason ?? "download changed during install");
593
+ }
594
+ try {
595
+ await options.ledger?.record(key);
596
+ }
597
+ catch {
598
+ return recordResult(plan, at, "failed", fail(plan, at, "install.ledger-error", "install ledger write failed", options.now()), false);
599
+ }
600
+ return recordResult(plan, at, "installed", receipt(plan, at, { approval: "approved", ok: true, resultHash: installResultHash(plan), finishedAt: options.now() }), true, undefined, plan.expectedSha256);
601
+ }
602
+ /**
603
+ * Download a skill/package into a fresh controlled staging directory, verify
604
+ * hash, license, exact dependencies and CycloneDX SBOM, then atomically
605
+ * activate it. Verification failures never activate or overwrite a target.
606
+ */
607
+ export async function installSkillPackage(plan, approval, runner, options) {
608
+ const clock = options.now ?? (() => new Date().toISOString());
609
+ // Kept solely for callers of the original pre-staging API. It never
610
+ // activates a target and still verifies the hash before and after the runner.
611
+ if (options.downloadPath !== undefined && (plan.stagingDir === undefined || plan.target === undefined)) {
612
+ return legacyDownload(plan, approval, runner, options.downloadPath, {
613
+ now: clock,
614
+ ...(options.ledger === undefined ? {} : { ledger: options.ledger }),
615
+ });
616
+ }
617
+ const at = clock();
618
+ if (approval.approved !== true || approval.actor.trim() === "" || approval.reason.trim() === "")
619
+ return blockedRecord(plan, at, "install.approval-required", "not approved");
620
+ if (plan.expectedSha256 === undefined)
621
+ return blockedRecord(plan, at, "install.missing-pin", "no content pin provided");
622
+ if (!SHA256_RE.test(plan.expectedSha256))
623
+ return blockedRecord(plan, at, "install.malformed-pin", "content pin is malformed");
624
+ const basicFailure = sourceAndRefFailure(plan, false);
625
+ if (basicFailure !== undefined)
626
+ return blockedRecord(plan, at, basicFailure.code, basicFailure.reason);
627
+ let paths;
628
+ try {
629
+ paths = await validateInstallPaths(plan);
630
+ }
631
+ catch {
632
+ return blockedRecord(plan, at, "install.path-invalid", "staging or target path is unsafe");
633
+ }
634
+ const ledger = options.ledger ?? (plan.projectRoot === undefined ? undefined : new FileInstallLedger(plan.projectRoot));
635
+ const key = installKey(plan);
636
+ let reserved = false;
637
+ try {
638
+ reserved = await reserveLedger(ledger, key);
639
+ }
640
+ catch {
641
+ return blockedRecord(plan, at, "install.ledger-error", "install ledger unavailable");
642
+ }
643
+ if (!reserved)
644
+ return recordResult(plan, at, "already-recorded", already(plan, at), true, undefined, plan.expectedSha256);
645
+ if (await pathExists(paths.staging)) {
646
+ await releaseLedger(ledger, key);
647
+ return blockedRecord(plan, at, "install.staging-exists", "staging directory already exists");
648
+ }
649
+ let stagingCreated = false;
650
+ try {
651
+ await mkdir(dirname(paths.staging), { recursive: true });
652
+ await mkdir(paths.staging, { recursive: false });
653
+ stagingCreated = true;
654
+ }
655
+ catch {
656
+ await releaseLedger(ledger, key);
657
+ return blockedRecord(plan, at, "install.staging-create-failed", "could not create a fresh staging directory");
658
+ }
659
+ const cleanup = async () => {
660
+ if (stagingCreated)
661
+ await rm(paths.staging, { recursive: true, force: true }).catch(() => undefined);
662
+ };
663
+ let exitCode;
664
+ try {
665
+ exitCode = (await runner(plan.command, { plan, stagingDir: paths.staging, target: paths.target })).exitCode;
666
+ }
667
+ catch {
668
+ await cleanup();
669
+ // Unknown side effects: retain reservation and fail closed.
670
+ return recordResult(plan, at, "failed", fail(plan, at, "install.runner-error", "runner threw", clock()), false);
671
+ }
672
+ if (!Number.isInteger(exitCode) || exitCode !== 0) {
673
+ await cleanup();
674
+ await releaseLedger(ledger, key);
675
+ return recordResult(plan, at, "failed", fail(plan, at, "install.failed", `exit ${String(exitCode)}`, clock()), false);
676
+ }
677
+ const contentVerification = await verifyFileSha256(paths.contentPath, plan.expectedSha256);
678
+ if (!contentVerification.ok) {
679
+ await cleanup();
680
+ await releaseLedger(ledger, key);
681
+ return blockedRecord(plan, at, "install.hash-mismatch", contentVerification.reason ?? "content hash mismatch");
682
+ }
683
+ const metadata = await verifyMetadata(plan, paths);
684
+ if (!metadata.ok) {
685
+ await cleanup();
686
+ await releaseLedger(ledger, key);
687
+ return blockedRecord(plan, at, metadata.code ?? "install.preflight-failed", metadata.reason ?? "install preflight failed");
688
+ }
689
+ try {
690
+ const targetStat = await lstat(paths.target);
691
+ if (targetStat.isSymbolicLink()) {
692
+ await cleanup();
693
+ await releaseLedger(ledger, key);
694
+ return blockedRecord(plan, at, "install.target-symlink", "target symlink is not writable");
695
+ }
696
+ if (targetStat.isDirectory()) {
697
+ const existing = await verifyFileSha256(join(paths.target, plan.contentFile ?? "SKILL.md"), plan.expectedSha256);
698
+ await cleanup();
699
+ if (existing.ok) {
700
+ try {
701
+ await ledger?.record(key);
702
+ }
703
+ catch {
704
+ return recordResult(plan, at, "failed", fail(plan, at, "install.ledger-error", "install ledger write failed", clock()), false);
705
+ }
706
+ return recordResult(plan, at, "already-recorded", already(plan, at), true, undefined, plan.expectedSha256, metadata.sbomSha256);
707
+ }
708
+ await releaseLedger(ledger, key);
709
+ return blockedRecord(plan, at, "install.target-conflict", "target exists with different content");
710
+ }
711
+ await cleanup();
712
+ await releaseLedger(ledger, key);
713
+ return blockedRecord(plan, at, "install.target-conflict", "target exists and is not a directory");
714
+ }
715
+ catch (error) {
716
+ if (error.code !== "ENOENT") {
717
+ await cleanup();
718
+ await releaseLedger(ledger, key);
719
+ return blockedRecord(plan, at, "install.target-conflict", "target became unavailable");
720
+ }
721
+ }
722
+ try {
723
+ await mkdir(dirname(paths.target), { recursive: true });
724
+ await rename(paths.staging, paths.target);
725
+ stagingCreated = false;
726
+ }
727
+ catch (error) {
728
+ await cleanup();
729
+ await releaseLedger(ledger, key);
730
+ const code = error.code === "EEXIST" ? "install.target-conflict" : "install.activate-failed";
731
+ return blockedRecord(plan, at, code, "could not atomically activate staged install");
732
+ }
733
+ try {
734
+ await ledger?.record(key);
735
+ }
736
+ catch {
737
+ // Leave the activated result and reservation in place; retry must report
738
+ // already-recorded rather than execute another external command.
739
+ return recordResult(plan, at, "failed", fail(plan, at, "install.ledger-error", "install ledger write failed", clock()), false);
740
+ }
741
+ return recordResult(plan, at, "installed", receipt(plan, at, { approval: "approved", ok: true, resultHash: installResultHash(plan, plan.expectedSha256), finishedAt: clock() }), true, undefined, plan.expectedSha256, metadata.sbomSha256);
742
+ }
743
+ //# sourceMappingURL=installer.js.map