@clossys/launcher 0.3.1 → 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 (285) hide show
  1. package/README.md +1324 -60
  2. package/contracts/conversation-contract.md +2 -2
  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 -4
  210. package/skeleton/README.md +14 -9
  211. package/skeleton/package.json +2 -1
  212. package/skill/SKILL.md +23 -7
  213. package/skill-catalogue/advisor/SKILL.md +59 -6
  214. package/skill-catalogue/architect/SKILL.md +2 -2
  215. package/skill-catalogue/bouncer/SKILL.md +2 -2
  216. package/skill-catalogue/builder/SKILL.md +2 -2
  217. package/skill-catalogue/butler/SKILL.md +2 -2
  218. package/skill-catalogue/controller/SKILL.md +2 -2
  219. package/skill-catalogue/customer/SKILL.md +2 -2
  220. package/skill-catalogue/designer/SKILL.md +4 -2
  221. package/skill-catalogue/giver/SKILL.md +2 -2
  222. package/skill-catalogue/influencer/SKILL.md +2 -2
  223. package/skill-catalogue/inspector/SKILL.md +2 -2
  224. package/skill-catalogue/integrator/SKILL.md +2 -2
  225. package/skill-catalogue/keeper/SKILL.md +2 -2
  226. package/skill-catalogue/launcher/SKILL.md +23 -7
  227. package/skill-catalogue/locksmith/SKILL.md +2 -2
  228. package/skill-catalogue/messenger/SKILL.md +2 -2
  229. package/skill-catalogue/observer/SKILL.md +2 -2
  230. package/skill-catalogue/publisher/SKILL.md +2 -2
  231. package/skill-catalogue/starter/SKILL.md +3 -2
  232. package/skill-catalogue/strategist/SKILL.md +12 -4
  233. package/skill-catalogue/writer/SKILL.md +2 -2
  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
