@clossys/launcher 0.3.1 → 0.5.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 (290) hide show
  1. package/README.md +1373 -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 +467 -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 +804 -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 +163 -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 +403 -0
  46. package/dist/change-set-contract.d.ts.map +1 -0
  47. package/dist/change-set-contract.js +781 -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/existing-declaration-adoption.check.d.ts +2 -0
  69. package/dist/existing-declaration-adoption.check.d.ts.map +1 -0
  70. package/dist/existing-declaration-adoption.check.js +10 -0
  71. package/dist/existing-declaration-adoption.check.js.map +1 -0
  72. package/dist/generated/contract-schema.generated.d.ts +97 -0
  73. package/dist/generated/contract-schema.generated.d.ts.map +1 -0
  74. package/dist/generated/contract-schema.generated.js +496 -0
  75. package/dist/generated/contract-schema.generated.js.map +1 -0
  76. package/dist/generated/package-scope.generated.d.ts +6 -0
  77. package/dist/generated/package-scope.generated.d.ts.map +1 -0
  78. package/dist/generated/package-scope.generated.js +10 -0
  79. package/dist/generated/package-scope.generated.js.map +1 -0
  80. package/dist/generated/plan-contracts.generated.d.ts +3 -0
  81. package/dist/generated/plan-contracts.generated.d.ts.map +1 -0
  82. package/dist/generated/plan-contracts.generated.js +3101 -0
  83. package/dist/generated/plan-contracts.generated.js.map +1 -0
  84. package/dist/host.d.ts.map +1 -1
  85. package/dist/host.js +11 -0
  86. package/dist/host.js.map +1 -1
  87. package/dist/identity.d.ts +15 -0
  88. package/dist/identity.d.ts.map +1 -0
  89. package/dist/identity.js +48 -0
  90. package/dist/identity.js.map +1 -0
  91. package/dist/index.d.ts +35 -5
  92. package/dist/index.d.ts.map +1 -1
  93. package/dist/index.js +19 -2
  94. package/dist/index.js.map +1 -1
  95. package/dist/inventory-adoption.d.ts +24 -5
  96. package/dist/inventory-adoption.d.ts.map +1 -1
  97. package/dist/inventory-adoption.js +70 -25
  98. package/dist/inventory-adoption.js.map +1 -1
  99. package/dist/inventory-choice.d.ts +40 -0
  100. package/dist/inventory-choice.d.ts.map +1 -0
  101. package/dist/inventory-choice.js +156 -0
  102. package/dist/inventory-choice.js.map +1 -0
  103. package/dist/inventory-contract.d.ts +89 -0
  104. package/dist/inventory-contract.d.ts.map +1 -0
  105. package/dist/inventory-contract.js +121 -0
  106. package/dist/inventory-contract.js.map +1 -0
  107. package/dist/key-editor.d.ts +30 -0
  108. package/dist/key-editor.d.ts.map +1 -0
  109. package/dist/key-editor.js +445 -0
  110. package/dist/key-editor.js.map +1 -0
  111. package/dist/ledger-contract.d.ts +190 -0
  112. package/dist/ledger-contract.d.ts.map +1 -0
  113. package/dist/ledger-contract.js +555 -0
  114. package/dist/ledger-contract.js.map +1 -0
  115. package/dist/ledger-trust.d.ts +90 -0
  116. package/dist/ledger-trust.d.ts.map +1 -0
  117. package/dist/ledger-trust.js +203 -0
  118. package/dist/ledger-trust.js.map +1 -0
  119. package/dist/lockfile-invariants.d.ts +48 -0
  120. package/dist/lockfile-invariants.d.ts.map +1 -0
  121. package/dist/lockfile-invariants.js +375 -0
  122. package/dist/lockfile-invariants.js.map +1 -0
  123. package/dist/lockfile-readers.d.ts +72 -0
  124. package/dist/lockfile-readers.d.ts.map +1 -0
  125. package/dist/lockfile-readers.js +713 -0
  126. package/dist/lockfile-readers.js.map +1 -0
  127. package/dist/lockfile-regen.d.ts +106 -0
  128. package/dist/lockfile-regen.d.ts.map +1 -0
  129. package/dist/lockfile-regen.js +760 -0
  130. package/dist/lockfile-regen.js.map +1 -0
  131. package/dist/lockfile-tool-env.d.ts +29 -0
  132. package/dist/lockfile-tool-env.d.ts.map +1 -0
  133. package/dist/lockfile-tool-env.js +111 -0
  134. package/dist/lockfile-tool-env.js.map +1 -0
  135. package/dist/materialize.d.ts +113 -0
  136. package/dist/materialize.d.ts.map +1 -0
  137. package/dist/materialize.js +881 -0
  138. package/dist/materialize.js.map +1 -0
  139. package/dist/observe-repository.d.ts +90 -0
  140. package/dist/observe-repository.d.ts.map +1 -0
  141. package/dist/observe-repository.js +1367 -0
  142. package/dist/observe-repository.js.map +1 -0
  143. package/dist/plan-bundle-setup-fixture.d.ts +68 -0
  144. package/dist/plan-bundle-setup-fixture.d.ts.map +1 -0
  145. package/dist/plan-bundle-setup-fixture.js +167 -0
  146. package/dist/plan-bundle-setup-fixture.js.map +1 -0
  147. package/dist/plan-bundle.d.ts +256 -0
  148. package/dist/plan-bundle.d.ts.map +1 -0
  149. package/dist/plan-bundle.js +882 -0
  150. package/dist/plan-bundle.js.map +1 -0
  151. package/dist/plan-command.d.ts +29 -0
  152. package/dist/plan-command.d.ts.map +1 -0
  153. package/dist/plan-command.js +523 -0
  154. package/dist/plan-command.js.map +1 -0
  155. package/dist/plan-contract.d.ts +153 -0
  156. package/dist/plan-contract.d.ts.map +1 -0
  157. package/dist/plan-contract.js +61 -0
  158. package/dist/plan-contract.js.map +1 -0
  159. package/dist/plan-digest.d.ts +25 -0
  160. package/dist/plan-digest.d.ts.map +1 -0
  161. package/dist/plan-digest.js +106 -0
  162. package/dist/plan-digest.js.map +1 -0
  163. package/dist/plan-rules.d.ts +23 -0
  164. package/dist/plan-rules.d.ts.map +1 -0
  165. package/dist/plan-rules.js +177 -0
  166. package/dist/plan-rules.js.map +1 -0
  167. package/dist/planned-bundle.d.ts +20 -0
  168. package/dist/planned-bundle.d.ts.map +1 -0
  169. package/dist/planned-bundle.js +191 -0
  170. package/dist/planned-bundle.js.map +1 -0
  171. package/dist/product-repository.d.ts +4 -0
  172. package/dist/product-repository.d.ts.map +1 -1
  173. package/dist/product-repository.js +9 -1
  174. package/dist/product-repository.js.map +1 -1
  175. package/dist/provenance-gate.d.ts +48 -0
  176. package/dist/provenance-gate.d.ts.map +1 -0
  177. package/dist/provenance-gate.js +324 -0
  178. package/dist/provenance-gate.js.map +1 -0
  179. package/dist/pull-request-body.d.ts +45 -0
  180. package/dist/pull-request-body.d.ts.map +1 -0
  181. package/dist/pull-request-body.js +232 -0
  182. package/dist/pull-request-body.js.map +1 -0
  183. package/dist/registry-snapshot.d.ts +141 -0
  184. package/dist/registry-snapshot.d.ts.map +1 -0
  185. package/dist/registry-snapshot.js +483 -0
  186. package/dist/registry-snapshot.js.map +1 -0
  187. package/dist/release-age-edit.d.ts +52 -0
  188. package/dist/release-age-edit.d.ts.map +1 -0
  189. package/dist/release-age-edit.js +413 -0
  190. package/dist/release-age-edit.js.map +1 -0
  191. package/dist/root-entries.d.ts +36 -0
  192. package/dist/root-entries.d.ts.map +1 -0
  193. package/dist/root-entries.js +80 -0
  194. package/dist/root-entries.js.map +1 -0
  195. package/dist/setup-template-scripts.d.ts +36 -0
  196. package/dist/setup-template-scripts.d.ts.map +1 -0
  197. package/dist/setup-template-scripts.js +568 -0
  198. package/dist/setup-template-scripts.js.map +1 -0
  199. package/dist/setup-templates.d.ts +55 -0
  200. package/dist/setup-templates.d.ts.map +1 -0
  201. package/dist/setup-templates.js +438 -0
  202. package/dist/setup-templates.js.map +1 -0
  203. package/dist/skills.d.ts +34 -1
  204. package/dist/skills.d.ts.map +1 -1
  205. package/dist/skills.js +129 -17
  206. package/dist/skills.js.map +1 -1
  207. package/dist/status.d.ts +63 -0
  208. package/dist/status.d.ts.map +1 -0
  209. package/dist/status.js +539 -0
  210. package/dist/status.js.map +1 -0
  211. package/dist/types.d.ts +151 -13
  212. package/dist/types.d.ts.map +1 -1
  213. package/package.json +4 -4
  214. package/skeleton/README.md +14 -9
  215. package/skeleton/package.json +2 -1
  216. package/skill/SKILL.md +23 -7
  217. package/skill-catalogue/advisor/SKILL.md +59 -6
  218. package/skill-catalogue/architect/SKILL.md +2 -2
  219. package/skill-catalogue/bouncer/SKILL.md +2 -2
  220. package/skill-catalogue/builder/SKILL.md +2 -2
  221. package/skill-catalogue/butler/SKILL.md +2 -2
  222. package/skill-catalogue/controller/SKILL.md +2 -2
  223. package/skill-catalogue/customer/SKILL.md +2 -2
  224. package/skill-catalogue/designer/SKILL.md +4 -2
  225. package/skill-catalogue/giver/SKILL.md +2 -2
  226. package/skill-catalogue/influencer/SKILL.md +2 -2
  227. package/skill-catalogue/inspector/SKILL.md +2 -2
  228. package/skill-catalogue/integrator/SKILL.md +2 -2
  229. package/skill-catalogue/keeper/SKILL.md +2 -2
  230. package/skill-catalogue/launcher/SKILL.md +23 -7
  231. package/skill-catalogue/locksmith/SKILL.md +2 -2
  232. package/skill-catalogue/messenger/SKILL.md +2 -2
  233. package/skill-catalogue/observer/SKILL.md +2 -2
  234. package/skill-catalogue/publisher/SKILL.md +2 -2
  235. package/skill-catalogue/starter/SKILL.md +3 -2
  236. package/skill-catalogue/strategist/SKILL.md +12 -4
  237. package/skill-catalogue/writer/SKILL.md +2 -2
  238. package/src/admission-fixture.ts +585 -0
  239. package/src/admission.ts +819 -0
  240. package/src/agents-guide.ts +29 -0
  241. package/src/apply-command-options.check.ts +27 -0
  242. package/src/apply-plan-cli.ts +454 -14
  243. package/src/apply-plan.ts +112 -124
  244. package/src/apply-step-fixture.ts +236 -0
  245. package/src/apply-store.ts +584 -0
  246. package/src/approval-sheet.ts +170 -0
  247. package/src/body-command.ts +162 -0
  248. package/src/change-set-contract.ts +987 -0
  249. package/src/change-set-digest.ts +70 -0
  250. package/src/check-cli.ts +14 -3
  251. package/src/cli.ts +90 -22
  252. package/src/core.ts +973 -275
  253. package/src/dry-materialize.ts +353 -0
  254. package/src/existing-declaration-adoption.check.ts +12 -0
  255. package/src/generated/contract-schema.generated.ts +520 -0
  256. package/src/generated/package-scope.generated.ts +10 -0
  257. package/src/generated/plan-contracts.generated.ts +3101 -0
  258. package/src/host.ts +10 -0
  259. package/src/identity.ts +51 -0
  260. package/src/index.ts +74 -3
  261. package/src/inventory-adoption.ts +107 -29
  262. package/src/inventory-choice.ts +172 -0
  263. package/src/inventory-contract.ts +166 -0
  264. package/src/key-editor.ts +446 -0
  265. package/src/ledger-contract.ts +660 -0
  266. package/src/ledger-trust.ts +272 -0
  267. package/src/lockfile-invariants.ts +421 -0
  268. package/src/lockfile-readers.ts +749 -0
  269. package/src/lockfile-regen.ts +851 -0
  270. package/src/lockfile-tool-env.ts +131 -0
  271. package/src/materialize.ts +915 -0
  272. package/src/observe-repository.ts +1365 -0
  273. package/src/plan-bundle-setup-fixture.ts +200 -0
  274. package/src/plan-bundle.ts +1014 -0
  275. package/src/plan-command.ts +532 -0
  276. package/src/plan-contract.ts +179 -0
  277. package/src/plan-digest.ts +102 -0
  278. package/src/plan-rules.ts +188 -0
  279. package/src/planned-bundle.ts +211 -0
  280. package/src/product-repository.ts +10 -1
  281. package/src/provenance-gate.ts +352 -0
  282. package/src/pull-request-body.ts +261 -0
  283. package/src/registry-snapshot.ts +534 -0
  284. package/src/release-age-edit.ts +430 -0
  285. package/src/root-entries.ts +81 -0
  286. package/src/setup-template-scripts.ts +580 -0
  287. package/src/setup-templates.ts +479 -0
  288. package/src/skills.ts +161 -18
  289. package/src/status.ts +557 -0
  290. package/src/types.ts +148 -13
