@clossys/launcher 0.3.0 → 0.4.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 (286) hide show
  1. package/README.md +1328 -60
  2. package/contracts/conversation-contract.md +2 -1
  3. package/contracts/product-ci-workflow.yml +74 -0
  4. package/contracts/repository-inventory.json +53 -0
  5. package/dist/admission-fixture.d.ts +168 -0
  6. package/dist/admission-fixture.d.ts.map +1 -0
  7. package/dist/admission-fixture.js +453 -0
  8. package/dist/admission-fixture.js.map +1 -0
  9. package/dist/admission.d.ts +124 -0
  10. package/dist/admission.d.ts.map +1 -0
  11. package/dist/admission.js +799 -0
  12. package/dist/admission.js.map +1 -0
  13. package/dist/agents-guide.d.ts +9 -0
  14. package/dist/agents-guide.d.ts.map +1 -0
  15. package/dist/agents-guide.js +26 -0
  16. package/dist/agents-guide.js.map +1 -0
  17. package/dist/apply-command-options.check.d.ts +12 -0
  18. package/dist/apply-command-options.check.d.ts.map +1 -0
  19. package/dist/apply-command-options.check.js +20 -0
  20. package/dist/apply-command-options.check.js.map +1 -0
  21. package/dist/apply-plan-cli.d.ts +39 -1
  22. package/dist/apply-plan-cli.d.ts.map +1 -1
  23. package/dist/apply-plan-cli.js +432 -15
  24. package/dist/apply-plan-cli.js.map +1 -1
  25. package/dist/apply-plan.d.ts +46 -59
  26. package/dist/apply-plan.d.ts.map +1 -1
  27. package/dist/apply-plan.js +112 -97
  28. package/dist/apply-plan.js.map +1 -1
  29. package/dist/apply-step-fixture.d.ts +87 -0
  30. package/dist/apply-step-fixture.d.ts.map +1 -0
  31. package/dist/apply-step-fixture.js +199 -0
  32. package/dist/apply-step-fixture.js.map +1 -0
  33. package/dist/apply-store.d.ts +93 -0
  34. package/dist/apply-store.d.ts.map +1 -0
  35. package/dist/apply-store.js +625 -0
  36. package/dist/apply-store.js.map +1 -0
  37. package/dist/approval-sheet.d.ts +21 -0
  38. package/dist/approval-sheet.d.ts.map +1 -0
  39. package/dist/approval-sheet.js +157 -0
  40. package/dist/approval-sheet.js.map +1 -0
  41. package/dist/body-command.d.ts +42 -0
  42. package/dist/body-command.d.ts.map +1 -0
  43. package/dist/body-command.js +143 -0
  44. package/dist/body-command.js.map +1 -0
  45. package/dist/change-set-contract.d.ts +381 -0
  46. package/dist/change-set-contract.d.ts.map +1 -0
  47. package/dist/change-set-contract.js +738 -0
  48. package/dist/change-set-contract.js.map +1 -0
  49. package/dist/change-set-digest.d.ts +28 -0
  50. package/dist/change-set-digest.d.ts.map +1 -0
  51. package/dist/change-set-digest.js +65 -0
  52. package/dist/change-set-digest.js.map +1 -0
  53. package/dist/check-cli.d.ts.map +1 -1
  54. package/dist/check-cli.js +14 -3
  55. package/dist/check-cli.js.map +1 -1
  56. package/dist/cli.d.ts +17 -6
  57. package/dist/cli.d.ts.map +1 -1
  58. package/dist/cli.js +84 -23
  59. package/dist/cli.js.map +1 -1
  60. package/dist/core.d.ts +79 -22
  61. package/dist/core.d.ts.map +1 -1
  62. package/dist/core.js +843 -268
  63. package/dist/core.js.map +1 -1
  64. package/dist/dry-materialize.d.ts +63 -0
  65. package/dist/dry-materialize.d.ts.map +1 -0
  66. package/dist/dry-materialize.js +330 -0
  67. package/dist/dry-materialize.js.map +1 -0
  68. package/dist/generated/contract-schema.generated.d.ts +97 -0
  69. package/dist/generated/contract-schema.generated.d.ts.map +1 -0
  70. package/dist/generated/contract-schema.generated.js +496 -0
  71. package/dist/generated/contract-schema.generated.js.map +1 -0
  72. package/dist/generated/package-scope.generated.d.ts +6 -0
  73. package/dist/generated/package-scope.generated.d.ts.map +1 -0
  74. package/dist/generated/package-scope.generated.js +10 -0
  75. package/dist/generated/package-scope.generated.js.map +1 -0
  76. package/dist/generated/plan-contracts.generated.d.ts +3 -0
  77. package/dist/generated/plan-contracts.generated.d.ts.map +1 -0
  78. package/dist/generated/plan-contracts.generated.js +2840 -0
  79. package/dist/generated/plan-contracts.generated.js.map +1 -0
  80. package/dist/host.d.ts.map +1 -1
  81. package/dist/host.js +11 -0
  82. package/dist/host.js.map +1 -1
  83. package/dist/identity.d.ts +15 -0
  84. package/dist/identity.d.ts.map +1 -0
  85. package/dist/identity.js +48 -0
  86. package/dist/identity.js.map +1 -0
  87. package/dist/index.d.ts +34 -5
  88. package/dist/index.d.ts.map +1 -1
  89. package/dist/index.js +19 -2
  90. package/dist/index.js.map +1 -1
  91. package/dist/inventory-adoption.d.ts +24 -5
  92. package/dist/inventory-adoption.d.ts.map +1 -1
  93. package/dist/inventory-adoption.js +70 -25
  94. package/dist/inventory-adoption.js.map +1 -1
  95. package/dist/inventory-choice.d.ts +40 -0
  96. package/dist/inventory-choice.d.ts.map +1 -0
  97. package/dist/inventory-choice.js +156 -0
  98. package/dist/inventory-choice.js.map +1 -0
  99. package/dist/inventory-contract.d.ts +89 -0
  100. package/dist/inventory-contract.d.ts.map +1 -0
  101. package/dist/inventory-contract.js +121 -0
  102. package/dist/inventory-contract.js.map +1 -0
  103. package/dist/key-editor.d.ts +30 -0
  104. package/dist/key-editor.d.ts.map +1 -0
  105. package/dist/key-editor.js +445 -0
  106. package/dist/key-editor.js.map +1 -0
  107. package/dist/ledger-contract.d.ts +187 -0
  108. package/dist/ledger-contract.d.ts.map +1 -0
  109. package/dist/ledger-contract.js +532 -0
  110. package/dist/ledger-contract.js.map +1 -0
  111. package/dist/ledger-trust.d.ts +90 -0
  112. package/dist/ledger-trust.d.ts.map +1 -0
  113. package/dist/ledger-trust.js +198 -0
  114. package/dist/ledger-trust.js.map +1 -0
  115. package/dist/lockfile-invariants.d.ts +48 -0
  116. package/dist/lockfile-invariants.d.ts.map +1 -0
  117. package/dist/lockfile-invariants.js +375 -0
  118. package/dist/lockfile-invariants.js.map +1 -0
  119. package/dist/lockfile-readers.d.ts +72 -0
  120. package/dist/lockfile-readers.d.ts.map +1 -0
  121. package/dist/lockfile-readers.js +713 -0
  122. package/dist/lockfile-readers.js.map +1 -0
  123. package/dist/lockfile-regen.d.ts +106 -0
  124. package/dist/lockfile-regen.d.ts.map +1 -0
  125. package/dist/lockfile-regen.js +760 -0
  126. package/dist/lockfile-regen.js.map +1 -0
  127. package/dist/lockfile-tool-env.d.ts +29 -0
  128. package/dist/lockfile-tool-env.d.ts.map +1 -0
  129. package/dist/lockfile-tool-env.js +111 -0
  130. package/dist/lockfile-tool-env.js.map +1 -0
  131. package/dist/materialize.d.ts +113 -0
  132. package/dist/materialize.d.ts.map +1 -0
  133. package/dist/materialize.js +840 -0
  134. package/dist/materialize.js.map +1 -0
  135. package/dist/observe-repository.d.ts +90 -0
  136. package/dist/observe-repository.d.ts.map +1 -0
  137. package/dist/observe-repository.js +1367 -0
  138. package/dist/observe-repository.js.map +1 -0
  139. package/dist/plan-bundle-setup-fixture.d.ts +68 -0
  140. package/dist/plan-bundle-setup-fixture.d.ts.map +1 -0
  141. package/dist/plan-bundle-setup-fixture.js +167 -0
  142. package/dist/plan-bundle-setup-fixture.js.map +1 -0
  143. package/dist/plan-bundle.d.ts +250 -0
  144. package/dist/plan-bundle.d.ts.map +1 -0
  145. package/dist/plan-bundle.js +827 -0
  146. package/dist/plan-bundle.js.map +1 -0
  147. package/dist/plan-command.d.ts +29 -0
  148. package/dist/plan-command.d.ts.map +1 -0
  149. package/dist/plan-command.js +493 -0
  150. package/dist/plan-command.js.map +1 -0
  151. package/dist/plan-contract.d.ts +153 -0
  152. package/dist/plan-contract.d.ts.map +1 -0
  153. package/dist/plan-contract.js +61 -0
  154. package/dist/plan-contract.js.map +1 -0
  155. package/dist/plan-digest.d.ts +25 -0
  156. package/dist/plan-digest.d.ts.map +1 -0
  157. package/dist/plan-digest.js +106 -0
  158. package/dist/plan-digest.js.map +1 -0
  159. package/dist/plan-rules.d.ts +23 -0
  160. package/dist/plan-rules.d.ts.map +1 -0
  161. package/dist/plan-rules.js +177 -0
  162. package/dist/plan-rules.js.map +1 -0
  163. package/dist/planned-bundle.d.ts +20 -0
  164. package/dist/planned-bundle.d.ts.map +1 -0
  165. package/dist/planned-bundle.js +191 -0
  166. package/dist/planned-bundle.js.map +1 -0
  167. package/dist/product-repository.d.ts +4 -0
  168. package/dist/product-repository.d.ts.map +1 -1
  169. package/dist/product-repository.js +9 -1
  170. package/dist/product-repository.js.map +1 -1
  171. package/dist/provenance-gate.d.ts +48 -0
  172. package/dist/provenance-gate.d.ts.map +1 -0
  173. package/dist/provenance-gate.js +324 -0
  174. package/dist/provenance-gate.js.map +1 -0
  175. package/dist/pull-request-body.d.ts +45 -0
  176. package/dist/pull-request-body.d.ts.map +1 -0
  177. package/dist/pull-request-body.js +232 -0
  178. package/dist/pull-request-body.js.map +1 -0
  179. package/dist/registry-snapshot.d.ts +141 -0
  180. package/dist/registry-snapshot.d.ts.map +1 -0
  181. package/dist/registry-snapshot.js +483 -0
  182. package/dist/registry-snapshot.js.map +1 -0
  183. package/dist/release-age-edit.d.ts +52 -0
  184. package/dist/release-age-edit.d.ts.map +1 -0
  185. package/dist/release-age-edit.js +413 -0
  186. package/dist/release-age-edit.js.map +1 -0
  187. package/dist/root-entries.d.ts +36 -0
  188. package/dist/root-entries.d.ts.map +1 -0
  189. package/dist/root-entries.js +80 -0
  190. package/dist/root-entries.js.map +1 -0
  191. package/dist/setup-template-scripts.d.ts +34 -0
  192. package/dist/setup-template-scripts.d.ts.map +1 -0
  193. package/dist/setup-template-scripts.js +557 -0
  194. package/dist/setup-template-scripts.js.map +1 -0
  195. package/dist/setup-templates.d.ts +54 -0
  196. package/dist/setup-templates.d.ts.map +1 -0
  197. package/dist/setup-templates.js +427 -0
  198. package/dist/setup-templates.js.map +1 -0
  199. package/dist/skills.d.ts +34 -1
  200. package/dist/skills.d.ts.map +1 -1
  201. package/dist/skills.js +129 -17
  202. package/dist/skills.js.map +1 -1
  203. package/dist/status.d.ts +63 -0
  204. package/dist/status.d.ts.map +1 -0
  205. package/dist/status.js +539 -0
  206. package/dist/status.js.map +1 -0
  207. package/dist/types.d.ts +151 -13
  208. package/dist/types.d.ts.map +1 -1
  209. package/package.json +4 -5
  210. package/skeleton/README.md +14 -9
  211. package/skeleton/package.json +2 -1
  212. package/skill/SKILL.md +20 -16
  213. package/skill-catalogue/advisor/SKILL.md +100 -11
  214. package/skill-catalogue/architect/SKILL.md +2 -13
  215. package/skill-catalogue/bouncer/SKILL.md +2 -13
  216. package/skill-catalogue/builder/SKILL.md +2 -13
  217. package/skill-catalogue/butler/SKILL.md +2 -13
  218. package/skill-catalogue/controller/SKILL.md +2 -13
  219. package/skill-catalogue/customer/SKILL.md +4 -13
  220. package/skill-catalogue/designer/SKILL.md +9 -14
  221. package/skill-catalogue/giver/SKILL.md +2 -13
  222. package/skill-catalogue/influencer/SKILL.md +2 -13
  223. package/skill-catalogue/inspector/SKILL.md +2 -13
  224. package/skill-catalogue/integrator/SKILL.md +2 -13
  225. package/skill-catalogue/keeper/SKILL.md +2 -13
  226. package/skill-catalogue/launcher/SKILL.md +20 -16
  227. package/skill-catalogue/locksmith/SKILL.md +2 -13
  228. package/skill-catalogue/messenger/SKILL.md +2 -13
  229. package/skill-catalogue/observer/SKILL.md +2 -13
  230. package/skill-catalogue/publisher/SKILL.md +11 -17
  231. package/skill-catalogue/starter/SKILL.md +3 -13
  232. package/skill-catalogue/strategist/SKILL.md +14 -17
  233. package/skill-catalogue/writer/SKILL.md +7 -14
  234. package/src/admission-fixture.ts +572 -0
  235. package/src/admission.ts +816 -0
  236. package/src/agents-guide.ts +29 -0
  237. package/src/apply-command-options.check.ts +27 -0
  238. package/src/apply-plan-cli.ts +454 -14
  239. package/src/apply-plan.ts +112 -124
  240. package/src/apply-step-fixture.ts +236 -0
  241. package/src/apply-store.ts +584 -0
  242. package/src/approval-sheet.ts +164 -0
  243. package/src/body-command.ts +162 -0
  244. package/src/change-set-contract.ts +937 -0
  245. package/src/change-set-digest.ts +70 -0
  246. package/src/check-cli.ts +14 -3
  247. package/src/cli.ts +90 -22
  248. package/src/core.ts +973 -275
  249. package/src/dry-materialize.ts +353 -0
  250. package/src/generated/contract-schema.generated.ts +520 -0
  251. package/src/generated/package-scope.generated.ts +10 -0
  252. package/src/generated/plan-contracts.generated.ts +2840 -0
  253. package/src/host.ts +10 -0
  254. package/src/identity.ts +51 -0
  255. package/src/index.ts +72 -3
  256. package/src/inventory-adoption.ts +107 -29
  257. package/src/inventory-choice.ts +172 -0
  258. package/src/inventory-contract.ts +166 -0
  259. package/src/key-editor.ts +446 -0
  260. package/src/ledger-contract.ts +637 -0
  261. package/src/ledger-trust.ts +267 -0
  262. package/src/lockfile-invariants.ts +421 -0
  263. package/src/lockfile-readers.ts +749 -0
  264. package/src/lockfile-regen.ts +851 -0
  265. package/src/lockfile-tool-env.ts +131 -0
  266. package/src/materialize.ts +886 -0
  267. package/src/observe-repository.ts +1365 -0
  268. package/src/plan-bundle-setup-fixture.ts +200 -0
  269. package/src/plan-bundle.ts +964 -0
  270. package/src/plan-command.ts +509 -0
  271. package/src/plan-contract.ts +179 -0
  272. package/src/plan-digest.ts +102 -0
  273. package/src/plan-rules.ts +188 -0
  274. package/src/planned-bundle.ts +211 -0
  275. package/src/product-repository.ts +10 -1
  276. package/src/provenance-gate.ts +352 -0
  277. package/src/pull-request-body.ts +261 -0
  278. package/src/registry-snapshot.ts +534 -0
  279. package/src/release-age-edit.ts +430 -0
  280. package/src/root-entries.ts +81 -0
  281. package/src/setup-template-scripts.ts +571 -0
  282. package/src/setup-templates.ts +471 -0
  283. package/src/skills.ts +161 -18
  284. package/src/status.ts +557 -0
  285. package/src/types.ts +148 -13
  286. package/CHANGELOG.md +0 -131