@@ -0,0 +1,637 @@
1
+ // The installed-state ledger (issue #1178), validated against the shared
2
+ // contract docs/contracts/installed-ledger.json -- in the public repository,
3
+ // not shipped in this package. This package's build packs it into
4
+ // src/generated/ beside the change-set and bundle contracts, and validates
5
+ // it with the same generated copy of the one contract checker. Validation
6
+ // makes a ledger well formed, not true: trusting a row needs the hub's
7
+ // change sets (see TRUST in the contract), which nothing here reads. Nothing
8
+ // in this package writes a ledger yet. The TypeScript types below describe
9
+ // the contract's shapes for callers; they validate nothing.
10
+
11
+ import { AGENTS_GUIDE_PATH, AGENTS_GUIDE_TEXT } from "./agents-guide.js";
12
+ import { formatContractViolation, readContractDocument, validateAgainstContract } from "./generated/contract-schema.generated.js";
13
+ import {
14
+ EXEMPTION_SURFACES,
15
+ ID_TOKEN,
16
+ INTRODUCIBLE_ROOTS,
17
+ LEDGER_PATH,
18
+ LOCKFILE_NAMES,
19
+ canonicalOrder,
20
+ compareTuples,
21
+ contentDigest,
22
+ derivedPlanItem,
23
+ dependencyPointer,
24
+ discoveryLinkRole,
25
+ matchesPathPattern,
26
+ } from "./change-set-contract.js";
27
+ import type { ApprovalBinding, ChangeSetPhase, DependencyPlacement, RepositoryChangeSet } from "./change-set-contract.js";
28
+ import { changeSetDigest } from "./change-set-digest.js";
29
+ import { loadContract } from "./plan-contract.js";
30
+ import type { ValidationResult } from "./plan-contract.js";
31
+
32
+ /** The digest of the Launcher guide's bytes: the only after an apply-phase add of the guide may carry (RENDER and S3). */
33
+ const AGENTS_GUIDE_DIGEST = contentDigest(AGENTS_GUIDE_TEXT);
34
+
35
+ /** One generation: the change set that wrote it, and on what authority. */
36
+ export interface LedgerHistoryEntry {
37
+ readonly generation: number;
38
+ readonly changeSet: string;
39
+ readonly phase: ChangeSetPhase;
40
+ readonly planDigest: string;
41
+ readonly bundle: string;
42
+ readonly baseCommit: string;
43
+ readonly binding: ApprovalBinding;
44
+ }
45
+
46
+ /** A whole file the flow wrote and still owns. */
47
+ export interface LedgerFileRow {
48
+ readonly path: string;
49
+ readonly mode: "100644" | "120000";
50
+ readonly after: string;
51
+ readonly changeSet: string;
52
+ }
53
+
54
+ /** A package.json key the flow wrote. */
55
+ export interface LedgerKeyRow {
56
+ readonly file: "package.json";
57
+ readonly pointer: string;
58
+ readonly value: string;
59
+ readonly changeSet: string;
60
+ }
61
+
62
+ /** An entry the flow added to a release-age exemption list, or to a Controller profile's root vocabulary. */
63
+ export type LedgerEntryRow =
64
+ | { readonly file: "pnpm-workspace.yaml"; readonly key: "minimumReleaseAgeExclude"; readonly value: string; readonly changeSet: string }
65
+ | { readonly file: ".yarnrc.yml"; readonly key: "npmPreapprovedPackages"; readonly value: string; readonly changeSet: string }
66
+ | { readonly file: string; readonly key: "rootEntries"; readonly value: string; readonly changeSet: string };
67
+
68
+ /** One exact package identity, as a package act names it. */
69
+ export interface LedgerPackageIdentity {
70
+ readonly planItem: string;
71
+ readonly name: string;
72
+ readonly version: string;
73
+ readonly integrity: string;
74
+ readonly placement: DependencyPlacement;
75
+ }
76
+
77
+ /** A package act in effect. */
78
+ export interface LedgerPackageRow extends LedgerPackageIdentity {
79
+ readonly act: "install" | "pin-starter";
80
+ readonly changeSet: string;
81
+ }
82
+
83
+ /** An install the latest setup set deferred, with the plan's identity for it. */
84
+ export interface LedgerDeferredRow extends LedgerPackageIdentity {
85
+ readonly act: "install";
86
+ readonly reason: "after-setup";
87
+ readonly changeSet: string;
88
+ }
89
+
90
+ /** clossys/.state/installed.json (installed-ledger.json, in the public repository, not shipped in this package). */
91
+ export interface InstalledLedger {
92
+ readonly schemaVersion: 1;
93
+ readonly kind: "clossys.installed-ledger";
94
+ readonly repository: { readonly id: string; readonly nodeId: string };
95
+ readonly generation: number;
96
+ readonly history: readonly LedgerHistoryEntry[];
97
+ readonly files: readonly LedgerFileRow[];
98
+ readonly keys: readonly LedgerKeyRow[];
99
+ readonly entries: readonly LedgerEntryRow[];
100
+ readonly packages: readonly LedgerPackageRow[];
101
+ readonly deferred: readonly LedgerDeferredRow[];
102
+ }
103
+
104
+ export type LedgerRuleId = "L1" | "L2" | "L3" | "L4" | "L5" | "L6" | "L7" | "L8" | "L9" | "L10";
105
+ export type LedgerSuccessionRuleId = "S2" | "S3";
106
+
107
+ /** One reason a ledger, or a pair of ledgers, is refused: `rule` is "schema" for the contract's keywords, "bytes" for text that is not a ledger's exact bytes, else the rule's id. */
108
+ export interface LedgerViolation {
109
+ readonly rule: "schema" | "bytes" | LedgerRuleId | LedgerSuccessionRuleId;
110
+ /** In a succession, which ledger breaks a contract rule; absent for S2 and S3, which relate the two. */
111
+ readonly side?: "base" | "head";
112
+ readonly path: string;
113
+ readonly message: string;
114
+ }
115
+
116
+ interface RuleViolation {
117
+ readonly rule: LedgerRuleId | LedgerSuccessionRuleId;
118
+ readonly path: string;
119
+ readonly message: string;
120
+ }
121
+
122
+ const CONTRACT = "installed-ledger.json";
123
+
124
+ /** The owned path patterns, read from the packed ledger contract. */
125
+ const OWNED_PATTERNS: readonly string[] = (() => {
126
+ const definitions = loadContract(CONTRACT).definitions as Record<string, { enum?: unknown }> | undefined;
127
+ const list = definitions?.ownedPattern?.enum;
128
+ if (!Array.isArray(list) || !list.every((entry) => typeof entry === "string")) throw new Error("the packed ledger contract has no ownedPattern list");
129
+ return list as string[];
130
+ })();
131
+
132
+ function repeats<T>(values: readonly T[], key: (value: T) => string): { index: number; first: number }[] {
133
+ const seen = new Map<string, number>();
134
+ const out: { index: number; first: number }[] = [];
135
+ values.forEach((value, index) => {
136
+ const k = key(value);
137
+ const first = seen.get(k);
138
+ if (first === undefined) seen.set(k, index);
139
+ else out.push({ index, first });
140
+ });
141
+ return out;
142
+ }
143
+
144
+ /** Whether two JSON values are equal member for member, whatever their members' order. */
145
+ function sameValue(left: unknown, right: unknown): boolean {
146
+ if (left === right) return true;
147
+ if (typeof left !== "object" || typeof right !== "object" || left === null || right === null) return false;
148
+ if (Array.isArray(left) !== Array.isArray(right)) return false;
149
+ if (Array.isArray(left)) {
150
+ const other = right as readonly unknown[];
151
+ return left.length === other.length && left.every((value, index) => sameValue(value, other[index]));
152
+ }
153
+ const a = left as Record<string, unknown>;
154
+ const b = right as Record<string, unknown>;
155
+ const keys = Object.keys(a);
156
+ return keys.length === Object.keys(b).length && keys.every((key) => Object.hasOwn(b, key) && sameValue(a[key], b[key]));
157
+ }
158
+
159
+ const IDENTITY: readonly (keyof LedgerPackageIdentity | "act")[] = ["planItem", "act", "name", "version", "integrity", "placement"];
160
+
161
+ /** Code rules L1-L10 of installed-ledger.json, over a ledger whose schema already passes. Messages name positions, never values. */
162
+ export function ledgerRuleViolations(ledger: InstalledLedger): RuleViolation[] {
163
+ const out: RuleViolation[] = [];
164
+ const push = (rule: LedgerRuleId, path: string, message: string) => out.push({ rule, path, message });
165
+ const { history } = ledger;
166
+
167
+ // L1
168
+ if (ledger.generation < 1 || ledger.generation !== history.length) push("L1", "generation", "must be 1 or more and equal the number of history entries");
169
+ history.forEach((entry, index) => {
170
+ if (entry.generation !== index + 1) push("L1", `history[${index}].generation`, "is not its position plus 1");
171
+ });
172
+
173
+ // L2
174
+ for (const { index, first } of repeats(history, (entry) => entry.changeSet)) push("L2", `history[${index}].changeSet`, `repeats history[${first}].changeSet`);
175
+
176
+ // L3
177
+ history.forEach((entry, index) => {
178
+ const at = `history[${index}].binding`;
179
+ const { binding } = entry;
180
+ if (entry.phase === "setup" && binding.kind !== "approved") {
181
+ push("L3", at, "is not approved, and a setup entry's binding must be");
182
+ return;
183
+ }
184
+ // An approved binding's subjectDigest may differ from the entry's bundle: bundle is the run that computed the set, subjectDigest the approval.
185
+ if (binding.kind === "approved") return;
186
+ const previous = index > 0 ? history[index - 1] : undefined;
187
+ if (previous === undefined) {
188
+ push("L3", at, "is admitted, but no setup entry comes before it");
189
+ return;
190
+ }
191
+ if (previous.changeSet !== binding.setupChangeSet) push("L3", `${at}.setupChangeSet`, "is not the change set of the entry before it");
192
+ if (previous.phase !== "setup" || previous.binding.kind !== "approved") push("L3", at, "is admitted, but the entry before it is not an approved setup entry");
193
+ if (previous.planDigest !== entry.planDigest) push("L3", `history[${index}].planDigest`, "is not the plan digest of the setup entry it follows");
194
+ if (previous.binding.subjectDigest !== binding.subjectDigest) push("L3", `${at}.subjectDigest`, "is not the approved subject of the setup entry it follows");
195
+ });
196
+
197
+ // L4
198
+ const written = new Set(history.map((entry) => entry.changeSet));
199
+ const rowArrays: [string, readonly { readonly changeSet: string }[]][] = [
200
+ ["files", ledger.files],
201
+ ["keys", ledger.keys],
202
+ ["entries", ledger.entries],
203
+ ["packages", ledger.packages],
204
+ ["deferred", ledger.deferred],
205
+ ];
206
+ for (const [name, rows] of rowArrays) {
207
+ rows.forEach((row, index) => {
208
+ if (!written.has(row.changeSet)) push("L4", `${name}[${index}].changeSet`, "names no history entry's change set");
209
+ });
210
+ }
211
+ const last = history.at(-1);
212
+ if (last !== undefined) {
213
+ ledger.deferred.forEach((row, index) => {
214
+ if (written.has(row.changeSet) && row.changeSet !== last.changeSet) push("L4", `deferred[${index}].changeSet`, "is not the latest history entry's change set");
215
+ });
216
+ if (last.phase === "apply" && ledger.deferred.length > 0) push("L4", "deferred", "must be empty after an apply generation");
217
+ }
218
+
219
+ // L5
220
+ ledger.files.forEach((row, index) => {
221
+ const at = `files[${index}]`;
222
+ const lowered = row.path.toLowerCase();
223
+ if (lowered === LEDGER_PATH.toLowerCase() || lowered === "package.json" || LOCKFILE_NAMES.includes(lowered)) push("L5", `${at}.path`, "is the ledger, package.json or a lockfile, which no files row names");
224
+ else if (!OWNED_PATTERNS.some((pattern) => matchesPathPattern(row.path, pattern))) push("L5", `${at}.path`, "is not matched by any owned pattern");
225
+ const role = discoveryLinkRole(row.path) ?? /^\.agents\/skills\/clossys-([^/]+)\//u.exec(row.path)?.[1];
226
+ if (role !== undefined && !ID_TOKEN.test(role)) push("L5", `${at}.path`, "names a role that is not a lowercase id token");
227
+ const link = discoveryLinkRole(row.path) !== null;
228
+ if ((row.mode === "120000") !== link) push("L5", `${at}.mode`, link ? "is not 120000, and this path is a discovery link" : "is 120000, which only a discovery link has");
229
+ });
230
+
231
+ // L6
232
+ ledger.keys.forEach((row, index) => {
233
+ const owners = ledger.packages.filter((pkg) => dependencyPointer(pkg.placement, pkg.name) === row.pointer);
234
+ if (owners.length !== 1) push("L6", `keys[${index}].pointer`, "does not name exactly one packages row");
235
+ else if (owners[0]!.version !== row.value) push("L6", `keys[${index}].value`, "is not the version of the package it names");
236
+ });
237
+
238
+ // L7
239
+ const acts = [...ledger.packages.map((row, index) => ({ row, path: `packages[${index}]` })), ...ledger.deferred.map((row, index) => ({ row, path: `deferred[${index}]` }))];
240
+ // A repeated planItem is a repeated name: L10 derives each planItem from its name.
241
+ for (const { index, first } of repeats(acts, (entry) => entry.row.name)) push("L7", `${acts[index]!.path}.name`, `repeats ${acts[first]!.path}.name`);
242
+
243
+ // L8
244
+ const order = <T>(name: string, rows: readonly T[], key: (row: T) => readonly string[]) => {
245
+ for (let index = 1; index < rows.length; index += 1) {
246
+ if (compareTuples(key(rows[index - 1]!), key(rows[index]!)) >= 0) {
247
+ push("L8", `${name}[${index}]`, "is out of canonical order, or repeats the entry before it");
248
+ return;
249
+ }
250
+ }
251
+ };
252
+ order("files", ledger.files, (row) => [row.path]);
253
+ for (const { index, first } of repeats(ledger.files, (row) => row.path.toLowerCase())) push("L8", `files[${index}].path`, `repeats files[${first}].path, compared case-insensitively`);
254
+ order("keys", ledger.keys, (row) => [row.file, row.pointer]);
255
+ order("entries", ledger.entries, (row) => [row.file, row.key, row.value]);
256
+ order("packages", ledger.packages, (row) => [row.planItem]);
257
+ order("deferred", ledger.deferred, (row) => [row.planItem]);
258
+
259
+ // L9
260
+ const profileRows = ledger.entries.map((row, index) => ({ row, index })).filter(({ row }) => row.key === "rootEntries");
261
+ const profileFile = profileRows[0]?.row.file;
262
+ for (const { row, index } of profileRows) {
263
+ if (row.file !== profileFile) push("L9", `entries[${index}].file`, "is a second Controller profile, and the flow edits one");
264
+ if (!INTRODUCIBLE_ROOTS.has(row.value)) push("L9", `entries[${index}].value`, "is not a root name an owned pattern can introduce");
265
+ }
266
+
267
+ // L10: a planItem is derived from this repository's id and the package, never free text.
268
+ for (const [name, rows] of [["packages", ledger.packages], ["deferred", ledger.deferred]] as const) {
269
+ rows.forEach((row, index) => {
270
+ if (row.planItem !== derivedPlanItem(ledger.repository.id, row.name)) push("L10", `${name}[${index}].planItem`, "is not repository.id, a colon and the row's name");
271
+ });
272
+ }
273
+ return out;
274
+ }
275
+
276
+ function contractViolations(value: unknown, label: string, side?: "base" | "head"): LedgerViolation[] {
277
+ const where = side === undefined ? {} : { side };
278
+ const schema = validateAgainstContract(loadContract(CONTRACT), value, loadContract);
279
+ if (schema.length > 0) return schema.map((violation) => ({ rule: "schema", ...where, path: violation.path, message: formatContractViolation(label, violation) }));
280
+ return ledgerRuleViolations(value as InstalledLedger).map((violation) => ({
281
+ ...violation,
282
+ ...where,
283
+ message: `${label}.${violation.path} ${violation.message} (rule ${violation.rule})`,
284
+ }));
285
+ }
286
+
287
+ /** Every reason a ledger is refused: the ledger contract's schema, then, once that passes, its code rules L1-L10. */
288
+ export function installedLedgerViolations(value: unknown): LedgerViolation[] {
289
+ return contractViolations(value, "ledger");
290
+ }
291
+
292
+ /** Validates a ledger against installed-ledger.json and its code rules L1-L10. A valid ledger is well formed, not trusted. No reason echoes a value. */
293
+ export function validateInstalledLedger(value: unknown): ValidationResult {
294
+ const violations = installedLedgerViolations(value);
295
+ if (violations.length === 0) return { valid: true };
296
+ return { valid: false, reason: violations.map((violation) => violation.message).join("; ") };
297
+ }
298
+
299
+ /**
300
+ * What a pull request's head ledger does to its base's. `change` is none only
301
+ * when both are valid, exactly canonical, and byte for byte the same.
302
+ * `admission` says what was proved about a next generation that breaks no
303
+ * rule: admitted when it is admitted and S3 held; approval-claimed when it is
304
+ * bound approved, which a reader without the hub cannot authenticate, so it
305
+ * is a claim, never an admission or a pass; null for no change, or when any
306
+ * rule refuses the pair.
307
+ */
308
+ export interface LedgerSuccession {
309
+ readonly change: "none" | "next-generation";
310
+ readonly admission: "admitted" | "approval-claimed" | null;
311
+ readonly violations: readonly LedgerViolation[];
312
+ }
313
+
314
+ function successionRuleViolations(base: InstalledLedger | null, head: InstalledLedger): RuleViolation[] {
315
+ const out: RuleViolation[] = [];
316
+ const push = (rule: LedgerSuccessionRuleId, path: string, message: string) => out.push({ rule, path, message });
317
+
318
+ // S2
319
+ if (base !== null && !sameValue(head.repository, base.repository)) push("S2", "head.repository", "is not the base ledger's repository");
320
+ // head.generation is base.generation plus 1 exactly when this holds, because L1 ties each ledger's generation to its history's length.
321
+ if (!sameValue(head.history.slice(0, -1), base?.history ?? [])) push("S2", "head.history", "does not keep the base ledger's history unchanged before its last entry, or is not one generation past it");
322
+
323
+ // S3
324
+ const last = head.history.at(-1)!;
325
+ if (last.binding.kind !== "admitted") return out;
326
+ const at = `head.history[${head.history.length - 1}].binding`;
327
+ if (base === null) {
328
+ push("S3", at, "is admitted, but the base has no ledger holding the setup it follows");
329
+ return out;
330
+ }
331
+ // Already proved: L3 on the head makes the entry before an admitted one its approved setup entry, and S2 makes that entry the base's latest;
332
+ // L3 makes an admitted entry an apply entry, and L4 leaves no deferred row after one.
333
+ // The one file an admitted generation may add: the Launcher guide, for an install set up before it existed. Its row is the only change to
334
+ // the files, is written by this generation, holds the guide's own bytes, and so the base holds none at that path (L8 forbids a repeat in
335
+ // any letter case).
336
+ const addedFiles = head.files.filter((row) => !base.files.some((other) => sameValue(other, row)));
337
+ const droppedFiles = base.files.filter((row) => !head.files.some((other) => sameValue(other, row)));
338
+ const guideAdded =
339
+ droppedFiles.length === 0 &&
340
+ addedFiles.length === 1 &&
341
+ addedFiles[0]!.path === AGENTS_GUIDE_PATH &&
342
+ addedFiles[0]!.mode === "100644" &&
343
+ addedFiles[0]!.after === AGENTS_GUIDE_DIGEST &&
344
+ addedFiles[0]!.changeSet === last.changeSet;
345
+ if (addedFiles.length + droppedFiles.length > 0 && !guideAdded) push("S3", "head.files", "differ from the base ledger's, and an admitted generation changes no file but may add the guide's");
346
+ if (!sameValue(head.entries, base.entries)) push("S3", "head.entries", "differ from the base ledger's, and an admitted generation changes no entry");
347
+ const kept = <T>(rows: readonly T[], from: readonly T[]) => from.every((row) => rows.some((other) => sameValue(other, row)));
348
+ if (!kept(head.keys, base.keys)) push("S3", "head.keys", "drop or change a key row the base ledger has");
349
+ if (!kept(head.packages, base.packages)) push("S3", "head.packages", "drop or change a package row the base ledger has");
350
+ const added = head.packages.filter((row) => !base.packages.some((other) => sameValue(other, row)));
351
+ const matchesDeferral = (row: LedgerPackageRow, deferral: LedgerDeferredRow) =>
352
+ IDENTITY.every((member) => row[member] === deferral[member]) && row.changeSet === last.changeSet;
353
+ const exact = added.length === base.deferred.length && base.deferred.every((deferral) => added.filter((row) => matchesDeferral(row, deferral)).length === 1);
354
+ if (!exact) push("S3", "head.packages", "add something other than exactly the installs the base ledger's setup deferred");
355
+ const addedKeys = head.keys.filter((row) => !base.keys.some((other) => sameValue(other, row)));
356
+ const keyed = (row: LedgerKeyRow) =>
357
+ row.changeSet === last.changeSet && added.some((pkg) => dependencyPointer(pkg.placement, pkg.name) === row.pointer && pkg.version === row.value);
358
+ if (!addedKeys.every(keyed)) push("S3", "head.keys", "add a key row that is not for an install the base ledger's setup deferred");
359
+ return out;
360
+ }
361
+
362
+ /**
363
+ * Reads one side's ledger from its bytes: decoded with the strict
364
+ * `readContractDocument()` (refuses a byte order mark, invalid UTF-8, a
365
+ * syntax error and a repeated key by position), then valid under the
366
+ * contract, then compared byte for byte with `serializeInstalledLedger()`'s
367
+ * own re-encoding of it. A caller's decode is never trusted: the bytes prove
368
+ * themselves. Any argument that is not a `Uint8Array` is refused under rule
369
+ * `bytes` before anything reads it.
370
+ */
371
+ function readLedgerBytes(input: unknown, side: "base" | "head"): { ledger: InstalledLedger | null; violations: LedgerViolation[] } {
372
+ const refuse = (message: string) => ({ ledger: null, violations: [{ rule: "bytes" as const, side, path: "", message: `${side} ${message} (rule bytes)` }] });
373
+ if (!(input instanceof Uint8Array)) return refuse("is not a ledger's bytes as a Uint8Array");
374
+ let parsed: unknown;
375
+ try {
376
+ parsed = readContractDocument(input);
377
+ } catch {
378
+ return refuse("is not a ledger's exact bytes: not strict UTF-8 JSON, with no byte order mark and no repeated key");
379
+ }
380
+ const violations = contractViolations(parsed, side, side);
381
+ if (violations.length > 0) return { ledger: null, violations };
382
+ const rendered = Buffer.from(serializeInstalledLedger(parsed as InstalledLedger), "utf8");
383
+ if (!rendered.equals(Buffer.from(input))) return refuse("is not the exact bytes the ledger contract's RENDER section gives this ledger");
384
+ return { ledger: parsed as InstalledLedger, violations: [] };
385
+ }
386
+
387
+ /**
388
+ * Reads a ledger from the exact bytes of clossys/.state/installed.json: the
389
+ * ledger when the bytes are valid under the contract and exactly the bytes
390
+ * RENDER gives it, else null. A repeated key, a byte order mark, invalid
391
+ * UTF-8, other spacing or member order, or any schema or code-rule refusal
392
+ * gives null, never a partial ledger; a non-`Uint8Array` argument also gives
393
+ * null. Well formed is not trusted: see TRUST in the contract.
394
+ */
395
+ export function readInstalledLedger(bytes: Uint8Array): InstalledLedger | null {
396
+ return readLedgerBytes(bytes, "head").ledger;
397
+ }
398
+
399
+ /**
400
+ * Compares a pull request's head ledger with its base's, each given as the
401
+ * exact bytes of clossys/.state/installed.json (base null when the base has
402
+ * none), under the ledger contract's SUCCESSION rules. Each side must be a
403
+ * `Uint8Array`, valid and exactly canonical, or only those reasons are
404
+ * returned: a repeated key, a byte order mark, invalid UTF-8 or a second
405
+ * spelling is never read as an unchanged ledger, and two sides that decode to
406
+ * the same replacement text but differ in their actual bytes are never
407
+ * folded together, because each side is read from its own bytes independently.
408
+ * Then the head is either byte-identical to the base, or one next generation
409
+ * that keeps the base's history; and when that generation is admitted, it
410
+ * installs exactly what the base's setup deferred and changes no other row.
411
+ * An approved next generation is reported as approval-claimed: an
412
+ * unauthenticated claim, never an admission. It checks what the ledgers
413
+ * claim, not the files: whether the tree matches the head ledger is a
414
+ * separate check.
415
+ */
416
+ export function ledgerSuccession(baseBytes: Uint8Array | null, headBytes: Uint8Array): LedgerSuccession {
417
+ const base = baseBytes === null ? { ledger: null, violations: [] } : readLedgerBytes(baseBytes, "base");
418
+ const head = readLedgerBytes(headBytes, "head");
419
+ const invalid = [...base.violations, ...head.violations];
420
+ if (invalid.length > 0 || head.ledger === null) return { change: "next-generation", admission: null, violations: invalid };
421
+ if (baseBytes !== null && Buffer.from(baseBytes).equals(Buffer.from(headBytes))) return { change: "none", admission: null, violations: [] };
422
+ const violations = successionRuleViolations(base.ledger, head.ledger).map((violation) => ({
423
+ ...violation,
424
+ message: `${violation.path} ${violation.message} (rule ${violation.rule})`,
425
+ }));
426
+ if (violations.length > 0) return { change: "next-generation", admission: null, violations };
427
+ const admitted = head.ledger.history.at(-1)!.binding.kind === "admitted";
428
+ return { change: "next-generation", admission: admitted ? "admitted" : "approval-claimed", violations: [] };
429
+ }
430
+
431
+ /** Every object's members, in the order installed-ledger.json declares them (RENDER). */
432
+ export const LEDGER_MEMBER_ORDER = {
433
+ ledger: ["schemaVersion", "kind", "repository", "generation", "history", "files", "keys", "entries", "packages", "deferred"],
434
+ repository: ["id", "nodeId"],
435
+ history: ["generation", "changeSet", "phase", "planDigest", "bundle", "baseCommit", "binding"],
436
+ approvedBinding: ["kind", "subjectDigest"],
437
+ admittedBinding: ["kind", "subjectDigest", "setupChangeSet"],
438
+ file: ["path", "mode", "after", "changeSet"],
439
+ key: ["file", "pointer", "value", "changeSet"],
440
+ entry: ["file", "key", "value", "changeSet"],
441
+ package: ["planItem", "act", "name", "version", "integrity", "placement", "changeSet"],
442
+ deferred: ["planItem", "act", "name", "version", "integrity", "placement", "reason", "changeSet"],
443
+ } as const;
444
+
445
+ function ordered(value: object, members: readonly string[]): Record<string, unknown> {
446
+ const record = value as Record<string, unknown>;
447
+ return Object.fromEntries(members.map((member) => [member, record[member]]));
448
+ }
449
+
450
+ /**
451
+ * The exact bytes of a ledger, as the contract's RENDER section defines
452
+ * them: every object's members in the contract's declared order, two-space
453
+ * JSON and one line feed. Throws, naming positions only, when the ledger is
454
+ * refused, so no refused ledger ever gets bytes.
455
+ */
456
+ export function serializeInstalledLedger(ledger: InstalledLedger): string {
457
+ const violations = installedLedgerViolations(ledger);
458
+ if (violations.length > 0) throw new TypeError(`a refused ledger has no bytes: ${violations.map((violation) => violation.message).join("; ")}`);
459
+ const order = LEDGER_MEMBER_ORDER;
460
+ const canonical = {
461
+ ...ordered(ledger, order.ledger),
462
+ repository: ordered(ledger.repository, order.repository),
463
+ history: ledger.history.map((entry) => ({
464
+ ...ordered(entry, order.history),
465
+ binding: ordered(entry.binding, entry.binding.kind === "approved" ? order.approvedBinding : order.admittedBinding),
466
+ })),
467
+ files: ledger.files.map((row) => ordered(row, order.file)),
468
+ keys: ledger.keys.map((row) => ordered(row, order.key)),
469
+ entries: ledger.entries.map((row) => ordered(row, order.entry)),
470
+ packages: ledger.packages.map((row) => ordered(row, order.package)),
471
+ deferred: ledger.deferred.map((row) => ordered(row, order.deferred)),
472
+ };
473
+ return `${JSON.stringify(canonical, null, 2)}\n`;
474
+ }
475
+
476
+ /** Whether a change-set file entry is a whole file (not derived): the digest section's own test for the same distinction. */
477
+ function isWholeFile(file: RepositoryChangeSet["files"][number]): file is Extract<typeof file, { readonly before: string | null; readonly after: string | null }> {
478
+ return !("derived" in file);
479
+ }
480
+
481
+ const sameIdentity = (left: LedgerPackageRow, right: LedgerPackageRow): boolean =>
482
+ left.planItem === right.planItem && left.act === right.act && left.name === right.name && left.version === right.version && left.integrity === right.integrity && left.placement === right.placement;
483
+
484
+ /**
485
+ * The installed-state ledger a change set with digest `set.changeSetDigest`
486
+ * and binding `binding` writes over `previous` (null at generation 0):
487
+ * installed-ledger.json's RENDER section. Returns
488
+ * serializeInstalledLedger(ledger)'s bytes, so a result that fails the
489
+ * contract or its code rules throws before it is returned rather than being
490
+ * handed back malformed. `planPackages` gives the plan's identity for each
491
+ * planItem, which a deferred row needs since a change set's own deferral
492
+ * carries none. Throws a TypeError, naming only positions, never values,
493
+ * when: `set.changeSetDigest` is not this change set's own digest;
494
+ * `set.ledger.generation` is not previous's generation (0 with no
495
+ * previous); previous's repository differs from the set's; a deferred
496
+ * row's planItem names no plan package; an apply set keeps a whole file
497
+ * (before equal to after) at a path previous holds no row for, which would
498
+ * be an adoption row and only a setup set may adopt one; or an apply set
499
+ * writes a whole file whose before does not match previous's after at that
500
+ * path, or at a path previous holds no row for. The one apply-phase add is
501
+ * the Launcher guide (AGENTS_GUIDE_PATH, mode 100644, before null, after the
502
+ * digest of AGENTS_GUIDE_TEXT) over a previous ledger that holds no row for
503
+ * the path in any letter case: the row is written like any other. An add at
504
+ * any other path, or of other bytes, still throws.
505
+ */
506
+ export function renderInstalledLedger(
507
+ previous: InstalledLedger | null,
508
+ set: RepositoryChangeSet,
509
+ binding: ApprovalBinding,
510
+ planPackages: readonly (LedgerPackageIdentity & { readonly act: "install" | "pin-starter" })[],
511
+ ): string {
512
+ const digest = changeSetDigest(set);
513
+ if (set.changeSetDigest !== digest) throw new TypeError("set.changeSetDigest is not this change set's own digest");
514
+ const d = digest;
515
+
516
+ const baseGeneration = previous?.generation ?? 0;
517
+ if (set.ledger.generation !== baseGeneration) throw new TypeError("set.ledger.generation does not match previous's generation");
518
+ if (previous !== null && (previous.repository.id !== set.repository.id || previous.repository.nodeId !== set.repository.nodeId)) {
519
+ throw new TypeError("previous.repository is not the set's repository");
520
+ }
521
+
522
+ const generation = set.ledger.generation + 1;
523
+ const history: LedgerHistoryEntry[] = [
524
+ ...(previous?.history ?? []),
525
+ { generation, changeSet: d, phase: set.phase, planDigest: set.planDigest, bundle: set.bundle, baseCommit: set.repository.baseCommit, binding },
526
+ ];
527
+
528
+ // files: previous's rows, then each whole file of the set (a derived file, the ledger or the lockfile, is skipped).
529
+ const filesByPath = new Map<string, LedgerFileRow>();
530
+ for (const row of previous?.files ?? []) filesByPath.set(row.path.toLowerCase(), row);
531
+ set.files.forEach((file, index) => {
532
+ if (!isWholeFile(file)) return;
533
+ const key = file.path.toLowerCase();
534
+ if (file.after === null) {
535
+ filesByPath.delete(key);
536
+ return;
537
+ }
538
+ const existing = filesByPath.get(key);
539
+ const isKeep = file.before === file.after;
540
+ if (isKeep && existing !== undefined && existing.after === file.after) return; // unchanged: previous's row already holds this after
541
+ if (isKeep && set.phase === "apply") throw new TypeError(`files[${index}] keeps a file previous holds no row for, which only a setup set may adopt`);
542
+ // The one apply-phase add: the Launcher guide's own bytes, for an install set up before it existed, where previous holds no row at its
543
+ // path. A row at that path that is not 100644 would also fail the output's own validation (L5) below.
544
+ const guideAdd =
545
+ previous !== null && file.path === AGENTS_GUIDE_PATH && file.before === null && file.mode === "100644" && file.after === AGENTS_GUIDE_DIGEST && existing === undefined;
546
+ if (set.phase === "apply" && !isKeep && !guideAdd && (existing === undefined || existing.after !== file.before)) {
547
+ throw new TypeError(`files[${index}] has no matching previous row for an apply update`);
548
+ }
549
+ filesByPath.set(key, { path: file.path, mode: file.mode, after: file.after, changeSet: d });
550
+ });
551
+ const files = canonicalOrder([...filesByPath.values()], (row) => [row.path]);
552
+
553
+ // keys: previous's rows, then each key of the set.
554
+ const keysByPointer = new Map<string, LedgerKeyRow>();
555
+ for (const row of previous?.keys ?? []) keysByPointer.set(row.pointer, row);
556
+ for (const key of set.keys) {
557
+ if (key.after === null) keysByPointer.delete(key.pointer);
558
+ else keysByPointer.set(key.pointer, { file: "package.json", pointer: key.pointer, value: key.after, changeSet: d });
559
+ }
560
+ const keys = canonicalOrder([...keysByPointer.values()], (row) => [row.file, row.pointer]);
561
+
562
+ // entries: previous's rows, then one row per exempt-release-age item and per declare-root-entry entry the set adds, unless a path refusal names the item.
563
+ const entries: LedgerEntryRow[] = [...(previous?.entries ?? [])];
564
+ const hasEntry = (file: string, entryKey: string, value: string) => entries.some((row) => row.file === file && row.key === entryKey && row.value === value);
565
+ const pathRefused = (path: string) => set.refused.some((refusal) => "path" in refusal && refusal.path === path);
566
+ for (const item of set.items) {
567
+ if (item.act === "exempt-release-age") {
568
+ if (pathRefused(item.path)) continue;
569
+ const value = `${item.scope}/*`;
570
+ if (hasEntry(item.path, EXEMPTION_SURFACES[item.surface].key, value)) continue;
571
+ entries.push(
572
+ item.surface === "pnpm-workspace"
573
+ ? { file: "pnpm-workspace.yaml", key: "minimumReleaseAgeExclude", value, changeSet: d }
574
+ : { file: ".yarnrc.yml", key: "npmPreapprovedPackages", value, changeSet: d },
575
+ );
576
+ } else if (item.act === "declare-root-entry") {
577
+ if (pathRefused(item.path)) continue;
578
+ for (const entry of item.entries) {
579
+ if (!hasEntry(item.path, "rootEntries", entry.name)) entries.push({ file: item.path, key: "rootEntries", value: entry.name, changeSet: d });
580
+ }
581
+ }
582
+ }
583
+ const orderedEntries = canonicalOrder(entries, (row) => [row.file, row.key, row.value]);
584
+
585
+ // packages: previous's rows, then each install or pin-starter item, unless a key refusal names its item.
586
+ let packages: LedgerPackageRow[] = [...(previous?.packages ?? [])];
587
+ const keyRefused = (itemId: string) => set.refused.some((refusal) => "pointer" in refusal && refusal.item === itemId);
588
+ for (const item of set.items) {
589
+ if (item.act !== "install" && item.act !== "pin-starter") continue;
590
+ if (keyRefused(item.id)) continue;
591
+ const newRow: LedgerPackageRow = {
592
+ planItem: item.planItem,
593
+ act: item.act,
594
+ name: item.package.name,
595
+ version: item.package.version,
596
+ integrity: item.package.integrity,
597
+ placement: item.placement,
598
+ changeSet: d,
599
+ };
600
+ const matched = packages.filter((row) => row.planItem === newRow.planItem || row.name === newRow.name);
601
+ packages = packages.filter((row) => row.planItem !== newRow.planItem && row.name !== newRow.name);
602
+ const identical = matched.find((row) => sameIdentity(row, newRow));
603
+ packages.push(identical ?? newRow);
604
+ }
605
+ const orderedPackages = canonicalOrder(packages, (row) => [row.planItem]);
606
+
607
+ // deferred: exactly the set's deferred, with the plan's identity for each planItem.
608
+ const deferred: LedgerDeferredRow[] = set.deferred.map((deferral, index) => {
609
+ const identity = planPackages.find((candidate) => candidate.planItem === deferral.planItem);
610
+ if (identity === undefined) throw new TypeError(`deferred[${index}].planItem names no plan package identity`);
611
+ return {
612
+ planItem: identity.planItem,
613
+ act: "install",
614
+ name: identity.name,
615
+ version: identity.version,
616
+ integrity: identity.integrity,
617
+ placement: identity.placement,
618
+ reason: deferral.reason,
619
+ changeSet: d,
620
+ };
621
+ });
622
+ const orderedDeferred = canonicalOrder(deferred, (row) => [row.planItem]);
623
+
624
+ const ledger: InstalledLedger = {
625
+ schemaVersion: 1,
626
+ kind: "clossys.installed-ledger",
627
+ repository: { id: set.repository.id, nodeId: set.repository.nodeId },
628
+ generation,
629
+ history,
630
+ files,
631
+ keys,
632
+ entries: orderedEntries,
633
+ packages: orderedPackages,
634
+ deferred: orderedDeferred,
635
+ };
636
+ return serializeInstalledLedger(ledger);
637
+ }