@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,261 @@
1
+ // The title and body of one repository's pull request (issue #1178; the apply
2
+ // RFC's section 6 body marker, threat T6, decision D31 and section 12.7, in the
3
+ // public repository, not shipped in this package). A pure function of three
4
+ // inputs: a stored change set, the ApprovalBinding admission decided for it,
5
+ // and the number of the task-record issue. It reads nothing, writes nothing and
6
+ // opens nothing; a later step decides the binding, passes it in, records
7
+ // `bodySha256` and hands the text to the agent that opens the pull request.
8
+ // The caller may also pass the numbers of the older pull requests this one
9
+ // replaces; they are written ascending under `## Supersedes` and nothing else.
10
+ //
11
+ // This is a public-safety surface, so every ambiguity refuses:
12
+ //
13
+ // - The text carries ids, act names, versions and digests only: never a brief,
14
+ // plan prose, `texts`, a key value, a path or a file's contents. Every value
15
+ // is matched against its own strict pattern before it is written, and any
16
+ // that holds `<`, `>`, a backtick, `|`, a carriage return or a line feed is
17
+ // refused, so no value can start a marker, close a code span or add a line.
18
+ // - Each value is written inside a code span, so a package name such as
19
+ // `@scope/name` is never read by GitHub as a mention.
20
+ // - The body opens with the one marker line and holds no other, and it is read
21
+ // back with readChangeSetMarker before it is returned.
22
+ // - The renderer does not decide the binding: it shows what the caller passes.
23
+ // It does not check that the task-record issue exists, and it cannot stop a
24
+ // pull request's body being edited after it is opened; `bodySha256` and the
25
+ // marker are how a later `status` sees that.
26
+ //
27
+ // A refusal carries a fixed token, never an id, a digest or any input text.
28
+
29
+ import { createHash } from "node:crypto";
30
+ import { validateRepositoryChangeSet } from "./change-set-contract.js";
31
+ import type { ApprovalBinding, ChangeSetItem, RepositoryChangeSet } from "./change-set-contract.js";
32
+ import { changeSetDigest } from "./change-set-digest.js";
33
+
34
+ export type PullRequestRefusalReason =
35
+ | "change-set-invalid"
36
+ | "title-mismatch"
37
+ | "binding-invalid"
38
+ | "binding-phase-mismatch"
39
+ | "task-record-invalid"
40
+ | "supersedes-invalid"
41
+ | "value-unsafe"
42
+ | "body-invalid"
43
+ | "render-failed";
44
+
45
+ /** A refusal: a fixed token, never a value. */
46
+ export interface PullRequestRefusal {
47
+ readonly state: "refused";
48
+ readonly reason: PullRequestRefusalReason;
49
+ }
50
+
51
+ export interface RenderPullRequestInput {
52
+ readonly set: RepositoryChangeSet;
53
+ /** What admission decided for this set (decideSetBinding). It is shown, not checked here beyond its shape. */
54
+ readonly binding: ApprovalBinding;
55
+ /** The number of the task-record issue in the target repository: a positive safe integer. */
56
+ readonly taskRecord: number;
57
+ /**
58
+ * The numbers of the pull requests of older change sets this one replaces (RFC section 6, D6): each a positive safe integer,
59
+ * none repeated and none the task record. Written ascending under `## Supersedes`. Absent or empty: no such section, and the
60
+ * body is the one it was without this option.
61
+ */
62
+ readonly supersedes?: readonly number[];
63
+ }
64
+
65
+ export interface PullRequestText {
66
+ readonly state: "rendered";
67
+ readonly title: string;
68
+ readonly body: string;
69
+ /** `sha256:` and the hex SHA-256 of the body's UTF-8 bytes. */
70
+ readonly bodySha256: string;
71
+ }
72
+
73
+ const DIGEST = /^sha256:[0-9a-f]{64}$/u;
74
+ const TITLE = /^Clossys: apply plan [0-9a-f]{12}$/u;
75
+ const MARKER_LINE = /^<!-- clossys-change-set: (sha256:[0-9a-f]{64}) -->$/u;
76
+ const REPOSITORY_ID = /^(?:[A-Za-z0-9][A-Za-z0-9-]{0,38}\/)?[A-Za-z0-9._-]{1,100}$/u;
77
+ // 355 characters: a repository id of up to 140, a colon and a package name of up to 214 (the planItem the contract derives, C16).
78
+ const ITEM_ID = /^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,354}$/u;
79
+ const ID_TOKEN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
80
+ const PACKAGE_NAME = /^(?=.{1,214}$)@[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*$/u;
81
+ const EXACT_VERSION = /^(?:0|[1-9][0-9]{0,15})\.(?:0|[1-9][0-9]{0,15})\.(?:0|[1-9][0-9]{0,15})$/u;
82
+ const UNSAFE = /[<>`|\r\n]/u;
83
+ const PRINTABLE_LINES = /^[\x20-\x7e\n]*$/u;
84
+ const MARKER_WORD = /clossys-change-set/giu;
85
+
86
+ const PHASES: ReadonlySet<string> = new Set(["setup", "apply"]);
87
+ const ACTS: ReadonlySet<string> = new Set([
88
+ "write-record",
89
+ "compose-skills",
90
+ "install",
91
+ "pin-starter",
92
+ "exempt-release-age",
93
+ "declare-root-entry",
94
+ "write-ledger",
95
+ "add-caller-workflow",
96
+ "write-starter-request",
97
+ "add-ci-template",
98
+ "add-path-scope-job",
99
+ ]);
100
+
101
+ const refuse = (reason: PullRequestRefusalReason): PullRequestRefusal => ({ state: "refused", reason });
102
+
103
+ /** A value that may be interpolated: a string that matches its own pattern and holds nothing that could start a marker, close a code span or add a line. */
104
+ function safe(value: unknown, pattern: RegExp): value is string {
105
+ return typeof value === "string" && !UNSAFE.test(value) && pattern.test(value);
106
+ }
107
+
108
+ const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value);
109
+
110
+ const sha256Of = (text: string): string => `sha256:${createHash("sha256").update(Buffer.from(text, "utf8")).digest("hex")}`;
111
+
112
+ /**
113
+ * The change-set digest a body carries, or null. Exactly one line must be
114
+ * exactly `<!-- clossys-change-set: sha256:<64 hex> -->`, and the marker's
115
+ * name must appear nowhere else in the body, so a second marker line, or a
116
+ * marker inside another line, reads as none.
117
+ */
118
+ export function readChangeSetMarker(body: string): string | null {
119
+ if (typeof body !== "string") return null;
120
+ const found = body.split("\n").flatMap((line) => MARKER_LINE.exec(line)?.[1] ?? []);
121
+ if (found.length !== 1) return null;
122
+ if ((body.match(MARKER_WORD) ?? []).length !== 1) return null;
123
+ return found[0]!;
124
+ }
125
+
126
+ function itemLine(item: ChangeSetItem): string | null {
127
+ if (!safe(item.id, ITEM_ID) || typeof item.act !== "string" || !ACTS.has(item.act)) return null;
128
+ if (item.act !== "install" && item.act !== "pin-starter") return `- \`${item.id}\` \`${item.act}\``;
129
+ if (!safe(item.package.name, PACKAGE_NAME) || !safe(item.package.version, EXACT_VERSION)) return null;
130
+ return `- \`${item.id}\` \`${item.act}\` \`${item.package.name}@${item.package.version}\``;
131
+ }
132
+
133
+ function bindingLines(binding: unknown, set: RepositoryChangeSet): string[] | PullRequestRefusal {
134
+ if (!isRecord(binding)) return refuse("binding-invalid");
135
+ const keys = Object.keys(binding).sort().join(",");
136
+ if (binding.kind === "approved") {
137
+ if (keys !== "kind,subjectDigest" || !safe(binding.subjectDigest, DIGEST)) return refuse("binding-invalid");
138
+ return ["- Binding: `approved`", `- Approved subject: \`${binding.subjectDigest}\``];
139
+ }
140
+ if (binding.kind === "admitted") {
141
+ if (keys !== "kind,setupChangeSet,subjectDigest" || !safe(binding.subjectDigest, DIGEST) || !safe(binding.setupChangeSet, DIGEST)) return refuse("binding-invalid");
142
+ if (set.phase !== "apply") return refuse("binding-phase-mismatch");
143
+ // Admission never binds a set to itself, or to the bundle that holds the set.
144
+ if (binding.setupChangeSet === set.changeSetDigest || binding.subjectDigest === set.bundle) return refuse("binding-invalid");
145
+ return ["- Binding: `admitted`", `- Admitted subject: \`${binding.subjectDigest}\``, `- Setup change set: \`${binding.setupChangeSet}\``];
146
+ }
147
+ return refuse("binding-invalid");
148
+ }
149
+
150
+ /**
151
+ * The pull request title and body for one stored change set. The title is
152
+ * exactly `set.pullRequest.title`, which must be `Clossys: apply plan ` and
153
+ * the first 12 hex digits of the set's own digest. Refuses a set that fails
154
+ * validateRepositoryChangeSet or whose digest does not recompute, a malformed
155
+ * binding, an `admitted` binding on a setup set, a task record that is not a
156
+ * positive safe integer, and any value it cannot prove safe to write. Never
157
+ * throws.
158
+ */
159
+ export function renderPullRequest(input: RenderPullRequestInput): PullRequestText | PullRequestRefusal {
160
+ try {
161
+ return render(input);
162
+ } catch {
163
+ return refuse("render-failed");
164
+ }
165
+ }
166
+
167
+ function render(input: RenderPullRequestInput): PullRequestText | PullRequestRefusal {
168
+ const { taskRecord } = input;
169
+ if (typeof taskRecord !== "number" || !Number.isSafeInteger(taskRecord) || taskRecord < 1) return refuse("task-record-invalid");
170
+
171
+ // Read once, like the set: what is checked is what is written. Only a list of distinct positive safe integers other than the task record passes.
172
+ let supersedes: number[] = [];
173
+ if (input.supersedes !== undefined) {
174
+ let read: unknown;
175
+ try {
176
+ read = JSON.parse(JSON.stringify(input.supersedes));
177
+ } catch {
178
+ return refuse("supersedes-invalid");
179
+ }
180
+ if (!Array.isArray(read)) return refuse("supersedes-invalid");
181
+ for (const entry of read as unknown[]) if (typeof entry !== "number" || !Number.isSafeInteger(entry) || entry < 1 || entry === taskRecord) return refuse("supersedes-invalid");
182
+ supersedes = (read as number[]).slice().sort((left, right) => left - right);
183
+ if (supersedes.some((entry, index) => index > 0 && entry === supersedes[index - 1])) return refuse("supersedes-invalid");
184
+ }
185
+
186
+ // One snapshot, so a getter or a proxy cannot show the checks one value and the text another.
187
+ let set: RepositoryChangeSet;
188
+ let binding: unknown;
189
+ try {
190
+ set = JSON.parse(JSON.stringify(input.set)) as RepositoryChangeSet;
191
+ } catch {
192
+ return refuse("change-set-invalid");
193
+ }
194
+ try {
195
+ binding = JSON.parse(JSON.stringify(input.binding));
196
+ } catch {
197
+ return refuse("binding-invalid");
198
+ }
199
+ if (!validateRepositoryChangeSet(set).valid || changeSetDigest(set) !== set.changeSetDigest) return refuse("change-set-invalid");
200
+
201
+ // The contract's rule C5 already ties the title to the digest; this is the same rule, held here too.
202
+ const digest = set.changeSetDigest;
203
+ if (!safe(digest, DIGEST)) return refuse("change-set-invalid");
204
+ if (!safe(set.pullRequest.title, TITLE) || set.pullRequest.title !== `Clossys: apply plan ${digest.slice("sha256:".length, "sha256:".length + 12)}`) return refuse("title-mismatch");
205
+
206
+ const bound = bindingLines(binding, set);
207
+ if (!Array.isArray(bound)) return bound;
208
+
209
+ const repositoryId = set.repository.id;
210
+ if (!safe(repositoryId, REPOSITORY_ID) || repositoryId === "." || repositoryId === ".." || repositoryId.endsWith("/.") || repositoryId.endsWith("/..")) return refuse("value-unsafe");
211
+ if (typeof set.phase !== "string" || !PHASES.has(set.phase)) return refuse("value-unsafe");
212
+ if (!safe(set.planDigest, DIGEST) || !safe(set.bundle, DIGEST)) return refuse("value-unsafe");
213
+
214
+ const items: string[] = [];
215
+ for (const item of set.items) {
216
+ const line = itemLine(item);
217
+ if (line === null) return refuse("value-unsafe");
218
+ items.push(line);
219
+ }
220
+
221
+ const notes: string[] = [];
222
+ for (const deferral of set.deferred) {
223
+ if (!safe(deferral.planItem, ITEM_ID) || !safe(deferral.reason, ID_TOKEN)) return refuse("value-unsafe");
224
+ notes.push(`- Deferred: \`${deferral.planItem}\` \`${deferral.reason}\``);
225
+ }
226
+ for (const refusal of set.refused) {
227
+ if (!safe(refusal.item, ITEM_ID) || !safe(refusal.reason, ID_TOKEN)) return refuse("value-unsafe");
228
+ notes.push(`- Refused: \`${refusal.item}\` \`${refusal.reason}\``);
229
+ }
230
+
231
+ const lines = [
232
+ `<!-- clossys-change-set: ${digest} -->`,
233
+ "",
234
+ "## Change set",
235
+ "",
236
+ `- Repository: \`${repositoryId}\``,
237
+ `- Phase: \`${set.phase}\``,
238
+ `- Change set: \`${digest}\``,
239
+ `- Plan: \`${set.planDigest}\``,
240
+ `- Bundle: \`${set.bundle}\``,
241
+ "",
242
+ "## Binding",
243
+ "",
244
+ ...bound,
245
+ "",
246
+ "## Items",
247
+ "",
248
+ ...items,
249
+ ...(notes.length === 0 ? [] : ["", "## Not applied", "", ...notes]),
250
+ ...(supersedes.length === 0 ? [] : ["", "## Supersedes", "", ...supersedes.map((number) => `- #${number}`)]),
251
+ "",
252
+ "## Task record",
253
+ "",
254
+ `- #${taskRecord}`,
255
+ ];
256
+ const body = `${lines.join("\n")}\n`;
257
+
258
+ // The last word: printable ASCII and line feeds only, no line feed at the end but one, and the marker reads back as this set's digest.
259
+ if (!PRINTABLE_LINES.test(body) || body.endsWith("\n\n") || readChangeSetMarker(body) !== digest || !body.startsWith(`<!-- clossys-change-set: ${digest} -->\n`)) return refuse("body-invalid");
260
+ return { state: "rendered", title: set.pullRequest.title, body, bodySha256: sha256Of(body) };
261
+ }