@@ -0,0 +1,886 @@
1
+ // Write an approved repository change set into a clean local clone and verify
2
+ // the result (RFC apply-approved-plan §7 V4 and V8, migration step 3).
3
+
4
+ import { spawnSync } from "node:child_process";
5
+ import { createHash } from "node:crypto";
6
+ import {
7
+ chmodSync,
8
+ existsSync,
9
+ lstatSync,
10
+ mkdirSync,
11
+ readFileSync,
12
+ readlinkSync,
13
+ realpathSync,
14
+ symlinkSync,
15
+ unlinkSync,
16
+ writeFileSync,
17
+ } from "node:fs";
18
+ import { dirname, join } from "node:path";
19
+ import { decideSetBinding, planPackagesFor, readHubAuthority } from "./admission.js";
20
+ import { AGENTS_GUIDE_PATH, verifyAgentsGuide } from "./agents-guide.js";
21
+ import type { AdmissionRefusal, PlanPackageIdentity, ReadinessRunner } from "./admission.js";
22
+ import { storeChangeSet } from "./apply-store.js";
23
+ import {
24
+ CANONICAL_KEYS,
25
+ LEDGER_PATH,
26
+ canonicalOrder,
27
+ contentDigest,
28
+ discoveryLinkRole,
29
+ discoveryLinkTarget,
30
+ lockfilePath,
31
+ matchesPathPattern,
32
+ validateRepositoryChangeSet,
33
+ } from "./change-set-contract.js";
34
+ import type { ApprovalBinding, FileChange, PackageInvariant, RepositoryChangeSet, WholeFileChange } from "./change-set-contract.js";
35
+ import { JsonEditUnstableError, editJsonPointer, valueAtJsonPointer } from "./key-editor.js";
36
+ import { renderInstalledLedger } from "./ledger-contract.js";
37
+ import type { InstalledLedger } from "./ledger-contract.js";
38
+ import { trustInstalledLedger } from "./ledger-trust.js";
39
+ import { checkLockfileInvariants } from "./lockfile-invariants.js";
40
+ import type { LockfileInvariantPackage } from "./lockfile-invariants.js";
41
+ import { regenerateLockfile } from "./lockfile-regen.js";
42
+ import type { LockfileSpawn } from "./lockfile-regen.js";
43
+ import { verifyReleaseAgeExemption } from "./release-age-edit.js";
44
+
45
+ // The approval binding a set's ledger records is never an input: admission
46
+ // (admission.ts) computes it from the hub's committed plan and stored bundles,
47
+ // and materialize and verify both use exactly that binding (#1178).
48
+ export interface MaterializeInput {
49
+ readonly clone: string;
50
+ readonly hub: string;
51
+ readonly set: RepositoryChangeSet;
52
+ readonly texts: Readonly<Record<string, string>>;
53
+ readonly heldChangeSets?: readonly RepositoryChangeSet[];
54
+ readonly spawn?: LockfileSpawn;
55
+ /** The instant the execution authorization is judged at; the wall clock by default. */
56
+ readonly now?: () => Date;
57
+ readonly toolVersion?: string | null;
58
+ /** Runs the hub's advisor-execution-readiness; the hub's own installed executable by default. */
59
+ readonly runReadiness?: ReadinessRunner;
60
+ }
61
+
62
+ export interface VerifyInput {
63
+ readonly clone: string;
64
+ readonly hub: string;
65
+ readonly set: RepositoryChangeSet;
66
+ readonly heldChangeSets?: readonly RepositoryChangeSet[];
67
+ readonly now?: () => Date;
68
+ readonly runReadiness?: ReadinessRunner;
69
+ }
70
+
71
+ export interface ApplyStepResult {
72
+ readonly exitCode: 0 | 1 | 2;
73
+ readonly verdict: "materialized" | "violated" | "indeterminate";
74
+ readonly reason?: string;
75
+ readonly detail?: string;
76
+ }
77
+
78
+ const BRANCH_SHAPE = /^clossys\/apply-[0-9a-f]{12}$/u;
79
+ const DEFAULT_BRANCH_SHAPE = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/u;
80
+ const COMMIT_SHAPE = /^[0-9a-f]{40}$/u;
81
+ const TOOL_VERSION_SHAPE = /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-[0-9A-Za-z.-]+)?$/u;
82
+ const LEFTOVER_SKILL = /^\.agents\/skills\/clossys-[^/]+(?:\/.*)?$/u;
83
+ const RESERVED_ROOTS = new Set(["clossys", ".github", ".starter"]);
84
+
85
+ const isWhole = (file: FileChange): file is WholeFileChange => !("derived" in file);
86
+
87
+ function result(exitCode: 0 | 1 | 2, verdict: ApplyStepResult["verdict"], reason?: string, detail?: string): ApplyStepResult {
88
+ return detail === undefined ? { exitCode, verdict, reason } : { exitCode, verdict, reason, detail };
89
+ }
90
+
91
+ /**
92
+ * The time limit `status` puts on the git calls of the preconditions it shares with verify, through this variable; unset (verify's
93
+ * own case) there is no limit. A call that runs out of time has no status, which is read as a failure.
94
+ */
95
+ export const GIT_TIMEOUT_ENV = "CLOSSYS_LAUNCHER_GIT_TIMEOUT_MS";
96
+ function gitTimeout(): number | undefined {
97
+ const limit = Number(process.env[GIT_TIMEOUT_ENV]);
98
+ return Number.isSafeInteger(limit) && limit > 0 ? limit : undefined;
99
+ }
100
+
101
+ function git(root: string, args: readonly string[]): { status: number; stdout: string } {
102
+ const run = spawnSync("git", ["-c", "commit.gpgsign=false", "-c", "core.hooksPath=/dev/null", ...args], {
103
+ cwd: root,
104
+ encoding: "utf8",
105
+ stdio: ["ignore", "pipe", "ignore"],
106
+ timeout: gitTimeout(),
107
+ killSignal: "SIGKILL",
108
+ });
109
+ return { status: run.status ?? 1, stdout: run.stdout ?? "" };
110
+ }
111
+
112
+ function safeDefaultBranch(branch: string): boolean {
113
+ if (branch.includes(":") || branch.includes("+") || branch.includes("*")) return false;
114
+ return DEFAULT_BRANCH_SHAPE.test(branch);
115
+ }
116
+
117
+ function refusalResult(refusal: AdmissionRefusal): ApplyStepResult {
118
+ return result(refusal.exitCode, refusal.exitCode === 1 ? "violated" : "indeterminate", refusal.reason, refusal.detail);
119
+ }
120
+
121
+ function digestAtPath(root: string, relPath: string): string | null {
122
+ const path = join(root, relPath);
123
+ try {
124
+ const stat = lstatSync(path);
125
+ if (stat.isSymbolicLink()) return contentDigest(readlinkSync(path));
126
+ if (stat.isFile()) return contentDigest(readFileSync(path, "utf8"));
127
+ return null;
128
+ } catch (cause) {
129
+ if ((cause as NodeJS.ErrnoException).code === "ENOENT") return null;
130
+ throw cause;
131
+ }
132
+ }
133
+
134
+ function gitShowUtf8(root: string, ref: string, path: string): string | null {
135
+ const { status, stdout } = git(root, ["show", `${ref}:${path}`]);
136
+ if (status !== 0) return null;
137
+ return stdout;
138
+ }
139
+
140
+ function gitShowBytes(root: string, ref: string, path: string): Uint8Array | null {
141
+ const run = spawnSync("git", ["-c", "commit.gpgsign=false", "-c", "core.hooksPath=/dev/null", "show", `${ref}:${path}`], {
142
+ cwd: root,
143
+ stdio: ["ignore", "pipe", "ignore"],
144
+ timeout: gitTimeout(),
145
+ killSignal: "SIGKILL",
146
+ });
147
+ if ((run.status ?? 1) !== 0) return null;
148
+ return run.stdout ?? null;
149
+ }
150
+
151
+ function declaredPaths(set: RepositoryChangeSet): Set<string> {
152
+ const paths = new Set(set.files.map((file) => file.path));
153
+ if (set.keys.length > 0) paths.add("package.json");
154
+ return paths;
155
+ }
156
+
157
+ function writePaths(set: RepositoryChangeSet): string[] {
158
+ const paths = set.files.filter(isWhole).filter((file) => file.before !== file.after).map((file) => file.path);
159
+ if (set.keys.length > 0) paths.push("package.json");
160
+ paths.push(LEDGER_PATH);
161
+ return paths;
162
+ }
163
+
164
+ function hasSymlinkAncestor(root: string, relPath: string): boolean {
165
+ const parts = relPath.split("/");
166
+ if (parts.length <= 1) return false;
167
+ let current = root;
168
+ for (let index = 0; index < parts.length - 1; index += 1) {
169
+ current = join(current, parts[index]!);
170
+ try {
171
+ if (lstatSync(current).isSymbolicLink()) return true;
172
+ } catch {
173
+ return false;
174
+ }
175
+ }
176
+ return false;
177
+ }
178
+
179
+ function ensureParentDir(root: string, relPath: string): ApplyStepResult | null {
180
+ const parent = dirname(relPath);
181
+ if (parent === ".") return null;
182
+ const segments = parent.split("/");
183
+ let current = root;
184
+ for (const segment of segments) {
185
+ current = join(current, segment);
186
+ try {
187
+ const stat = lstatSync(current);
188
+ if (stat.isSymbolicLink()) return result(2, "indeterminate", "symlink-ancestor");
189
+ if (!stat.isDirectory()) return result(2, "indeterminate", "symlink-ancestor");
190
+ } catch (cause) {
191
+ if ((cause as NodeJS.ErrnoException).code !== "ENOENT") throw cause;
192
+ mkdirSync(current);
193
+ }
194
+ }
195
+ return null;
196
+ }
197
+
198
+ export function writeRegularFile(root: string, relPath: string, text: string, after: string): ApplyStepResult | null {
199
+ const parent = ensureParentDir(root, relPath);
200
+ if (parent !== null) return parent;
201
+ const path = join(root, relPath);
202
+ try {
203
+ if (lstatSync(path).isSymbolicLink()) return result(2, "indeterminate", "symlink-ancestor");
204
+ } catch (cause) {
205
+ if ((cause as NodeJS.ErrnoException).code !== "ENOENT") throw cause;
206
+ }
207
+ writeFileSync(path, text, "utf8");
208
+ try {
209
+ const mode = lstatSync(path).mode & 0o777;
210
+ if ((mode & 0o111) !== 0) chmodSync(path, 0o644);
211
+ } catch {
212
+ // Best effort.
213
+ }
214
+ const readBack = readFileSync(path, "utf8");
215
+ if (readBack !== text) return result(1, "violated", "content-mismatch");
216
+ if (contentDigest(readBack) !== after) return result(1, "violated", "content-mismatch");
217
+ return null;
218
+ }
219
+
220
+ export function writeSymlink(root: string, relPath: string, target: string, after: string): ApplyStepResult | null {
221
+ const parent = ensureParentDir(root, relPath);
222
+ if (parent !== null) return parent;
223
+ const path = join(root, relPath);
224
+ try {
225
+ if (lstatSync(path).isSymbolicLink()) unlinkSync(path);
226
+ else if (existsSync(path)) unlinkSync(path);
227
+ } catch (cause) {
228
+ if ((cause as NodeJS.ErrnoException).code !== "ENOENT") throw cause;
229
+ }
230
+ symlinkSync(target, path);
231
+ if (contentDigest(readlinkSync(path)) !== after) return result(1, "violated", "content-mismatch");
232
+ return null;
233
+ }
234
+
235
+ export function removePath(root: string, relPath: string): ApplyStepResult | null {
236
+ if (hasSymlinkAncestor(root, relPath)) return result(2, "indeterminate", "symlink-ancestor");
237
+ const path = join(root, relPath);
238
+ try {
239
+ unlinkSync(path);
240
+ } catch (cause) {
241
+ if ((cause as NodeJS.ErrnoException).code === "ENOENT") return null;
242
+ throw cause;
243
+ }
244
+ if (existsSync(path)) return result(1, "violated", "removal-present");
245
+ return null;
246
+ }
247
+
248
+ export function resolveCloneRoot(clone: string): { root: string } | ApplyStepResult {
249
+ try {
250
+ const stat = lstatSync(clone);
251
+ if (!stat.isDirectory() || stat.isSymbolicLink()) return result(2, "indeterminate", "missing-clone");
252
+ } catch {
253
+ return result(2, "indeterminate", "missing-clone");
254
+ }
255
+ let root: string;
256
+ try {
257
+ root = realpathSync(clone);
258
+ } catch {
259
+ return result(2, "indeterminate", "missing-clone");
260
+ }
261
+ if (root !== clone) return result(2, "indeterminate", "missing-clone");
262
+ return { root };
263
+ }
264
+
265
+ function checkSymlinksSupported(root: string): ApplyStepResult | null {
266
+ const { status, stdout } = git(root, ["config", "--get", "core.symlinks"]);
267
+ if (status !== 0) return null;
268
+ if (stdout.trim() === "false") return result(2, "indeterminate", "symlinks-unsupported");
269
+ return null;
270
+ }
271
+
272
+ function checkRemoteTip(root: string, set: RepositoryChangeSet): ApplyStepResult | null {
273
+ const branch = set.repository.defaultBranch;
274
+ if (!safeDefaultBranch(branch)) return result(2, "indeterminate", "change-set-invalid");
275
+ const fetched = git(root, ["fetch", "--no-tags", "--refmap=", "origin", `+refs/heads/${branch}:refs/remotes/origin/${branch}`]);
276
+ if (fetched.status !== 0) return result(2, "indeterminate", "remote-tip-unreadable");
277
+ const local = git(root, ["rev-parse", `refs/heads/${branch}`]);
278
+ const remote = git(root, ["rev-parse", `refs/remotes/origin/${branch}`]);
279
+ if (local.status !== 0 || remote.status !== 0) return result(2, "indeterminate", "remote-tip-unreadable");
280
+ if (local.stdout.trim() !== remote.stdout.trim()) return result(1, "violated", "remote-tip-mismatch");
281
+ return null;
282
+ }
283
+
284
+ function digestAtRef(root: string, ref: string, relPath: string, mode: WholeFileChange["mode"]): string | null {
285
+ if (mode === "120000") {
286
+ const target = gitShowUtf8(root, ref, relPath);
287
+ if (target === null) return null;
288
+ return contentDigest(target);
289
+ }
290
+ const text = gitShowUtf8(root, ref, relPath);
291
+ if (text === null) return null;
292
+ return contentDigest(text);
293
+ }
294
+
295
+ /**
296
+ * Whether the bytes an exempt-release-age item writes at `path` are the base's bytes plus exactly the one scope entry, judged by
297
+ * verifyReleaseAgeExemption() over the base commit's own bytes (never the working tree) and its .npmrc. True for a file no such
298
+ * item names. The set carries the file's digest only, so this is the one place the "one entry, nothing else" rule is proved.
299
+ */
300
+ function provesReleaseAgeEdit(root: string, set: RepositoryChangeSet, file: WholeFileChange, after: string): boolean {
301
+ return provesReleaseAgeEditWith((path) => gitShowUtf8(root, set.repository.baseCommit, path), set, file, after);
302
+ }
303
+
304
+ /**
305
+ * provesReleaseAgeEdit() over any reader of the base commit's own bytes: `readBase` returns the text of a path at the base
306
+ * commit, or null when there is none. The plan command's dry tree reads them from the clone's object database and never runs
307
+ * `git show`.
308
+ */
309
+ export function provesReleaseAgeEditWith(readBase: (path: string) => string | null, set: RepositoryChangeSet, file: WholeFileChange, after: string): boolean {
310
+ const item = set.items.find((entry) => entry.id === file.item);
311
+ if (item === undefined || item.act !== "exempt-release-age") return true;
312
+ if (item.path !== file.path) return false;
313
+ const before = readBase(file.path);
314
+ // The digest the set names as `before` must be these very bytes, or the proof would be over another file.
315
+ if ((before === null ? null : contentDigest(before)) !== file.before) return false;
316
+ const npmrc = readBase(".npmrc");
317
+ return verifyReleaseAgeExemption({ surface: item.surface, before, after, npmrc }).verified;
318
+ }
319
+
320
+ function checkBaseCommitMovement(root: string, set: RepositoryChangeSet): ApplyStepResult | null {
321
+ const branch = set.repository.defaultBranch;
322
+ const tip = git(root, ["rev-parse", `refs/heads/${branch}`]);
323
+ if (tip.status !== 0) return result(2, "indeterminate", "remote-tip-unreadable");
324
+ const tipCommit = tip.stdout.trim();
325
+ if (tipCommit === set.repository.baseCommit) return null;
326
+ for (const file of set.files.filter(isWhole)) {
327
+ if (digestAtRef(root, tipCommit, file.path, file.mode) !== file.before) return result(2, "indeterminate", "base-conflict");
328
+ }
329
+ return result(1, "violated", "superseded");
330
+ }
331
+
332
+ function persistChangeSet(hub: string, set: RepositoryChangeSet): ApplyStepResult | null {
333
+ let hubReal: string;
334
+ try {
335
+ hubReal = realpathSync(hub);
336
+ } catch {
337
+ return result(2, "indeterminate", "change-set-not-stored");
338
+ }
339
+ try {
340
+ storeChangeSet(hubReal, set);
341
+ } catch {
342
+ return result(2, "indeterminate", "change-set-not-stored");
343
+ }
344
+ return null;
345
+ }
346
+
347
+ export function checkChangeSetShape(set: RepositoryChangeSet): ApplyStepResult | null {
348
+ if (!BRANCH_SHAPE.test(set.branch) || !COMMIT_SHAPE.test(set.repository.baseCommit)) return result(2, "indeterminate", "change-set-invalid");
349
+ if (!validateRepositoryChangeSet(set).valid) return result(2, "indeterminate", "change-set-invalid");
350
+ return null;
351
+ }
352
+
353
+ /**
354
+ * The base ledger, trusted against the acts of the plan committed at the hub
355
+ * (never the acts of the set being judged: a set cannot vouch for itself), with
356
+ * the exact bytes it was read from (null when the base has none).
357
+ */
358
+ function baseLedgerTrust(
359
+ root: string,
360
+ set: RepositoryChangeSet,
361
+ heldChangeSets: readonly RepositoryChangeSet[],
362
+ planDigest: string,
363
+ planPackages: readonly PlanPackageIdentity[],
364
+ ): { ledger: InstalledLedger | null; bytes: Uint8Array | null } | ApplyStepResult {
365
+ const bytes = gitShowBytes(root, set.repository.baseCommit, LEDGER_PATH);
366
+ const ledgerBytes = bytes ?? new Uint8Array(0);
367
+ const trust = trustInstalledLedger(
368
+ ledgerBytes.length === 0 ? null : ledgerBytes,
369
+ { id: set.repository.id, nodeId: set.repository.nodeId },
370
+ heldChangeSets,
371
+ { planPackageActs: [{ planDigest, packages: planPackages }] },
372
+ );
373
+ if (trust.state === "refused") return result(2, "indeterminate", trust.rule);
374
+ const generation = trust.ledger?.generation ?? 0;
375
+ if (generation !== set.ledger.generation) return result(1, "violated", "ledger-mismatch");
376
+ return { ledger: trust.ledger, bytes: ledgerBytes.length === 0 ? null : ledgerBytes };
377
+ }
378
+
379
+ export function refuseReservedSymlinks(root: string, paths: readonly string[]): ApplyStepResult | null {
380
+ for (const reserved of RESERVED_ROOTS) {
381
+ try {
382
+ if (lstatSync(join(root, reserved)).isSymbolicLink()) return result(2, "indeterminate", "symlink-ancestor");
383
+ } catch (cause) {
384
+ if ((cause as NodeJS.ErrnoException).code !== "ENOENT") throw cause;
385
+ }
386
+ }
387
+ for (const rel of paths) {
388
+ const segments = rel.split("/");
389
+ let current = root;
390
+ for (let index = 0; index < segments.length; index += 1) {
391
+ current = join(current, segments[index]!);
392
+ try {
393
+ if (lstatSync(current).isSymbolicLink()) return result(2, "indeterminate", "symlink-ancestor");
394
+ } catch (cause) {
395
+ if ((cause as NodeJS.ErrnoException).code === "ENOENT") break;
396
+ throw cause;
397
+ }
398
+ }
399
+ }
400
+ return null;
401
+ }
402
+
403
+ function declareRootEntryText(root: string, set: RepositoryChangeSet, path: string): string | ApplyStepResult | null {
404
+ const item = set.items.find((entry) => entry.act === "declare-root-entry" && entry.path === path);
405
+ if (item === undefined || item.act !== "declare-root-entry") return null;
406
+ let onDisk: string;
407
+ try {
408
+ onDisk = readFileSync(join(root, path), "utf8");
409
+ } catch {
410
+ return null;
411
+ }
412
+ try {
413
+ return editJsonPointer(
414
+ onDisk,
415
+ item.entries.map((entry) => ({ pointer: "/rootEntries/-", value: { name: entry.name, classification: entry.classification, disposition: entry.disposition } })),
416
+ );
417
+ } catch (cause) {
418
+ if (cause instanceof JsonEditUnstableError) return result(2, "indeterminate", "json-edit-unstable");
419
+ throw cause;
420
+ }
421
+ }
422
+
423
+ export function resolveFileText(root: string, set: RepositoryChangeSet, file: WholeFileChange, texts: Readonly<Record<string, string>>): string | ApplyStepResult {
424
+ if (texts[file.path] !== undefined) return texts[file.path]!;
425
+ if (file.mode === "120000") {
426
+ const role = discoveryLinkRole(file.path);
427
+ if (role !== null) return discoveryLinkTarget(role);
428
+ }
429
+ const item = set.items.find((entry) => entry.id === file.item);
430
+ if (item?.act === "declare-root-entry" && item.path === file.path) {
431
+ const edited = declareRootEntryText(root, set, file.path);
432
+ if (edited === null) return result(2, "indeterminate", "file-text-unavailable");
433
+ if (typeof edited !== "string") return edited;
434
+ return edited;
435
+ }
436
+ return result(2, "indeterminate", "file-text-unavailable");
437
+ }
438
+
439
+ export function packagesForLockfile(set: RepositoryChangeSet, invariants: readonly PackageInvariant[]): LockfileInvariantPackage[] | null {
440
+ const packages: LockfileInvariantPackage[] = [];
441
+ for (const row of invariants) {
442
+ const item = set.items.find((entry) => entry.id === row.item);
443
+ if (item === undefined || (item.act !== "install" && item.act !== "pin-starter")) return null;
444
+ if (item.package.name !== row.name || item.package.version !== row.version || item.package.integrity !== row.integrity) return null;
445
+ packages.push({ name: row.name, version: row.version, integrity: row.integrity, placement: item.placement });
446
+ }
447
+ return packages;
448
+ }
449
+
450
+ export function derivedLockfile(set: RepositoryChangeSet): { path: string; invariants: PackageInvariant[] } | null {
451
+ const derived = set.files.find((file) => "derived" in file && file.path !== LEDGER_PATH);
452
+ if (derived === undefined || !("derived" in derived)) return null;
453
+ const invariants = derived.invariants.filter((row): row is PackageInvariant => "name" in row);
454
+ return { path: derived.path, invariants };
455
+ }
456
+
457
+ type GitPathList = { readonly ok: true; readonly paths: readonly string[] } | { readonly ok: false };
458
+
459
+ function porcelainPaths(root: string): GitPathList {
460
+ const { status, stdout } = git(root, ["status", "--porcelain", "--untracked-files=all"]);
461
+ if (status !== 0) return { ok: false };
462
+ const paths: string[] = [];
463
+ for (const line of stdout.split("\n")) {
464
+ if (line.length < 4) continue;
465
+ let path = line.slice(3).trim();
466
+ const arrow = path.indexOf(" -> ");
467
+ if (arrow !== -1) path = path.slice(arrow + 4);
468
+ if (path.length > 0) paths.push(path);
469
+ }
470
+ return { ok: true, paths };
471
+ }
472
+
473
+ function diffPaths(root: string, baseCommit: string): GitPathList {
474
+ const { status, stdout } = git(root, ["diff", "--name-only", baseCommit]);
475
+ if (status !== 0) return { ok: false };
476
+ return {
477
+ ok: true,
478
+ paths: stdout
479
+ .split("\n")
480
+ .map((line) => line.trim())
481
+ .filter((line) => line.length > 0),
482
+ };
483
+ }
484
+
485
+ /** What one path is in a tree: a link and where it points, a regular file (and whether it is executable), or anything else. */
486
+ export type TreeEntry =
487
+ | { readonly kind: "symlink"; readonly target: string }
488
+ | { readonly kind: "file"; readonly executable: boolean }
489
+ | { readonly kind: "other" };
490
+
491
+ /**
492
+ * Everything verifyPrepared reads about the tree it judges, so the same checks run over the working tree (verify) or over one
493
+ * commit's tree with no checkout (status). Nothing here writes.
494
+ */
495
+ export interface TreeReader {
496
+ /** Whether the tree is on `branch`: a working tree checks its HEAD; a commit reader is told its ref by its caller and answers true. */
497
+ onBranch(branch: string): boolean;
498
+ /** The paths that differ from `baseCommit`. */
499
+ changedPaths(baseCommit: string): GitPathList;
500
+ /** The paths git reports as modified or untracked; a commit has none. */
501
+ dirtyPaths(): GitPathList;
502
+ /** Whether a directory above `relPath` is a link. */
503
+ hasSymlinkAncestor(relPath: string): boolean;
504
+ /** What `relPath` is, or null when there is nothing there; throws when it cannot be told. */
505
+ entry(relPath: string): TreeEntry | null;
506
+ /** The bytes at `relPath`; throws when it cannot be read. */
507
+ bytes(relPath: string): Buffer;
508
+ }
509
+
510
+ export type { GitPathList };
511
+
512
+ /** The reader verify uses: the clone's working tree and its git status. */
513
+ function workingTreeReader(root: string): TreeReader {
514
+ return {
515
+ onBranch(branch) {
516
+ const head = git(root, ["rev-parse", "--abbrev-ref", "HEAD"]);
517
+ return head.status === 0 && head.stdout.trim() === branch;
518
+ },
519
+ changedPaths: (baseCommit) => diffPaths(root, baseCommit),
520
+ dirtyPaths: () => porcelainPaths(root),
521
+ hasSymlinkAncestor: (relPath) => hasSymlinkAncestor(root, relPath),
522
+ entry(relPath) {
523
+ const path = join(root, relPath);
524
+ let stat;
525
+ try {
526
+ stat = lstatSync(path);
527
+ } catch (cause) {
528
+ if ((cause as NodeJS.ErrnoException).code === "ENOENT") return null;
529
+ throw cause;
530
+ }
531
+ if (stat.isSymbolicLink()) return { kind: "symlink", target: readlinkSync(path) };
532
+ if (stat.isFile()) return { kind: "file", executable: (stat.mode & 0o111) !== 0 };
533
+ return { kind: "other" };
534
+ },
535
+ bytes: (relPath) => readFileSync(join(root, relPath)),
536
+ };
537
+ }
538
+
539
+ function symlinkBeforeRead(reader: TreeReader, relPaths: readonly string[]): ApplyStepResult | null {
540
+ for (const relPath of relPaths) {
541
+ if (reader.hasSymlinkAncestor(relPath)) return result(2, "indeterminate", "symlink-ancestor");
542
+ }
543
+ return null;
544
+ }
545
+
546
+ function expectedLedgerBytes(
547
+ previous: InstalledLedger | null,
548
+ set: RepositoryChangeSet,
549
+ binding: ApprovalBinding,
550
+ planPackages: readonly PlanPackageIdentity[],
551
+ ): Buffer {
552
+ return Buffer.from(renderInstalledLedger(previous, set, binding, planPackages), "utf8");
553
+ }
554
+
555
+ export interface Preconditions {
556
+ readonly root: string;
557
+ readonly previousLedger: InstalledLedger | null;
558
+ /** The binding admission computed from the hub: the only one a ledger may record. */
559
+ readonly binding: ApprovalBinding;
560
+ /** The committed plan's package acts for the set's repository: what RENDER writes ledger rows from. */
561
+ readonly planPackages: readonly PlanPackageIdentity[];
562
+ }
563
+
564
+ export async function runPreconditions(
565
+ clone: string,
566
+ hub: string,
567
+ set: RepositoryChangeSet,
568
+ heldChangeSets: readonly RepositoryChangeSet[],
569
+ admission: { readonly now?: () => Date; readonly runReadiness?: ReadinessRunner },
570
+ ): Promise<Preconditions | ApplyStepResult> {
571
+ const resolved = resolveCloneRoot(clone);
572
+ if ("exitCode" in resolved) return resolved;
573
+ const { root } = resolved;
574
+ const shape = checkChangeSetShape(set);
575
+ if (shape !== null) return shape;
576
+ const symlinks = checkSymlinksSupported(root);
577
+ if (symlinks !== null) return symlinks;
578
+ const remote = checkRemoteTip(root, set);
579
+ if (remote !== null) return remote;
580
+
581
+ let hubRoot = hub;
582
+ try {
583
+ hubRoot = realpathSync(hub);
584
+ } catch {
585
+ // readHubAuthority refuses a hub it cannot read.
586
+ }
587
+ const authority = readHubAuthority(hubRoot);
588
+ if ("state" in authority) return refusalResult(authority);
589
+ const planPackages = planPackagesFor(authority, set.repository.id);
590
+ const trust = baseLedgerTrust(root, set, heldChangeSets, authority.planDigest, planPackages);
591
+ if ("exitCode" in trust) return trust;
592
+ const decided = await decideSetBinding({
593
+ hub: hubRoot,
594
+ clone: root,
595
+ set,
596
+ authority,
597
+ baseLedger: trust.ledger,
598
+ baseLedgerBytes: trust.bytes,
599
+ now: admission.now,
600
+ runReadiness: admission.runReadiness,
601
+ });
602
+ if (decided.state !== "bound") return refusalResult(decided);
603
+ return { root, previousLedger: trust.ledger, binding: decided.binding, planPackages };
604
+ }
605
+
606
+ export async function verifyRepository(input: VerifyInput): Promise<ApplyStepResult> {
607
+ const pre = await runPreconditions(input.clone, input.hub, input.set, input.heldChangeSets ?? [], input);
608
+ if ("exitCode" in pre) return pre;
609
+ return verifyPrepared(input.set, pre);
610
+ }
611
+
612
+ /**
613
+ * The body of verify, over preconditions (and so an admission) that were already computed, and over a reader of the tree it
614
+ * judges: the clone's working tree by default (verify), or one commit's tree (status).
615
+ */
616
+ export function verifyPrepared(set: RepositoryChangeSet, pre: Preconditions, reader: TreeReader = workingTreeReader(pre.root)): ApplyStepResult {
617
+ const { root, previousLedger, binding, planPackages } = pre;
618
+ const allowed = (path: string) => set.pathAllowList.some((pattern) => matchesPathPattern(path, pattern));
619
+ const declared = declaredPaths(set);
620
+
621
+ if (!reader.onBranch(set.branch)) return result(1, "violated", "diverged");
622
+
623
+ const porcelainEarly = reader.dirtyPaths();
624
+ if (!porcelainEarly.ok) return result(2, "indeterminate", "status-unreadable");
625
+ const diffEarly = reader.changedPaths(set.repository.baseCommit);
626
+ if (!diffEarly.ok) return result(2, "indeterminate", "status-unreadable");
627
+
628
+ const lockEarly = derivedLockfile(set);
629
+ const readPaths = [...declared, ...(lockEarly !== null ? [lockEarly.path] : [])];
630
+ const symlink = symlinkBeforeRead(reader, readPaths);
631
+ if (symlink !== null) return symlink;
632
+
633
+ for (const path of declared) {
634
+ if (!allowed(path)) return result(1, "violated", "outside-allow-list");
635
+ }
636
+
637
+ // The Launcher's guide is checked before the whole-file loop so a changed byte is named for what it is: the set's own digest,
638
+ // then the constant text (G5). The file's text is read here and never echoed.
639
+ if (set.items.some((item) => item.act === "write-record" && item.source === "agents-guide")) {
640
+ const guide = set.files.find((file) => isWhole(file) && file.path === AGENTS_GUIDE_PATH);
641
+ if (guide !== undefined && guide.after !== null) {
642
+ let text: string | null = null;
643
+ try {
644
+ const entry = reader.entry(AGENTS_GUIDE_PATH);
645
+ if (entry !== null && entry.kind === "file") text = reader.bytes(AGENTS_GUIDE_PATH).toString("utf8");
646
+ } catch {
647
+ text = null;
648
+ }
649
+ if (text === null || contentDigest(text) !== guide.after || !verifyAgentsGuide(text)) return result(1, "violated", "agents-guide-mismatch");
650
+ }
651
+ }
652
+
653
+ for (const file of set.files.filter(isWhole)) {
654
+ if (file.after !== null) {
655
+ try {
656
+ const entry = reader.entry(file.path);
657
+ if (entry === null) return result(1, "violated", "content-mismatch");
658
+ if (file.mode === "120000") {
659
+ if (entry.kind !== "symlink") return result(1, "violated", "mode-mismatch");
660
+ if (contentDigest(entry.target) !== file.after) return result(1, "violated", "content-mismatch");
661
+ } else {
662
+ if (entry.kind !== "file") return result(1, "violated", "mode-mismatch");
663
+ if (entry.executable) return result(1, "violated", "mode-mismatch");
664
+ const onDisk = reader.bytes(file.path).toString("utf8");
665
+ if (contentDigest(onDisk) !== file.after) return result(1, "violated", "content-mismatch");
666
+ if (!provesReleaseAgeEdit(root, set, file, onDisk)) return result(1, "violated", "content-mismatch");
667
+ }
668
+ } catch {
669
+ return result(1, "violated", "content-mismatch");
670
+ }
671
+ } else {
672
+ const present = reader.entry(file.path);
673
+ if (present !== null && present.kind !== "other") return result(1, "violated", "removal-present");
674
+ }
675
+ }
676
+
677
+ if (set.keys.length > 0) {
678
+ let manifest: string;
679
+ try {
680
+ manifest = reader.bytes("package.json").toString("utf8");
681
+ } catch {
682
+ return result(1, "violated", "content-mismatch");
683
+ }
684
+ for (const key of set.keys) {
685
+ const at = valueAtJsonPointer(manifest, key.pointer);
686
+ if (key.after === null) {
687
+ if (at.found) return result(1, "violated", "content-mismatch");
688
+ } else if (!at.found || at.value !== key.after) return result(1, "violated", "content-mismatch");
689
+ }
690
+ }
691
+
692
+ const lock = derivedLockfile(set);
693
+ if (lock !== null) {
694
+ const format =
695
+ lock.path === "package-lock.json" ? "npm" : lock.path === "pnpm-lock.yaml" ? "pnpm" : lock.path === "yarn.lock" ? null : null;
696
+ if (format === null) return result(2, "indeterminate", "lockfile-format-unsupported");
697
+ const base = gitShowUtf8(root, set.repository.baseCommit, lock.path) ?? "";
698
+ let current: string;
699
+ try {
700
+ current = reader.bytes(lock.path).toString("utf8");
701
+ } catch {
702
+ return result(1, "violated", "lockfile-invariants");
703
+ }
704
+ const packages = packagesForLockfile(set, lock.invariants);
705
+ if (packages === null) return result(2, "indeterminate", "lockfile-invariants");
706
+ const checked = checkLockfileInvariants({ format, base, regenerated: current, packages });
707
+ if (checked.verdict === "violated") return result(1, "violated", "lockfile-invariants");
708
+ if (checked.verdict === "indeterminate") return result(2, "indeterminate", checked.reason);
709
+ }
710
+
711
+ const expectedLedger = expectedLedgerBytes(previousLedger, set, binding, planPackages);
712
+ let ledgerOnDisk: Buffer;
713
+ try {
714
+ ledgerOnDisk = reader.bytes(LEDGER_PATH);
715
+ } catch {
716
+ return result(1, "violated", "ledger-mismatch");
717
+ }
718
+ if (!ledgerOnDisk.equals(expectedLedger)) return result(1, "violated", "ledger-mismatch");
719
+
720
+ for (const path of diffEarly.paths) {
721
+ if (!declared.has(path)) return result(1, "violated", "undeclared-path");
722
+ }
723
+
724
+ for (const path of porcelainEarly.paths) {
725
+ if (!declared.has(path)) {
726
+ if (LEFTOVER_SKILL.test(path)) return result(1, "violated", "dirty", `${path} left by an earlier run`);
727
+ return result(1, "violated", "dirty");
728
+ }
729
+ }
730
+
731
+ return result(0, "materialized");
732
+ }
733
+
734
+ export function textsFromChangeSet(set: RepositoryChangeSet): Readonly<Record<string, string>> {
735
+ if (set.texts === undefined) return {};
736
+ return Object.fromEntries(set.texts.map((row) => [row.path, row.text] as const));
737
+ }
738
+
739
+ export async function materializeRepository(input: MaterializeInput): Promise<ApplyStepResult> {
740
+ const pre = await runPreconditions(input.clone, input.hub, input.set, input.heldChangeSets ?? [], input);
741
+ if ("exitCode" in pre) return pre;
742
+ const { root, previousLedger, binding, planPackages } = pre;
743
+ const set = input.set;
744
+ const texts = { ...textsFromChangeSet(set), ...input.texts };
745
+
746
+ const baseMovement = checkBaseCommitMovement(root, set);
747
+ if (baseMovement !== null) return baseMovement;
748
+
749
+ if (git(root, ["show-ref", "--verify", "--quiet", `refs/heads/${set.branch}`]).status === 0) {
750
+ const verified = verifyPrepared(set, pre);
751
+ if (verified.exitCode === 0) {
752
+ const stored = persistChangeSet(input.hub, set);
753
+ if (stored !== null) return stored;
754
+ return verified;
755
+ }
756
+ if (verified.verdict === "indeterminate") return verified;
757
+ return { exitCode: 1, verdict: "violated", reason: "diverged", detail: verified.reason === "agents-guide-mismatch" ? verified.reason : verified.detail };
758
+ }
759
+
760
+ for (const file of set.files.filter(isWhole)) {
761
+ const onDisk = digestAtPath(root, file.path);
762
+ if (onDisk !== file.before) return result(1, "violated", "base-mismatch");
763
+ }
764
+
765
+ const porcelain = porcelainPaths(root);
766
+ if (!porcelain.ok) return result(2, "indeterminate", "status-unreadable");
767
+ if (porcelain.paths.length > 0) return result(1, "violated", "dirty");
768
+
769
+ const toWrite = set.files.filter(isWhole).filter((file) => file.before !== file.after);
770
+ const resolvedTexts = new Map<string, string>();
771
+ for (const file of toWrite) {
772
+ const resolved = resolveFileText(root, set, file, texts);
773
+ if (typeof resolved !== "string") return resolved;
774
+ if (file.after !== null && contentDigest(resolved) !== file.after) return result(1, "violated", "content-mismatch");
775
+ if (!provesReleaseAgeEdit(root, set, file, resolved)) return result(1, "violated", "content-mismatch");
776
+ resolvedTexts.set(file.path, resolved);
777
+ }
778
+
779
+ let packageText: string | undefined;
780
+ if (set.keys.length > 0) {
781
+ const existing = gitShowUtf8(root, set.repository.baseCommit, "package.json");
782
+ if (existing === null) return result(2, "indeterminate", "manifest-absent");
783
+ try {
784
+ packageText = editJsonPointer(
785
+ existing,
786
+ set.keys.map((key) => (key.after === null ? { pointer: key.pointer, remove: true } : { pointer: key.pointer, value: key.after })),
787
+ );
788
+ } catch (cause) {
789
+ if (cause instanceof JsonEditUnstableError) return result(2, "indeterminate", "json-edit-unstable");
790
+ throw cause;
791
+ }
792
+ }
793
+
794
+ const pathsToTouch = writePaths(set);
795
+ const symlinkCheck = refuseReservedSymlinks(root, pathsToTouch);
796
+ if (symlinkCheck !== null) return symlinkCheck;
797
+
798
+ let ledgerBytes: Buffer;
799
+ try {
800
+ ledgerBytes = expectedLedgerBytes(previousLedger, set, binding, planPackages);
801
+ } catch (cause) {
802
+ if (cause instanceof TypeError) return result(2, "indeterminate", "change-set-invalid");
803
+ throw cause;
804
+ }
805
+
806
+ if (git(root, ["checkout", "-b", set.branch, set.repository.baseCommit]).status !== 0) {
807
+ return result(2, "indeterminate", "branch-not-created");
808
+ }
809
+
810
+ for (const file of toWrite) {
811
+ if (file.after === null) {
812
+ const removed = removePath(root, file.path);
813
+ if (removed !== null) return removed;
814
+ continue;
815
+ }
816
+ const text = resolvedTexts.get(file.path)!;
817
+ if (file.mode === "120000") {
818
+ const linked = writeSymlink(root, file.path, text, file.after);
819
+ if (linked !== null) return linked;
820
+ } else {
821
+ const written = writeRegularFile(root, file.path, text, file.after);
822
+ if (written !== null) return written;
823
+ }
824
+ }
825
+
826
+ if (packageText !== undefined) {
827
+ const written = writeRegularFile(root, "package.json", packageText, contentDigest(packageText));
828
+ if (written !== null) return written;
829
+ }
830
+
831
+ const lock = derivedLockfile(set);
832
+ let toolingVersion: string | null = null;
833
+ let toolingTool: "npm" | "pnpm" | "yarn" | null = null;
834
+ if (lock !== null) {
835
+ const lockpath = lockfilePath({ packageManager: set.observed.packageManager, lockfile: set.observed.lockfile });
836
+ if (lockpath === null || lockpath !== lock.path) return result(2, "indeterminate", "lockfile-format-unsupported");
837
+ const packages = packagesForLockfile(set, lock.invariants);
838
+ if (packages === null) return result(2, "indeterminate", "lockfile-invariants");
839
+ const baseLock = gitShowUtf8(root, set.repository.baseCommit, lock.path) ?? "";
840
+ const regen = await regenerateLockfile(
841
+ {
842
+ root,
843
+ packageManager: set.observed.packageManager,
844
+ lockfile: set.observed.lockfile,
845
+ toolVersion: input.toolVersion ?? null,
846
+ baseLockfile: baseLock,
847
+ packages,
848
+ now: input.now ?? (() => new Date()),
849
+ },
850
+ { spawn: input.spawn },
851
+ );
852
+ if (regen.verdict !== "satisfied") {
853
+ if (regen.verdict === "violated") return result(1, "violated", regen.rule);
854
+ return result(2, "indeterminate", regen.reason);
855
+ }
856
+ // The runner already wrote the lockfile. `after` is the hex digest of those bytes.
857
+ let onDisk: Buffer;
858
+ try {
859
+ onDisk = readFileSync(join(root, regen.path));
860
+ } catch {
861
+ return result(1, "violated", "lockfile-invariants");
862
+ }
863
+ if (createHash("sha256").update(onDisk).digest("hex") !== regen.after) return result(1, "violated", "lockfile-invariants");
864
+ toolingTool = regen.tooling.tool;
865
+ toolingVersion = regen.tooling.version;
866
+ }
867
+
868
+ const ledgerWritten = writeRegularFile(root, LEDGER_PATH, ledgerBytes.toString("utf8"), contentDigest(ledgerBytes.toString("utf8")));
869
+ if (ledgerWritten !== null) return ledgerWritten;
870
+ const ledgerRead = readFileSync(join(root, LEDGER_PATH));
871
+ if (!ledgerRead.equals(ledgerBytes)) return result(1, "violated", "ledger-mismatch");
872
+
873
+ let toStore: RepositoryChangeSet = set;
874
+ if (toolingVersion !== null && toolingTool !== null && TOOL_VERSION_SHAPE.test(toolingVersion)) {
875
+ const tooling = canonicalOrder(
876
+ [...(set.tooling ?? []).filter((row) => row.tool !== toolingTool), { tool: toolingTool, version: toolingVersion }],
877
+ CANONICAL_KEYS.tool,
878
+ );
879
+ toStore = { ...set, tooling };
880
+ }
881
+
882
+ const stored = persistChangeSet(input.hub, toStore);
883
+ if (stored !== null) return stored;
884
+
885
+ return result(0, "materialized");
886
+ }