@@ -0,0 +1,987 @@
1
+ // The repository change set and the apply bundle (issue #1178), validated
2
+ // against the shared contracts docs/contracts/repository-change-set.json and
3
+ // apply-bundle.json -- in the public repository, not shipped in this
4
+ // package. This package's build packs both into src/generated/ beside the
5
+ // plan and brief contracts, and validates them with the same generated copy
6
+ // of the one contract checker. The TypeScript types below describe the same
7
+ // shapes for callers; they validate nothing.
8
+
9
+ import { createHash } from "node:crypto";
10
+ import { formatContractViolation, validateAgainstContract } from "./generated/contract-schema.generated.js";
11
+ import { PACKAGE_SCOPE } from "./generated/package-scope.generated.js";
12
+ import { bundleDigest, changeSetDigest } from "./change-set-digest.js";
13
+ import { AGENTS_GUIDE_PATH } from "./agents-guide.js";
14
+ import { loadContract } from "./plan-contract.js";
15
+ import type { ValidationResult } from "./plan-contract.js";
16
+
17
+ export type RepositoryVisibility = "private" | "internal" | "public";
18
+ export type ChangeSetPhase = "setup" | "apply";
19
+ export type PackageManagerKind = "npm" | "pnpm" | "yarn" | "none";
20
+ export type LockfileName = "package-lock.json" | "pnpm-lock.yaml" | "yarn.lock" | "none";
21
+ export type ReleaseAgeSurfaceKind = "pnpm-workspace" | "yarnrc" | "npmrc";
22
+ /** The surfaces an exempt-release-age item can write: npm has no exemption key, so never .npmrc. */
23
+ export type ExemptionSurfaceKind = "pnpm-workspace" | "yarnrc";
24
+ export type DependencyPlacement = "dependencies" | "devDependencies";
25
+ /** A discovery root: a directory agent hosts read skills from, holding a link to each composed skill. */
26
+ export type DiscoveryRoot = ".claude/skills" | ".cursor/skills";
27
+ export type WriteRecordSource = "engagement-brief" | "agents-pointer" | "claude-loader" | "agents-guide";
28
+
29
+ /** Bounded stable registry semver declarations: exact, caret or tilde, with a resolved lower-bound match. */
30
+ export function existingDeclarationVersionMatches(literal: string, resolved: string): boolean {
31
+ const match=/^([~^]?)(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/.exec(literal);
32
+ const actual=/^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/.exec(resolved);
33
+ if(!match || !actual)return false;
34
+ const lower=match.slice(2).map(value=>BigInt(value));
35
+ const version=actual.slice(1).map(value=>BigInt(value));
36
+ const compared=version[0]!==lower[0]?version[0]!>lower[0]!:version[1]!==lower[1]?version[1]!>lower[1]!:version[2]!>=lower[2]!;
37
+ if(!compared)return false;
38
+ if(match[1] === "")return resolved === literal;
39
+ if(match[1] === "~")return version[0]===lower[0] && version[1]===lower[1];
40
+ if(lower[0]!==0n)return version[0]===lower[0];
41
+ if(lower[1]!==0n)return version[0]===0n && version[1]===lower[1];
42
+ return version[0]===0n && version[1]===0n && version[2]===lower[2];
43
+ }
44
+
45
+ /** One exact package: one version and one sha512 integrity value. */
46
+ export interface PinnedPackage {
47
+ readonly name: string;
48
+ readonly version: string;
49
+ readonly integrity: string;
50
+ }
51
+
52
+ export type ChangeSetItem =
53
+ | { readonly id: string; readonly act: "write-record"; readonly source: WriteRecordSource }
54
+ | { readonly id: string; readonly act: "compose-skills"; readonly roles: readonly string[] }
55
+ | {
56
+ readonly id: string;
57
+ readonly act: "install" | "pin-starter";
58
+ readonly planItem: string;
59
+ readonly package: PinnedPackage;
60
+ readonly placement: DependencyPlacement;
61
+ /** True when the default branch already has this exact version and integrity at this placement; such an item writes nothing. */
62
+ readonly satisfiedInBase: boolean;
63
+ }
64
+ | { readonly id: string; readonly act: "exempt-release-age"; readonly scope: string; readonly surface: ExemptionSurfaceKind; readonly path: string }
65
+ | { readonly id: string; readonly act: "declare-root-entry"; readonly path: string; readonly entries: readonly RootEntryDeclaration[] }
66
+ | { readonly id: string; readonly act: "write-ledger" | "add-caller-workflow" | "write-starter-request" | "add-ci-template" | "add-path-scope-job" };
67
+
68
+ /** One root entry a declare-root-entry item adds to a Controller profile: always an extension the repository allows. */
69
+ export interface RootEntryDeclaration {
70
+ readonly name: string;
71
+ readonly classification: "extension";
72
+ readonly disposition: "allowed";
73
+ }
74
+
75
+ /** What was observed of the Controller repository profile on the default branch (code rule C13). */
76
+ export interface RepositoryProfileObservation {
77
+ /** The profile Controller would locate. */
78
+ readonly path: string;
79
+ /** none: no root vocabulary Controller checks; checked: one it checks; unparseable: not readable as a profile with a well-formed rootEntries. */
80
+ readonly rootVocabulary: "none" | "checked" | "unparseable";
81
+ /** Root names the set introduces that the vocabulary does not declare. */
82
+ readonly undeclaredRoots: readonly string[];
83
+ /** Root names the set introduces that the vocabulary declares as prohibited. */
84
+ readonly prohibitedRoots: readonly string[];
85
+ }
86
+
87
+ /** `sha256:` and 64 hex digits of a file's bytes, or null when the file is absent. */
88
+ export type ContentDigest = string | null;
89
+
90
+ /** A file whose exact bytes the set writes; mode 120000 is a discovery link, whose bytes are its target. */
91
+ export interface WholeFileChange {
92
+ readonly path: string;
93
+ readonly mode: "100644" | "120000";
94
+ readonly before: ContentDigest;
95
+ readonly after: ContentDigest;
96
+ readonly item: string;
97
+ }
98
+
99
+ /** The lockfile resolves this package to this exact version and integrity. */
100
+ export interface PackageInvariant extends PinnedPackage {
101
+ readonly item: string;
102
+ }
103
+
104
+ /** The ledger the set writes has this generation. */
105
+ export interface LedgerInvariant {
106
+ readonly ledgerGeneration: number;
107
+ }
108
+
109
+ /** A file checked by its invariants, never by its bytes; its `before` and `after` are outside the digest. */
110
+ export interface DerivedFileChange {
111
+ readonly path: string;
112
+ readonly mode: "100644";
113
+ readonly derived: true;
114
+ readonly item: string;
115
+ readonly invariants: readonly (PackageInvariant | LedgerInvariant)[];
116
+ readonly before?: ContentDigest;
117
+ readonly after?: ContentDigest;
118
+ }
119
+
120
+ export type FileChange = WholeFileChange | DerivedFileChange;
121
+
122
+ /** Explicit consent for one existing root declaration; registry metadata is not installed-byte evidence. */
123
+ export interface ExistingDeclarationAdoption {
124
+ readonly file: "package.json";
125
+ readonly placement: DependencyPlacement;
126
+ readonly name: string;
127
+ readonly beforeVersion: string;
128
+ readonly beforeResolved: PinnedPackage;
129
+ readonly desired: PinnedPackage & {readonly planItem: string; readonly act: "install" | "pin-starter"; readonly placement: DependencyPlacement};
130
+ readonly observedBaseCommit: string;
131
+ readonly desiredSnapshotDigest: string;
132
+ readonly consent: "adopt-existing-declaration";
133
+ }
134
+
135
+ /** One owned key inside package.json, by JSON pointer. */
136
+ export interface KeyChange {
137
+ readonly file: "package.json";
138
+ readonly pointer: string;
139
+ readonly before: string | null;
140
+ readonly after: string | null;
141
+ readonly item: string;
142
+ }
143
+
144
+ export type RefusalReason =
145
+ | "unowned-existing"
146
+ | "client-edited"
147
+ | "deleted"
148
+ | "manifest-absent"
149
+ | "unsafe-path"
150
+ | "release-age-surface-conflict"
151
+ | "release-age-surface-unparseable"
152
+ | "root-vocabulary-unknown"
153
+ | "root-entry-prohibited"
154
+ | "skills-root-is-link";
155
+
156
+ export type ChangeSetRefusal =
157
+ | { readonly path: string; readonly reason: RefusalReason; readonly item: string }
158
+ | { readonly file: "package.json"; readonly pointer: string; readonly reason: RefusalReason; readonly item: string };
159
+
160
+ export interface ChangeSetDeferral {
161
+ readonly planItem: string;
162
+ readonly reason: "after-setup";
163
+ }
164
+
165
+ /** One repository's change set (repository-change-set.json, in the public repository, not shipped in this package). */
166
+ export type AgentProvenance = "codex" | "claude" | "cursor";
167
+
168
+ export interface RepositoryChangeSet {
169
+ readonly schemaVersion: 1;
170
+ readonly kind: "clossys.repository-change-set";
171
+ readonly existingDeclarationAdoptions?: readonly ExistingDeclarationAdoption[];
172
+ readonly producer: { readonly name: string; readonly version: string };
173
+ readonly planDigest: string;
174
+ readonly repository: {
175
+ readonly id: string;
176
+ readonly nodeId: string;
177
+ readonly visibility: RepositoryVisibility;
178
+ readonly defaultBranch: string;
179
+ readonly baseCommit: string;
180
+ };
181
+ readonly ledger: { readonly generation: number };
182
+ readonly phase: ChangeSetPhase;
183
+ readonly engine: PinnedPackage;
184
+ /** The exact Integrator package the hub pins; a product repository's CI runs its provenance check by this version. */
185
+ readonly integrator: PinnedPackage;
186
+ readonly observed: {
187
+ readonly packageManager: PackageManagerKind;
188
+ readonly lockfile: LockfileName;
189
+ readonly releaseAgeSurfaces: readonly { readonly surface: ReleaseAgeSurfaceKind; readonly path: string }[];
190
+ /** Whether the default branch has a workflow of its own, one whose file name does not start with clossys-. */
191
+ readonly consumerCi: boolean;
192
+ /** The discovery roots that are, or lie under, a symbolic link on the default branch; no link is written under them. */
193
+ readonly symlinkedSkillRoots: readonly DiscoveryRoot[];
194
+ /** The Controller repository profile the default branch declares, or null. */
195
+ readonly repositoryProfile: RepositoryProfileObservation | null;
196
+ /** Which of .agents, .agents/skills and .agents/skills/clossys-<role> is a symbolic link on the default branch. */
197
+ readonly linkedAgentsPaths: readonly string[];
198
+ };
199
+ readonly items: readonly ChangeSetItem[];
200
+ readonly files: readonly FileChange[];
201
+ readonly keys: readonly KeyChange[];
202
+ readonly refused: readonly ChangeSetRefusal[];
203
+ readonly deferred: readonly ChangeSetDeferral[];
204
+ readonly pathAllowList: readonly string[];
205
+ /** Optional authoring agent namespace, covered by the change-set digest. */
206
+ readonly agentProvenance?: AgentProvenance;
207
+ readonly branch: string;
208
+ readonly bundle: string;
209
+ readonly pullRequest: { readonly title: string; readonly bodySha256?: string };
210
+ readonly inverse?: string;
211
+ /** Tool versions recorded for diagnosis when derived files are regenerated; outside the digest. */
212
+ readonly tooling?: readonly { readonly tool: "node" | "npm" | "pnpm" | "yarn"; readonly version: string }[];
213
+ /** Whole-file bytes materialize reads when the set is stored; outside the digest. */
214
+ readonly texts?: readonly { readonly path: string; readonly text: string }[];
215
+ readonly changeSetDigest: string;
216
+ }
217
+
218
+ export type CheckVerdict = "satisfied" | "violated" | "indeterminate";
219
+ export type ApplyCheckId = "V1" | "V2" | "V3" | "V4" | "V5" | "V6" | "V7" | "V8" | "V9";
220
+
221
+ export interface ApplyCheck {
222
+ readonly check: ApplyCheckId;
223
+ readonly verdict: CheckVerdict;
224
+ readonly rule?: string;
225
+ }
226
+
227
+ /**
228
+ * On what authority a change set is written (installed-ledger.json,
229
+ * definitions.binding): approved as a member of the bundle whose digest the
230
+ * approving decision names, or admitted as the apply set that follows an
231
+ * approved setup set under the one-approval rule.
232
+ */
233
+ export type ApprovalBinding =
234
+ | { readonly kind: "approved"; readonly subjectDigest: string }
235
+ | { readonly kind: "admitted"; readonly subjectDigest: string; readonly setupChangeSet: string };
236
+
237
+ export type ApplyBundleRepository =
238
+ | {
239
+ readonly id: string;
240
+ readonly verdict: CheckVerdict;
241
+ readonly phase: ChangeSetPhase;
242
+ readonly changeSet: string;
243
+ readonly checks: readonly ApplyCheck[];
244
+ /** Only in a planned bundle, when V1 to V9 all passed and an approval binds the set (code rule A6). */
245
+ readonly state?: "planned";
246
+ /** Only in a planned bundle, when every V3 check passed (code rule A7). */
247
+ readonly binding?: ApprovalBinding;
248
+ }
249
+ | { readonly id: string; readonly verdict: "violated" | "indeterminate"; readonly reason: string; readonly checks: readonly ApplyCheck[] };
250
+
251
+ /**
252
+ * One application attempt (apply-bundle.json, in the public repository, not
253
+ * shipped in this package). A report bundle claims no repository state; a
254
+ * planned bundle records which repositories passed V1 to V9 and are bound by
255
+ * an approval. See its contract.
256
+ */
257
+ export interface ApplyBundle {
258
+ readonly schemaVersion: 1;
259
+ readonly kind: "clossys.apply-bundle";
260
+ readonly mode: "report" | "planned";
261
+ readonly plan: { readonly path: "clossys/advisor/plan.json"; readonly digest: string; readonly committed: boolean };
262
+ readonly snapshot: { readonly path: "clossys/.state/apply/registry-snapshot.json"; readonly digest: string } | null;
263
+ readonly engine: PinnedPackage;
264
+ readonly authorization: { readonly planDigest: string; readonly expiresAt: string } | null;
265
+ readonly computedAt: string;
266
+ readonly repositories: readonly ApplyBundleRepository[];
267
+ readonly bundleDigest: string;
268
+ }
269
+
270
+ /** The path of the installed-state ledger every change set writes. */
271
+ export const LEDGER_PATH = "clossys/.state/installed.json";
272
+
273
+ /** Where the engagement brief is written. */
274
+ export const BRIEF_PATH = "clossys/brief.json";
275
+
276
+ /** Every file name a lockfile can have. */
277
+ export const LOCKFILE_NAMES: readonly string[] = ["package-lock.json", "pnpm-lock.yaml", "yarn.lock"];
278
+
279
+ const DEFAULT_LOCKFILE: Readonly<Record<Exclude<PackageManagerKind, "none">, Exclude<LockfileName, "none">>> = {
280
+ npm: "package-lock.json",
281
+ pnpm: "pnpm-lock.yaml",
282
+ yarn: "yarn.lock",
283
+ };
284
+
285
+ /**
286
+ * The repository's lockfile path, as the change-set contract defines it: the
287
+ * observed lockfile, or, when there is none, the one its package manager
288
+ * writes; null when the repository has no package manager.
289
+ */
290
+ export function lockfilePath(observed: { readonly packageManager: PackageManagerKind; readonly lockfile: LockfileName }): string | null {
291
+ if (observed.lockfile !== "none") return observed.lockfile;
292
+ return observed.packageManager === "none" ? null : DEFAULT_LOCKFILE[observed.packageManager];
293
+ }
294
+
295
+ /** Where a composed skill is written for a role. */
296
+ export function skillPath(role: string): string {
297
+ return `.agents/skills/clossys-${role}/SKILL.md`;
298
+ }
299
+
300
+ /** Where the composed-skill manifest is written: one per compose-skills item (code rule C9). */
301
+ export const SKILLS_MANIFEST_PATH = "clossys/.state/skills.json";
302
+
303
+ /** Controller's limit on a root entry name, in UTF-16 code units. */
304
+ export const MAX_ROOT_NAME_UNITS = 255;
305
+
306
+ /** A lowercase id token, such as a role: the contracts' idToken. */
307
+ export const ID_TOKEN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
308
+
309
+ /**
310
+ * The root names a change set can introduce: the first segment of each
311
+ * owned pattern other than `**`, read from the packed contract. A fixed list,
312
+ * so no plan or brief text can become a root entry (code rules C13 and L9).
313
+ */
314
+ export const INTRODUCIBLE_ROOTS: ReadonlySet<string> = (() => {
315
+ const definitions = loadContract("repository-change-set.json").definitions as Record<string, { allOf?: { enum?: unknown }[] }> | undefined;
316
+ const patterns = definitions?.ownedPattern?.allOf?.[1]?.enum;
317
+ if (!Array.isArray(patterns) || !patterns.every((entry) => typeof entry === "string")) throw new Error("the packed change-set contract has no ownedPattern list");
318
+ return new Set((patterns as string[]).map((pattern) => pattern.split("/")[0]!).filter((root) => root !== "**"));
319
+ })();
320
+
321
+ /** The planItem a package act for this repository and package has: `${repositoryId}:${name}`, exactly (code rules C16 and L10). */
322
+ export function derivedPlanItem(repositoryId: string, name: string): string {
323
+ return `${repositoryId}:${name}`;
324
+ }
325
+
326
+ /** The discovery roots, in canonical order. */
327
+ export const DISCOVERY_ROOTS: readonly DiscoveryRoot[] = [".claude/skills", ".cursor/skills"];
328
+
329
+ /** Where a role's discovery link is written under a discovery root. */
330
+ export function discoveryLinkPath(root: DiscoveryRoot, role: string): string {
331
+ return `${root}/clossys-${role}`;
332
+ }
333
+
334
+ /** A discovery link's target: its exact bytes, with no line feed. */
335
+ export function discoveryLinkTarget(role: string): string {
336
+ return `../../.agents/skills/clossys-${role}`;
337
+ }
338
+
339
+ const DISCOVERY_LINK = /^\.(?:claude|cursor)\/skills\/clossys-([^/]+)$/u;
340
+
341
+ /** The role a discovery link path names, or null when the path is not a discovery link. */
342
+ export function discoveryLinkRole(path: string): string | null {
343
+ if (!isSafeRelativePath(path)) return null;
344
+ return DISCOVERY_LINK.exec(path)?.[1] ?? null;
345
+ }
346
+
347
+ /** The file each write-record source writes (code rule C9). */
348
+ export const WRITE_RECORD_PATHS: Readonly<Record<WriteRecordSource, string>> = {
349
+ "engagement-brief": BRIEF_PATH,
350
+ "agents-pointer": "AGENTS.md",
351
+ "claude-loader": "CLAUDE.md",
352
+ "agents-guide": AGENTS_GUIDE_PATH,
353
+ };
354
+
355
+ /** The setup template acts, each with exactly the files it writes (code rule C9). */
356
+ export type TemplateAct = "add-caller-workflow" | "write-starter-request" | "add-ci-template" | "add-path-scope-job";
357
+ export const TEMPLATE_PATHS: Readonly<Record<TemplateAct, readonly string[]>> = {
358
+ "add-caller-workflow": [
359
+ ".github/workflows/clossys-adoption-evidence.yml",
360
+ ".github/workflows/clossys-adoption-decision.yml",
361
+ ".github/scripts/clossys-collect-adoption-snapshot.mjs",
362
+ ],
363
+ "write-starter-request": [".starter/request.json"],
364
+ "add-ci-template": [".github/workflows/clossys-ci.yml"],
365
+ "add-path-scope-job": [".github/workflows/clossys-path-scope.yml"],
366
+ };
367
+
368
+ /** For each surface an exempt-release-age item writes: its file, the list key in it, and the package manager that reads it (code rule C12). */
369
+ export const EXEMPTION_SURFACES: Readonly<Record<ExemptionSurfaceKind, { readonly path: string; readonly key: string; readonly packageManager: PackageManagerKind }>> = {
370
+ "pnpm-workspace": { path: "pnpm-workspace.yaml", key: "minimumReleaseAgeExclude", packageManager: "pnpm" },
371
+ yarnrc: { path: ".yarnrc.yml", key: "npmPreapprovedPackages", packageManager: "yarn" },
372
+ };
373
+
374
+ /**
375
+ * How a whole file may change (the contract's WRITE KINDS, code rule C15).
376
+ * write: after is not null. link: after is not null, and before is null or
377
+ * equal to it. create-or-edit: after is not null and differs from before.
378
+ * edit: before and after are not null and differ. No act deletes a file.
379
+ */
380
+ export type WriteKind = "write" | "link" | "create-or-edit" | "edit";
381
+
382
+ /** The write kind of each act's whole files; null for an act that writes no whole file. A compose-skills discovery link is a link. */
383
+ export const WRITE_KINDS: Readonly<Record<ChangeSetItem["act"], WriteKind | null>> = {
384
+ "write-record": "write",
385
+ "compose-skills": "write",
386
+ "add-caller-workflow": "write",
387
+ "write-starter-request": "write",
388
+ "add-ci-template": "write",
389
+ "add-path-scope-job": "write",
390
+ "exempt-release-age": "create-or-edit",
391
+ "declare-root-entry": "edit",
392
+ install: null,
393
+ "pin-starter": null,
394
+ "write-ledger": null,
395
+ };
396
+
397
+ /** The write kind a whole file at `path` has when `item` names it. */
398
+ export function writeKindOf(item: ChangeSetItem, path: string): WriteKind | null {
399
+ if (item.act === "compose-skills" && discoveryLinkRole(path) !== null) return "link";
400
+ return WRITE_KINDS[item.act];
401
+ }
402
+
403
+ /** `sha256:` and the hex SHA-256 of a text's UTF-8 bytes: a file's content digest. */
404
+ export function contentDigest(text: string): string {
405
+ return `sha256:${createHash("sha256").update(Buffer.from(text, "utf8")).digest("hex")}`;
406
+ }
407
+
408
+ /** The JSON pointer of one dependency entry in package.json. */
409
+ export function dependencyPointer(placement: DependencyPlacement, name: string): string {
410
+ return `/${placement}/${name.replace(/~/g, "~0").replace(/\//g, "~1")}`;
411
+ }
412
+
413
+ const SAFE_PATH = /^(?:(?!\.\.?\/)[^/\\\u0000-\u001f]+\/)*(?!\.\.?$)[^/\\\u0000-\u001f]+$/u;
414
+
415
+ /** A relative path with `/` between segments and no empty, `.` or `..` segment (the contracts' safePath). */
416
+ export function isSafeRelativePath(path: string): boolean {
417
+ return SAFE_PATH.test(path);
418
+ }
419
+
420
+ /** A path pattern as the contract allows one: a safe path whose segments may use `*`, where a segment containing `**` is exactly `**`, and which is not `**` alone. */
421
+ export function isPathPattern(pattern: string): boolean {
422
+ if (pattern === "**" || !isSafeRelativePath(pattern)) return false;
423
+ return pattern.split("/").every((segment) => segment === "**" || !segment.includes("**"));
424
+ }
425
+
426
+ function segmentMatches(segment: string, glob: string): boolean {
427
+ const pattern = glob.split("*").map((part) => part.replace(/[.+?^${}()|[\]\\]/g, "\\$&")).join("[^/]*");
428
+ return new RegExp(`^${pattern}$`, "u").test(segment);
429
+ }
430
+
431
+ function segmentsMatch(path: readonly string[], pattern: readonly string[]): boolean {
432
+ if (pattern.length === 0) return path.length === 0;
433
+ const [head, ...rest] = pattern;
434
+ if (head === "**") {
435
+ for (let skip = 0; skip <= path.length; skip += 1) if (segmentsMatch(path.slice(skip), rest)) return true;
436
+ return false;
437
+ }
438
+ return path.length > 0 && segmentMatches(path[0]!, head!) && segmentsMatch(path.slice(1), rest);
439
+ }
440
+
441
+ /**
442
+ * Whether a safe relative path is matched by a path pattern: `*` matches
443
+ * any characters within one segment, and a segment that is exactly `**`
444
+ * matches any number of whole segments, including none. An unsafe path, or
445
+ * a pattern the contract does not allow, matches nothing.
446
+ */
447
+ export function matchesPathPattern(path: string, pattern: string): boolean {
448
+ if (!isSafeRelativePath(path) || !isPathPattern(pattern)) return false;
449
+ return segmentsMatch(path.split("/"), pattern.split("/"));
450
+ }
451
+
452
+ /** Orders two strings by their UTF-16 code units. */
453
+ export function compareCodeUnits(left: string, right: string): number {
454
+ if (left === right) return 0;
455
+ return left < right ? -1 : 1;
456
+ }
457
+
458
+ /** Orders two tuples of strings member by member, by UTF-16 code units. */
459
+ export function compareTuples(left: readonly string[], right: readonly string[]): number {
460
+ for (let index = 0; index < Math.max(left.length, right.length); index += 1) {
461
+ const difference = compareCodeUnits(left[index] ?? "", right[index] ?? "");
462
+ if (difference !== 0) return difference;
463
+ }
464
+ return 0;
465
+ }
466
+
467
+ /** The sort keys of the change-set contract's canonical order (code rule C8), shared by the planner that writes it and the rule that checks it. */
468
+ export const CANONICAL_KEYS = {
469
+ item: (item: ChangeSetItem): string[] => [item.id],
470
+ file: (file: FileChange): string[] => [file.path],
471
+ key: (key: KeyChange): string[] => [key.file, key.pointer],
472
+ refusal: (refusal: ChangeSetRefusal): string[] => ("path" in refusal ? [refusal.path, ""] : [refusal.file, refusal.pointer]),
473
+ deferral: (deferral: ChangeSetDeferral): string[] => [deferral.planItem],
474
+ invariant: (invariant: PackageInvariant | LedgerInvariant): string[] => ("name" in invariant ? [invariant.name] : [""]),
475
+ pattern: (pattern: string): string[] => [pattern],
476
+ surface: (surface: { readonly surface: string; readonly path: string }): string[] => [surface.surface, surface.path],
477
+ root: (root: string): string[] => [root],
478
+ name: (name: string): string[] => [name],
479
+ tool: (tool: { readonly tool: string }): string[] => [tool.tool],
480
+ } as const;
481
+
482
+ /** A copy of `values` in canonical order by `key`. */
483
+ export function canonicalOrder<T>(values: readonly T[], key: (value: T) => readonly string[]): T[] {
484
+ return [...values].sort((left, right) => compareTuples(key(left), key(right)));
485
+ }
486
+
487
+ const VERDICT_RANK: Readonly<Record<CheckVerdict, number>> = { satisfied: 0, indeterminate: 1, violated: 2 };
488
+
489
+ /** The worst of some verdicts: violated, then indeterminate, then satisfied; satisfied for none. */
490
+ export function worstVerdict(verdicts: readonly CheckVerdict[]): CheckVerdict {
491
+ return verdicts.reduce<CheckVerdict>((current, verdict) => (VERDICT_RANK[verdict] > VERDICT_RANK[current] ? verdict : current), "satisfied");
492
+ }
493
+
494
+ /** The rule a bundle check carries when its authorization is for another plan (code rule A4). */
495
+ export const AUTHORIZATION_PLAN_MISMATCH = "authorization-plan-mismatch";
496
+
497
+ /** The rule a bundle check carries when the plan has package acts and no authorization permits them (code rule A4). */
498
+ export const AUTHORIZATION_ABSENT = "authorization-absent";
499
+
500
+ export type ChangeSetRuleId = "C1" | "C2" | "C3" | "C4" | "C5" | "C6" | "C7" | "C8" | "C9" | "C10" | "C11" | "C12" | "C13" | "C14" | "C15" | "C16" | "C17";
501
+ export type ApplyBundleRuleId = "A1" | "A2" | "A3" | "A4" | "A5" | "A6" | "A7";
502
+
503
+ /** One reason a change set or bundle is refused: `rule` is "schema" for the contract's keywords, else the code rule's id. */
504
+ export interface ChangeSetViolation {
505
+ readonly rule: "schema" | ChangeSetRuleId | ApplyBundleRuleId;
506
+ readonly path: string;
507
+ readonly message: string;
508
+ }
509
+
510
+ interface RuleViolation<R> {
511
+ readonly rule: R;
512
+ readonly path: string;
513
+ readonly message: string;
514
+ }
515
+
516
+ function repeats<T>(values: readonly T[], key: (value: T) => string): { index: number; first: number }[] {
517
+ const seen = new Map<string, number>();
518
+ const out: { index: number; first: number }[] = [];
519
+ values.forEach((value, index) => {
520
+ const k = key(value);
521
+ const first = seen.get(k);
522
+ if (first === undefined) seen.set(k, index);
523
+ else out.push({ index, first });
524
+ });
525
+ return out;
526
+ }
527
+
528
+ type PackageItem = Extract<ChangeSetItem, { act: "install" | "pin-starter" }>;
529
+ const isPackageItem = (item: ChangeSetItem): item is PackageItem => item.act === "install" || item.act === "pin-starter";
530
+ const isDerived = (file: FileChange): file is DerivedFileChange => "derived" in file;
531
+ const isPackageInvariant = (invariant: PackageInvariant | LedgerInvariant): invariant is PackageInvariant => "name" in invariant;
532
+ const lower = (text: string) => text.toLowerCase();
533
+
534
+ /** C8: the index of the first entry out of canonical order (or repeated, when `strict`), if any. */
535
+ function firstOutOfOrder<T>(values: readonly T[], key: (value: T) => readonly string[], strict: boolean): number | undefined {
536
+ for (let index = 1; index < values.length; index += 1) {
537
+ const difference = compareTuples(key(values[index - 1]!), key(values[index]!));
538
+ if (difference > 0 || (strict && difference === 0)) return index;
539
+ }
540
+ return undefined;
541
+ }
542
+
543
+ /** Code rules C1-C16 of repository-change-set.json, over a set whose schema already passes. Messages name positions, never values. */
544
+ export function changeSetRuleViolations(set: RepositoryChangeSet): RuleViolation<ChangeSetRuleId>[] {
545
+ const out: RuleViolation<ChangeSetRuleId>[] = [];
546
+ const push = (rule: ChangeSetRuleId, path: string, message: string) => out.push({ rule, path, message });
547
+
548
+ // C1
549
+ for (const { index, first } of repeats(set.items, (item) => item.id)) push("C1", `items[${index}].id`, `repeats items[${first}].id`);
550
+
551
+ // C2
552
+ const itemsById = new Map(set.items.map((item) => [item.id, item]));
553
+ const unknown = (path: string, item: string) => {
554
+ if (!itemsById.has(item)) push("C2", path, "is not the id of an item");
555
+ };
556
+ set.files.forEach((file, index) => {
557
+ unknown(`files[${index}].item`, file.item);
558
+ if (isDerived(file)) file.invariants.forEach((invariant, at) => {
559
+ if (isPackageInvariant(invariant)) unknown(`files[${index}].invariants[${at}].item`, invariant.item);
560
+ });
561
+ });
562
+ set.keys.forEach((key, index) => unknown(`keys[${index}].item`, key.item));
563
+ set.refused.forEach((refusal, index) => unknown(`refused[${index}].item`, refusal.item));
564
+
565
+ // C3
566
+ for (const { index, first } of repeats(set.files, (file) => lower(file.path))) push("C3", `files[${index}].path`, `repeats files[${first}].path, compared case-insensitively`);
567
+ for (const { index, first } of repeats(set.keys, (key) => `${key.file}\u0000${key.pointer}`)) push("C3", `keys[${index}].pointer`, `repeats keys[${first}].pointer`);
568
+ const written = new Set(set.files.map((file) => lower(file.path)));
569
+ const writtenKeys = new Set(set.keys.map((key) => `${key.file}\u0000${key.pointer}`));
570
+ set.refused.forEach((refusal, index) => {
571
+ if ("path" in refusal && written.has(lower(refusal.path))) push("C3", `refused[${index}].path`, "is also written in files");
572
+ if ("pointer" in refusal && writtenKeys.has(`${refusal.file}\u0000${refusal.pointer}`)) push("C3", `refused[${index}].pointer`, "is also written in keys");
573
+ });
574
+ const allowed = (path: string) => set.pathAllowList.some((pattern) => matchesPathPattern(path, pattern));
575
+ set.files.forEach((file, index) => {
576
+ if (!allowed(file.path)) push("C3", `files[${index}].path`, "is not matched by any pathAllowList entry");
577
+ });
578
+ set.keys.forEach((key, index) => {
579
+ if (!allowed(key.file)) push("C3", `keys[${index}].file`, "is not matched by any pathAllowList entry");
580
+ });
581
+ set.items.forEach((item, index) => {
582
+ if (item.act === "exempt-release-age" && !allowed(item.path)) push("C3", `items[${index}].path`, "is not matched by any pathAllowList entry");
583
+ });
584
+
585
+ // C4
586
+ if (set.ledger.generation < 0) push("C4", "ledger.generation", "must be 0 or more");
587
+ const ledgerItems = set.items.map((item, index) => ({ item, index })).filter(({ item }) => item.act === "write-ledger");
588
+ if (ledgerItems.length !== 1) {
589
+ push("C4", "items", `must hold exactly one write-ledger item, and holds ${ledgerItems.length}`);
590
+ } else {
591
+ const ledgerId = ledgerItems[0]!.item.id;
592
+ const ledgerFiles = set.files.map((file, index) => ({ file, index })).filter(({ file }) => file.item === ledgerId);
593
+ if (ledgerFiles.length !== 1) {
594
+ push("C4", `items[${ledgerItems[0]!.index}]`, `must be named by exactly one file, and is named by ${ledgerFiles.length}`);
595
+ } else {
596
+ const { file, index } = ledgerFiles[0]!;
597
+ const expected = set.ledger.generation + 1;
598
+ const ok = file.path === LEDGER_PATH && isDerived(file) && file.invariants.length === 1 && !isPackageInvariant(file.invariants[0]!) && file.invariants[0]!.ledgerGeneration === expected;
599
+ if (!ok) push("C4", `files[${index}]`, `must be the derived file ${LEDGER_PATH} with one invariant, ledgerGeneration equal to ledger.generation plus 1`);
600
+ }
601
+ }
602
+
603
+ // C5
604
+ const digest = changeSetDigest(set);
605
+ if (set.changeSetDigest !== digest) push("C5", "changeSetDigest", "is not this change set's digest");
606
+ const short = digest.slice("sha256:".length, "sha256:".length + 12);
607
+ if (set.branch !== `${set.agentProvenance ?? "clossys"}/apply-${short}`) push("C5", "branch", "does not match the covered agent provenance and the first 12 digits of this change set's digest");
608
+ if (!set.pullRequest.title.endsWith(short)) push("C5", "pullRequest.title", "does not end with the first 12 digits of this change set's digest");
609
+
610
+ // C6
611
+ const planItems = [
612
+ ...set.items.flatMap((item, index) => (isPackageItem(item) ? [{ planItem: item.planItem, path: `items[${index}].planItem` }] : [])),
613
+ ...set.deferred.map((deferral, index) => ({ planItem: deferral.planItem, path: `deferred[${index}].planItem` })),
614
+ ];
615
+ for (const { index, first } of repeats(planItems, (entry) => entry.planItem)) push("C6", planItems[index]!.path, `repeats ${planItems[first]!.path}`);
616
+
617
+ // C7
618
+ const lockPath = lockfilePath(set.observed);
619
+ const lockfiles: number[] = [];
620
+ set.files.forEach((file, index) => {
621
+ if (!isDerived(file)) {
622
+ if (file.path === LEDGER_PATH || file.path === "package.json" || LOCKFILE_NAMES.includes(file.path)) push("C7", `files[${index}].path`, "may not be written as a whole file");
623
+ return;
624
+ }
625
+ if (file.path === LEDGER_PATH) {
626
+ if (file.invariants.some(isPackageInvariant)) push("C7", `files[${index}].invariants`, "of the ledger may hold only a ledgerGeneration invariant");
627
+ return;
628
+ }
629
+ if (file.path !== lockPath) {
630
+ push("C7", `files[${index}].path`, "is derived but is neither the ledger nor this repository's lockfile path");
631
+ return;
632
+ }
633
+ lockfiles.push(index);
634
+ file.invariants.forEach((invariant, at) => {
635
+ if (!isPackageInvariant(invariant)) push("C7", `files[${index}].invariants[${at}]`, "of the lockfile must be a package invariant");
636
+ });
637
+ });
638
+ if (lockfiles.length > 1) push("C7", `files[${lockfiles[1]}]`, "is a second derived lockfile");
639
+
640
+ // C8
641
+ const order = <T>(path: string, values: readonly T[], key: (value: T) => readonly string[], strict = false) => {
642
+ const index = firstOutOfOrder(values, key, strict);
643
+ if (index !== undefined) push("C8", `${path}[${index}]`, strict ? "is out of canonical order, or repeats the entry before it" : "is out of canonical order");
644
+ };
645
+ order("items", set.items, CANONICAL_KEYS.item);
646
+ order("files", set.files, CANONICAL_KEYS.file);
647
+ order("keys", set.keys, CANONICAL_KEYS.key);
648
+ order("refused", set.refused, CANONICAL_KEYS.refusal, true);
649
+ order("deferred", set.deferred, CANONICAL_KEYS.deferral);
650
+ set.files.forEach((file, index) => {
651
+ if (isDerived(file)) order(`files[${index}].invariants`, file.invariants, CANONICAL_KEYS.invariant);
652
+ });
653
+ order("pathAllowList", set.pathAllowList, CANONICAL_KEYS.pattern, true);
654
+ order("observed.releaseAgeSurfaces", set.observed.releaseAgeSurfaces, CANONICAL_KEYS.surface, true);
655
+ order("observed.symlinkedSkillRoots", set.observed.symlinkedSkillRoots, CANONICAL_KEYS.root, true);
656
+ order("observed.linkedAgentsPaths", set.observed.linkedAgentsPaths, CANONICAL_KEYS.name, true);
657
+ if (set.observed.repositoryProfile !== null) {
658
+ order("observed.repositoryProfile.undeclaredRoots", set.observed.repositoryProfile.undeclaredRoots, CANONICAL_KEYS.name, true);
659
+ order("observed.repositoryProfile.prohibitedRoots", set.observed.repositoryProfile.prohibitedRoots, CANONICAL_KEYS.name, true);
660
+ }
661
+ if (set.tooling !== undefined) order("tooling", set.tooling, CANONICAL_KEYS.tool, true);
662
+ if (set.texts !== undefined) order("texts", set.texts, (row) => [row.path], true);
663
+
664
+ // C9
665
+ set.files.forEach((file, index) => {
666
+ if (isDerived(file)) return;
667
+ const role = discoveryLinkRole(file.path);
668
+ if ((file.mode === "120000") !== (role !== null)) push("C9", `files[${index}].mode`, role === null ? "is 120000, which only a discovery link has" : "is not 120000, and this path is a discovery link");
669
+ else if (role !== null && file.after !== null && file.after !== contentDigest(discoveryLinkTarget(role))) push("C9", `files[${index}].after`, "is not the content digest of this discovery link's target");
670
+ });
671
+ const linkedRoots = DISCOVERY_ROOTS.filter((root) => !set.observed.symlinkedSkillRoots.includes(root));
672
+ set.items.forEach((item, index) => {
673
+ const files = set.files.map((file, at) => ({ file, at })).filter(({ file }) => file.item === item.id);
674
+ const refusals = set.refused.map((refusal, at) => ({ refusal, at })).filter(({ refusal }) => refusal.item === item.id);
675
+ const keys = set.keys.filter((key) => key.item === item.id);
676
+ const invariants = set.files.flatMap((file) => (isDerived(file) ? file.invariants.filter((invariant) => isPackageInvariant(invariant) && invariant.item === item.id) : []));
677
+ const at = `items[${index}]`;
678
+ // Every path an item names: a whole file's or a path refusal's; a derived file or a key refusal counts as a name at no path.
679
+ const named = [...files.map(({ file }) => (isDerived(file) ? "" : file.path)), ...refusals.map(({ refusal }) => ("path" in refusal ? refusal.path : ""))];
680
+ const namedExactly = (expected: readonly string[]) => {
681
+ const counts = new Map<string, number>();
682
+ for (const path of named) counts.set(path, (counts.get(path) ?? 0) + 1);
683
+ return keys.length === 0 && named.length === expected.length && new Set(expected).size === expected.length && expected.every((path) => counts.get(path) === 1);
684
+ };
685
+ if (item.act === "write-record") {
686
+ const path = WRITE_RECORD_PATHS[item.source];
687
+ if (!namedExactly([path])) push("C9", at, `must be named by exactly one whole file or path refusal, at ${path}, and by nothing else`);
688
+ } else if (item.act === "compose-skills") {
689
+ for (const { index: repeat, first } of repeats(item.roles, (role) => role)) push("C9", `${at}.roles[${repeat}]`, `repeats roles[${first}]`);
690
+ const roles = [...new Set(item.roles)];
691
+ // A role whose SKILL.md is refused gets no discovery link: a link would expose a skill the flow does not own.
692
+ const written = (role: string) => files.some(({ file }) => !isDerived(file) && file.path === skillPath(role));
693
+ const expected = [...roles.flatMap((role) => [skillPath(role), ...(written(role) ? linkedRoots.map((root) => discoveryLinkPath(root, role)) : [])]), SKILLS_MANIFEST_PATH];
694
+ if (!namedExactly(expected)) {
695
+ push(
696
+ "C9",
697
+ at,
698
+ `must be named, for each role, by exactly one whole file or path refusal at that role's skill path, by one whole file or path refusal at its discovery link under each root not observed as a symbolic link when its skill is a whole file and by none when it is refused, by exactly one at ${SKILLS_MANIFEST_PATH}, and by nothing else`,
699
+ );
700
+ }
701
+ } else if (item.act === "add-caller-workflow" || item.act === "write-starter-request" || item.act === "add-ci-template" || item.act === "add-path-scope-job") {
702
+ if (!namedExactly(TEMPLATE_PATHS[item.act])) push("C9", at, `must be named by exactly one whole file or path refusal at each file an ${item.act} item writes, and by nothing else`);
703
+ } else if (item.act === "exempt-release-age") {
704
+ if (!(keys.length === 0 && named.length <= 1 && named.every((path) => path === item.path))) push("C9", at, "must be named by at most one whole file or path refusal, at its own path, and by nothing else");
705
+ } else if (item.act === "declare-root-entry") {
706
+ if (!namedExactly([item.path])) push("C9", at, "must be named by exactly one whole file or path refusal, at its own path, and by nothing else");
707
+ } else if (item.act === "write-ledger") {
708
+ if (refusals.length > 0) push("C9", at, "is refused, but the ledger is always written");
709
+ } else if (isPackageItem(item)) {
710
+ const pointer = dependencyPointer(item.placement, item.package.name);
711
+ if (item.act === "pin-starter" && item.placement !== "devDependencies") push("C9", `${at}.placement`, "of a pin-starter item must be devDependencies");
712
+ if (files.some(({ file }) => !isDerived(file))) push("C9", at, "is named by a whole file");
713
+ if (refusals.some(({ refusal }) => "path" in refusal)) push("C9", at, "is named by a path refusal");
714
+ const ownPointers = [dependencyPointer("dependencies", item.package.name), dependencyPointer("devDependencies", item.package.name)];
715
+ if (refusals.some(({ refusal }) => "pointer" in refusal && !ownPointers.includes(refusal.pointer))) push("C9", at, "is named by a key refusal for another package");
716
+ if (keys.some((key) => key.pointer !== pointer || key.after !== item.package.version)) push("C9", at, "is named by a key whose pointer or value is not this item's");
717
+ const pinned = (invariant: PackageInvariant | LedgerInvariant) =>
718
+ isPackageInvariant(invariant) && invariant.name === item.package.name && invariant.version === item.package.version && invariant.integrity === item.package.integrity;
719
+ if (!invariants.every(pinned)) push("C9", at, "is named by an invariant whose package is not this item's");
720
+ const keyRefusals = refusals.filter(({ refusal }) => "pointer" in refusal).length;
721
+ if (item.satisfiedInBase) {
722
+ if (keys.length + invariants.length + refusals.length > 0) push("C9", at, "is satisfied in the base, so nothing may be written or refused for it");
723
+ } else if (!((keys.length === 1 && invariants.length === 1 && refusals.length === 0) || (keys.length === 0 && invariants.length === 0 && keyRefusals >= 1))) {
724
+ push("C9", at, "is not satisfied in the base, so it must write exactly one key and one invariant, or be refused");
725
+ }
726
+ }
727
+ });
728
+ const kinds = new Map(set.items.map((item) => [item.id, item]));
729
+ set.keys.forEach((key, index) => {
730
+ const item = kinds.get(key.item);
731
+ if (item !== undefined && !isPackageItem(item)) push("C9", `keys[${index}].item`, "is not a package item");
732
+ });
733
+ set.refused.forEach((refusal, index) => {
734
+ const item = kinds.get(refusal.item);
735
+ if ("pointer" in refusal && item !== undefined && !isPackageItem(item)) push("C9", `refused[${index}].item`, "is a key refusal for an item that is not a package item");
736
+ });
737
+ set.files.forEach((file, index) => {
738
+ if (!isDerived(file) || file.path === LEDGER_PATH) return;
739
+ file.invariants.forEach((invariant, at) => {
740
+ const item = isPackageInvariant(invariant) ? kinds.get(invariant.item) : undefined;
741
+ if (item !== undefined && !isPackageItem(item)) push("C9", `files[${index}].invariants[${at}].item`, "is not a package item");
742
+ });
743
+ const first = file.invariants[0];
744
+ if (first !== undefined && isPackageInvariant(first) && file.item !== first.item) push("C9", `files[${index}].item`, "is not the item of its first invariant");
745
+ });
746
+
747
+ if (set.existingDeclarationAdoptions !== undefined) {
748
+ const seen = new Set<string>();
749
+ for (const [index,row] of set.existingDeclarationAdoptions.entries()) {
750
+ const item = set.items.find(item => isPackageItem(item) && item.planItem === row.desired.planItem);
751
+ const at = `existingDeclarationAdoptions[${index}]`;
752
+ if (seen.has(row.name) || row.name !== row.beforeResolved.name || row.name !== row.desired.name || row.placement !== row.desired.placement || row.desired.planItem !== `${set.repository.id}:${row.name}`) push("C9",at,"do not identify one root declaration");
753
+ seen.add(row.name);
754
+ if (set.phase === "setup" && row.observedBaseCommit !== set.repository.baseCommit) push("C9",at,"name another observed setup base");
755
+ if (set.phase === "setup" && row.desired.act === "install") {
756
+ if (!set.deferred.some(deferral => deferral.planItem === row.desired.planItem)) push("C9",at,"do not name a deferred install");
757
+ } else if (!item || !isPackageItem(item) || item.package.name !== row.name || item.package.version !== row.desired.version || item.package.integrity !== row.desired.integrity || item.placement !== row.placement || item.act !== row.desired.act) push("C9",at,"do not match desired package item");
758
+ if (item && isPackageItem(item) && !(set.phase === "apply" && row.desired.act === "pin-starter")) {
759
+ if (item.satisfiedInBase || !set.keys.some(key => key.item === item.id && key.before === row.beforeVersion && key.after === row.desired.version)) push("C9",at,"do not emit an explicit compare-and-swap");
760
+ }
761
+ }
762
+ }
763
+ // C10
764
+ const starters = set.items.map((item, index) => ({ item, index })).filter(({ item }) => item.act === "pin-starter");
765
+ if (starters.length > 1) push("C10", `items[${starters[1]!.index}]`, "is a second pin-starter item; a set pins Starter at most once");
766
+ set.items.forEach((item, index) => {
767
+ if (set.phase === "setup" && item.act === "install") push("C10", `items[${index}]`, "is an install in a setup set, where installs are deferred");
768
+ });
769
+ if (set.phase === "apply") set.deferred.forEach((_, index) => push("C10", `deferred[${index}]`, "is deferred in an apply set, which defers nothing"));
770
+
771
+ // C11
772
+ if (set.phase === "setup") {
773
+ const count = (act: ChangeSetItem["act"]) => set.items.filter((item) => item.act === act).length;
774
+ for (const act of ["add-caller-workflow", "write-starter-request", "add-ci-template", "add-path-scope-job", "pin-starter"] as const) {
775
+ const held = count(act);
776
+ if (held !== 1) push("C11", "items", `must hold exactly one ${act} item in a setup set, and holds ${held}`);
777
+ }
778
+ const exempts = count("exempt-release-age");
779
+ const wanted = set.observed.packageManager === "pnpm" || set.observed.packageManager === "yarn" ? 1 : 0;
780
+ if (exempts !== wanted) push("C11", "items", `must hold ${wanted} exempt-release-age item(s) in a setup set for this package manager, and holds ${exempts}`);
781
+ }
782
+
783
+ // C12
784
+ set.items.forEach((item, index) => {
785
+ if (item.act !== "exempt-release-age") return;
786
+ const surface = EXEMPTION_SURFACES[item.surface];
787
+ if (item.path !== surface.path) push("C12", `items[${index}].path`, "is not the file of the item's surface");
788
+ if (set.observed.packageManager !== surface.packageManager) push("C12", `items[${index}].surface`, "is not a surface this repository's package manager reads");
789
+ if (item.scope !== PACKAGE_SCOPE.scope) push("C12", `items[${index}].scope`, "is not the publishing scope this package packs");
790
+ });
791
+
792
+ // C13
793
+ const profile = set.observed.repositoryProfile;
794
+ const declarers = set.items.map((item, index) => ({ item, index })).filter(({ item }) => item.act === "declare-root-entry");
795
+ if (declarers.length > 1) push("C13", `items[${declarers[1]!.index}]`, "is a second declare-root-entry item");
796
+ const needed =
797
+ profile !== null && (profile.rootVocabulary === "unparseable" || (profile.rootVocabulary === "checked" && (profile.undeclaredRoots.length > 0 || profile.prohibitedRoots.length > 0)));
798
+ if (needed && declarers.length === 0) push("C13", "items", "must hold a declare-root-entry item for the observed repository profile");
799
+ if (!needed) for (const { index } of declarers) push("C13", `items[${index}]`, "is a declare-root-entry item the observed repository profile does not need");
800
+ if (profile !== null) {
801
+ if (profile.rootVocabulary !== "checked" && profile.undeclaredRoots.length + profile.prohibitedRoots.length > 0) {
802
+ push("C13", "observed.repositoryProfile", "lists root names, but its root vocabulary is not checked");
803
+ }
804
+ if (profile.undeclaredRoots.some((name) => profile.prohibitedRoots.includes(name))) push("C13", "observed.repositoryProfile", "lists one root name as both undeclared and prohibited");
805
+ // Only a path the set creates introduces a root name: a refused path is not written, and a key's file or an edited file already exists.
806
+ const roots = new Set(set.files.filter((file) => isDerived(file) || file.before === null).map((file) => file.path.split("/")[0]!));
807
+ for (const [name, list] of [["undeclaredRoots", profile.undeclaredRoots], ["prohibitedRoots", profile.prohibitedRoots]] as const) {
808
+ list.forEach((root, at) => {
809
+ if (!roots.has(root)) push("C13", `observed.repositoryProfile.${name}[${at}]`, "is not the first segment of any path the set creates");
810
+ if (!INTRODUCIBLE_ROOTS.has(root)) push("C13", `observed.repositoryProfile.${name}[${at}]`, "is not a root name an owned pattern can introduce");
811
+ });
812
+ }
813
+ }
814
+ if (profile !== null && needed && declarers.length === 1) {
815
+ const { item, index } = declarers[0]! as { item: Extract<ChangeSetItem, { act: "declare-root-entry" }>; index: number };
816
+ const at = `items[${index}]`;
817
+ if (item.path !== profile.path) push("C13", `${at}.path`, "is not the observed repository profile's path");
818
+ item.entries.forEach((entry, position) => {
819
+ if (!INTRODUCIBLE_ROOTS.has(entry.name)) push("C13", `${at}.entries[${position}].name`, "is not a root name an owned pattern can introduce");
820
+ });
821
+ if (item.entries.length !== profile.undeclaredRoots.length || item.entries.some((entry, position) => entry.name !== profile.undeclaredRoots[position])) {
822
+ push("C13", `${at}.entries`, "do not name exactly the observed undeclared root names, in their order");
823
+ }
824
+ const whole = set.files.find((file) => file.item === item.id && !isDerived(file));
825
+ const refusal = set.refused.find((entry) => entry.item === item.id && "path" in entry);
826
+ const expected = profile.rootVocabulary === "unparseable" ? "root-vocabulary-unknown" : profile.prohibitedRoots.length > 0 ? "root-entry-prohibited" : null;
827
+ if (expected === null) {
828
+ if (whole === undefined) push("C13", at, "must be named by a whole file that edits the profile");
829
+ } else if (refusal === undefined || refusal.reason !== expected) {
830
+ push("C13", at, `must be named by a path refusal with reason ${expected}`);
831
+ }
832
+ }
833
+
834
+ // C14
835
+ const linked = set.observed.linkedAgentsPaths;
836
+ const skillRoles = new Set(set.items.flatMap((item) => (item.act === "compose-skills" ? item.roles : [])));
837
+ linked.forEach((path, at) => {
838
+ const role = /^\.agents\/skills\/clossys-(.+)$/u.exec(path)?.[1];
839
+ if (role !== undefined && !skillRoles.has(role)) push("C14", `observed.linkedAgentsPaths[${at}]`, "is a skill directory of no role this set composes");
840
+ });
841
+ const underLink = (path: string) => linked.some((link) => path === link || path.startsWith(`${link}/`));
842
+ const skillPaths = new Set([...skillRoles].map(skillPath));
843
+ set.files.forEach((file, at) => {
844
+ if (skillPaths.has(file.path) && underLink(file.path)) push("C14", `files[${at}].path`, "is written through a symbolic link on the default branch");
845
+ });
846
+ set.refused.forEach((refusal, at) => {
847
+ if (!("path" in refusal)) {
848
+ if (refusal.reason === "skills-root-is-link") push("C14", `refused[${at}].reason`, "is skills-root-is-link on a key, which names no path");
849
+ return;
850
+ }
851
+ const shouldBe = skillPaths.has(refusal.path) && underLink(refusal.path);
852
+ if (shouldBe !== (refusal.reason === "skills-root-is-link")) {
853
+ push("C14", `refused[${at}].reason`, shouldBe ? "is not skills-root-is-link, and this skill lies under a symbolic link" : "is skills-root-is-link, but this path is no skill under a symbolic link");
854
+ }
855
+ });
856
+
857
+ // C15: every whole file obeys its item's write kind, checked here and nowhere else.
858
+ set.files.forEach((file, index) => {
859
+ const item = itemsById.get(file.item);
860
+ if (isDerived(file) || item === undefined) return;
861
+ const kind = writeKindOf(item, file.path);
862
+ if (kind === null) return; // an act that writes no whole file: code rule C9 refuses the file
863
+ const at = `files[${index}]`;
864
+ if (file.after === null) {
865
+ push("C15", `${at}.after`, `is null, but a ${kind} never deletes a file`);
866
+ return;
867
+ }
868
+ if (kind === "edit" && file.before === null) push("C15", `${at}.before`, "is null, but an edit changes a file the default branch has");
869
+ if ((kind === "edit" || kind === "create-or-edit") && file.after === file.before) push("C15", `${at}.after`, `equals before, but a ${kind} writes only a change`);
870
+ if (kind === "link" && file.before !== null && file.before !== file.after) push("C15", `${at}.before`, "is another target, but a discovery link is only created or kept");
871
+ });
872
+
873
+ // C16: a planItem reaches the public ledger, so it is derived, never free text.
874
+ set.items.forEach((item, index) => {
875
+ if (isPackageItem(item) && item.planItem !== derivedPlanItem(set.repository.id, item.package.name)) push("C16", `items[${index}].planItem`, "is not the repository id, a colon and the package name");
876
+ });
877
+ const prefix = `${set.repository.id}:`;
878
+ set.deferred.forEach((deferral, index) => {
879
+ const name = deferral.planItem.startsWith(prefix) ? deferral.planItem.slice(prefix.length) : "";
880
+ if (!PACKAGE_NAME.test(name)) push("C16", `deferred[${index}].planItem`, "is not the repository id, a colon and a package name");
881
+ });
882
+
883
+ if (set.texts !== undefined) {
884
+ const wholeByPath = new Map(set.files.filter((file): file is WholeFileChange => !("derived" in file)).map((file) => [file.path, file]));
885
+ set.texts.forEach((row, index) => {
886
+ const file = wholeByPath.get(row.path);
887
+ if (file === undefined) push("C17", `texts[${index}].path`, "names no whole file in files");
888
+ else if (file.after !== null && file.after !== contentDigest(row.text)) push("C17", `texts[${index}].text`, "is not the content digest of that whole file's after bytes");
889
+ });
890
+ }
891
+ return out;
892
+ }
893
+
894
+ const PACKAGE_NAME = /^(?=.{1,214}$)@[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*$/u;
895
+
896
+ const PRE_APPLY_CHECKS: readonly ApplyCheckId[] = ["V1", "V2", "V3", "V4", "V5", "V6", "V7", "V8", "V9"];
897
+
898
+ /** Code rules A1-A7 of apply-bundle.json, over a bundle whose schema already passes. */
899
+ export function applyBundleRuleViolations(bundle: ApplyBundle): RuleViolation<ApplyBundleRuleId>[] {
900
+ const out: RuleViolation<ApplyBundleRuleId>[] = [];
901
+ for (const { index, first } of repeats(bundle.repositories, (entry) => entry.id.toLowerCase())) out.push({ rule: "A1", path: `repositories[${index}].id`, message: `repeats repositories[${first}].id` });
902
+ const computed = bundle.repositories.flatMap((entry) => ("changeSet" in entry ? [{ id: entry.id, changeSetDigest: entry.changeSet }] : []));
903
+ if (bundle.bundleDigest !== bundleDigest(bundle.plan.digest, computed)) out.push({ rule: "A2", path: "bundleDigest", message: "is not the digest of plan.digest and the repositories that have a change set" });
904
+ const mismatch = bundle.authorization !== null && bundle.authorization.planDigest !== bundle.plan.digest;
905
+ // A snapshot is recorded exactly when the plan has package acts (the plan contract's R6), and package acts need an authorization.
906
+ const absent = bundle.snapshot !== null && bundle.authorization === null;
907
+ bundle.repositories.forEach((entry, index) => {
908
+ const expected = entry.checks.length === 0 && !("changeSet" in entry) ? entry.verdict : worstVerdict(entry.checks.map((check) => check.verdict));
909
+ if (entry.verdict !== expected) out.push({ rule: "A3", path: `repositories[${index}].verdict`, message: "is not the worst of its checks' verdicts" });
910
+ const flagged = entry.checks.some((check) => check.check === "V3" && check.verdict === "violated" && check.rule === AUTHORIZATION_PLAN_MISMATCH);
911
+ const carries = entry.checks.some((check) => check.rule === AUTHORIZATION_PLAN_MISMATCH);
912
+ if ("changeSet" in entry && mismatch && !flagged) out.push({ rule: "A4", path: `repositories[${index}].checks`, message: "lacks the violated V3 check for an authorization issued for another plan" });
913
+ if (!mismatch && carries) out.push({ rule: "A4", path: `repositories[${index}].checks`, message: "reports an authorization mismatch the bundle does not have" });
914
+ const flaggedAbsent = entry.checks.some((check) => check.check === "V3" && check.verdict === "violated" && check.rule === AUTHORIZATION_ABSENT);
915
+ const carriesAbsent = entry.checks.some((check) => check.rule === AUTHORIZATION_ABSENT);
916
+ if ("changeSet" in entry && absent && !flaggedAbsent) out.push({ rule: "A4", path: `repositories[${index}].checks`, message: "lacks the violated V3 check for package acts no authorization permits" });
917
+ if (!absent && carriesAbsent) out.push({ rule: "A4", path: `repositories[${index}].checks`, message: "reports a missing authorization the bundle does not lack" });
918
+ if (!("changeSet" in entry)) return;
919
+ const hasState = entry.state !== undefined;
920
+ const hasBinding = entry.binding !== undefined;
921
+ if (bundle.mode === "report") {
922
+ if (hasState) out.push({ rule: "A5", path: `repositories[${index}].state`, message: "is a repository state, which a report bundle never claims" });
923
+ if (hasBinding) out.push({ rule: "A5", path: `repositories[${index}].binding`, message: "is a binding, which a report bundle never records" });
924
+ return;
925
+ }
926
+ const planned = entry.verdict === "satisfied" && hasBinding;
927
+ if (hasState && !planned) out.push({ rule: "A6", path: `repositories[${index}].state`, message: "is planned, but the verdict is not satisfied or no approval binds the change set" });
928
+ if (!hasState && planned) out.push({ rule: "A6", path: `repositories[${index}]`, message: "is satisfied and bound by an approval, so it must be planned" });
929
+ if (hasState && !PRE_APPLY_CHECKS.every((id) => entry.checks.some((check) => check.check === id && check.verdict === "satisfied"))) {
930
+ out.push({ rule: "A6", path: `repositories[${index}].checks`, message: "lacks a satisfied check for each of V1 to V9, which a planned repository needs" });
931
+ }
932
+ const v3 = entry.checks.filter((check) => check.check === "V3");
933
+ const authorityHolds = v3.length > 0 && v3.every((check) => check.verdict === "satisfied");
934
+ if (hasBinding !== authorityHolds) {
935
+ out.push({ rule: "A7", path: `repositories[${index}]${hasBinding ? ".binding" : ""}`, message: hasBinding ? "is recorded, but V3 is missing or not satisfied" : "has V3 satisfied, so it must record the binding V3 found" });
936
+ }
937
+ if (entry.binding?.kind === "admitted") {
938
+ if (entry.phase !== "apply") out.push({ rule: "A7", path: `repositories[${index}].binding`, message: "is admitted, which only an apply set can be" });
939
+ if (entry.binding.setupChangeSet === entry.changeSet) out.push({ rule: "A7", path: `repositories[${index}].binding.setupChangeSet`, message: "is this repository's own change set, not the setup set it follows" });
940
+ if (entry.binding.subjectDigest === bundle.bundleDigest) out.push({ rule: "A7", path: `repositories[${index}].binding.subjectDigest`, message: "is this bundle's own digest, but the approved bundle held the setup set, not this apply set" });
941
+ }
942
+ });
943
+ if (bundle.mode === "planned" && !bundle.plan.committed) out.push({ rule: "A6", path: "plan.committed", message: "must be true in a planned bundle" });
944
+ if (bundle.mode === "planned") {
945
+ const bound = bundle.repositories.flatMap((entry, index) => ("changeSet" in entry && entry.binding !== undefined ? [{ subject: entry.binding.subjectDigest, index }] : []));
946
+ const first = bound[0];
947
+ for (const { subject, index } of bound) {
948
+ if (first !== undefined && subject !== first.subject) out.push({ rule: "A7", path: `repositories[${index}].binding.subjectDigest`, message: `is not the approval repositories[${first.index}] is bound by, and one bundle has one approval` });
949
+ }
950
+ // A plan with package acts has a snapshot; V3 passes for package acts only with a current execution authorization (a plan with none is bound by its decision alone).
951
+ if (first !== undefined && bundle.snapshot !== null && bundle.authorization === null) {
952
+ out.push({ rule: "A7", path: "authorization", message: "is null, but V3 passed for a bound change set of a plan with package acts, which needs a current execution authorization" });
953
+ }
954
+ }
955
+ return out;
956
+ }
957
+
958
+ function violationsOf<T, R extends ChangeSetRuleId | ApplyBundleRuleId>(contractName: string, label: string, value: unknown, rules: (document: T) => readonly RuleViolation<R>[]): ChangeSetViolation[] {
959
+ const schema = validateAgainstContract(loadContract(contractName), value, loadContract);
960
+ if (schema.length > 0) return schema.map((violation) => ({ rule: "schema", path: violation.path, message: formatContractViolation(label, violation) }));
961
+ return rules(value as T).map((violation) => ({ rule: violation.rule, path: violation.path, message: `${label}.${violation.path} ${violation.message} (rule ${violation.rule})` }));
962
+ }
963
+
964
+ function result(violations: readonly ChangeSetViolation[]): ValidationResult {
965
+ if (violations.length === 0) return { valid: true };
966
+ return { valid: false, reason: violations.map((violation) => violation.message).join("; ") };
967
+ }
968
+
969
+ /** Every reason a change set is refused: the change-set contract's schema, then, once that passes, its code rules C1-C16. */
970
+ export function repositoryChangeSetViolations(value: unknown): ChangeSetViolation[] {
971
+ return violationsOf<RepositoryChangeSet, ChangeSetRuleId>("repository-change-set.json", "changeSet", value, changeSetRuleViolations);
972
+ }
973
+
974
+ /** Every reason a bundle is refused: the bundle contract's schema, then, once that passes, its code rules A1-A7. */
975
+ export function applyBundleViolations(value: unknown): ChangeSetViolation[] {
976
+ return violationsOf<ApplyBundle, ApplyBundleRuleId>("apply-bundle.json", "bundle", value, applyBundleRuleViolations);
977
+ }
978
+
979
+ /** Validates a change set against repository-change-set.json and its code rules C1-C16. No reason echoes a value. */
980
+ export function validateRepositoryChangeSet(value: unknown): ValidationResult {
981
+ return result(repositoryChangeSetViolations(value));
982
+ }
983
+
984
+ /** Validates a bundle against apply-bundle.json and its code rules A1-A7. No reason echoes a value. */
985
+ export function validateApplyBundle(value: unknown): ValidationResult {
986
+ return result(applyBundleViolations(value));
987
+ }