@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,964 @@
1
+ // The pure apply planner (issue #1178): from an approved plan's validated
2
+ // records and from observations of each staffed repository's default
3
+ // branch, it computes one change set per repository and the report-mode
4
+ // bundle that holds them. It performs no I/O of any kind -- no file, no
5
+ // network, no process, no clock -- so the same inputs always give the same
6
+ // bytes. Reading the repositories, the hub and the composed skills is the
7
+ // caller's job, and the caller hands the results in.
8
+ //
9
+ // The shapes it produces are defined by the shared contracts
10
+ // repository-change-set.json and apply-bundle.json, and the digests by
11
+ // apply-change-set-digest.md (all in the public repository, not shipped in
12
+ // this package). Every set and the bundle are validated against those
13
+ // contracts, code rules included, before they are returned; a planner
14
+ // defect throws rather than returning a set that does not validate.
15
+ //
16
+ // Ownership follows the apply RFC's desired-minus-installed table (§12.1)
17
+ // over the installed-state ledger the base carries, and only once that
18
+ // ledger is trusted (ledger-trust.ts): every generation it records must be a
19
+ // change set the hub holds, and every row one of those sets' own writes;
20
+ // anything less skips the whole repository. A whole file is written only by
21
+ // compare-and-swap against the ledger's row: added where neither the ledger
22
+ // nor the base has it, kept or updated where the base still holds the bytes
23
+ // the flow last wrote, and refused otherwise (unowned-existing,
24
+ // client-edited, deleted). An owned package key follows the same table. The
25
+ // planner never removes what the ledger records and the desired state no
26
+ // longer names; it reports that (removal-unbuilt) and leaves the row to be
27
+ // carried forward. The bundle still claims no repository state.
28
+ //
29
+ // A setup-phase repository gets a setup set (code rules C10 to C12): the
30
+ // brief, the skills and the ledger as an apply set has them, exactly one item
31
+ // for each of the four setup templates -- whose bytes come only from
32
+ // renderSetupTemplate() -- the Starter pin the plan names, every install the
33
+ // plan names deferred until after setup, and, for pnpm, the one edit that
34
+ // exempts the publishing scope from the release-age window. A setup set is
35
+ // also the only place the generation-0 adoption pass runs (RFC §12.2): a file
36
+ // the base already holds is adopted only by byte proof. Every ambiguity is a
37
+ // skip with its own reason id (`package-manager-unsupported`,
38
+ // `starter-pin-absent`, `starter-pin-unsupported`, `release-age-text-absent`,
39
+ // `starter-request-invalid`, and, for an apply set, `starter-request-stale`),
40
+ // never a guess. A repository whose Controller profile needs root entries
41
+ // added (code rule C13) is skipped as root-entry-edit-unbuilt when the
42
+ // observation does not carry the profile text; when repositoryProfileText is
43
+ // present, the profile is edited here.
44
+
45
+ import type { AdvisorPlan, EngagementBrief, EngagementBriefRole, EngagementContext, PlanPackageAct } from "./plan-contract.js";
46
+ import { loadContract, validateAdvisorPlan, validateEngagementBrief } from "./plan-contract.js";
47
+ import { planDigest } from "./plan-digest.js";
48
+ import { AGENTS_GUIDE_PATH, AGENTS_GUIDE_TEXT } from "./agents-guide.js";
49
+ import { HUB_ONLY_ROLES } from "./plan-rules.js";
50
+ import { bundleDigest, changeSetDigest } from "./change-set-digest.js";
51
+ import {
52
+ AUTHORIZATION_ABSENT, AUTHORIZATION_PLAN_MISMATCH, BRIEF_PATH, CANONICAL_KEYS, DISCOVERY_ROOTS, EXEMPTION_SURFACES, ID_TOKEN, LEDGER_PATH, derivedPlanItem, SKILLS_MANIFEST_PATH, TEMPLATE_PATHS, canonicalOrder, contentDigest,
53
+ dependencyPointer, discoveryLinkPath, discoveryLinkTarget, isSafeRelativePath, lockfilePath, matchesPathPattern, skillPath, validateApplyBundle, validateRepositoryChangeSet,
54
+ worstVerdict,
55
+ } from "./change-set-contract.js";
56
+ import type {
57
+ ApplyBundle, ApplyBundleRepository, ApplyCheck, ChangeSetDeferral, ChangeSetItem, ChangeSetPhase, ChangeSetRefusal, DependencyPlacement, DiscoveryRoot,
58
+ FileChange, KeyChange, LockfileName, PackageInvariant, PackageManagerKind, PinnedPackage, ReleaseAgeSurfaceKind, RepositoryChangeSet, RepositoryProfileObservation,
59
+ RepositoryVisibility, TemplateAct,
60
+ } from "./change-set-contract.js";
61
+ import type { InstalledLedger } from "./ledger-contract.js";
62
+ import { PACKAGE_SCOPE } from "./generated/package-scope.generated.js";
63
+ import { JsonEditUnstableError, editJsonPointer } from "./key-editor.js";
64
+ import { reconcileWholeFile, trustInstalledLedger } from "./ledger-trust.js";
65
+ import type { PlanPackageActs } from "./ledger-trust.js";
66
+ import { editReleaseAgeExemption } from "./release-age-edit.js";
67
+ import { renderSetupTemplate } from "./setup-templates.js";
68
+
69
+ /** What was read from one staffed repository's default branch. The planner trusts it as given. */
70
+ export interface RepositoryObservation {
71
+ /** The repository inventory id, spelled exactly as in the plan's staffing. */
72
+ readonly id: string;
73
+ /** GitHub's immutable node id for the repository. */
74
+ readonly nodeId: string;
75
+ readonly visibility: RepositoryVisibility;
76
+ readonly defaultBranch: string;
77
+ /** The default branch's head commit. */
78
+ readonly baseCommit: string;
79
+ /** setup unless the base already carries what proves a later pull request; decided from the base by the caller. A setup repository gets a setup set. */
80
+ readonly phase: ChangeSetPhase;
81
+ readonly packageManager: PackageManagerKind;
82
+ readonly lockfile: LockfileName;
83
+ readonly releaseAgeSurfaces: readonly { readonly surface: ReleaseAgeSurfaceKind; readonly path: string }[];
84
+ /** Whether the default branch has a workflow of its own, one whose file name does not start with clossys-. */
85
+ readonly consumerCi: boolean;
86
+ /** The discovery roots that are, or lie under, a symbolic link on the default branch; no discovery link is written under them. */
87
+ readonly symlinkedSkillRoots: readonly DiscoveryRoot[];
88
+ /**
89
+ * The Controller repository profile the default branch declares, or null:
90
+ * its path, whether it has a root vocabulary Controller checks, and which
91
+ * root names this set introduces that it does not declare or prohibits
92
+ * (`wouldViolateRootEntries()` judges these from the profile).
93
+ */
94
+ readonly repositoryProfile: RepositoryProfileObservation | null;
95
+ /** Which of `.agents`, `.agents/skills` and `.agents/skills/clossys-<role>` is a symbolic link on the default branch. */
96
+ readonly linkedAgentsPaths: readonly string[];
97
+ /**
98
+ * Every file on the default branch that the apply flow may write -- under
99
+ * `clossys/`, under `.agents/skills/`, under `.claude/skills/` and
100
+ * `.cursor/skills/`, the setup templates (`.github/workflows/clossys-*`,
101
+ * `.github/scripts/clossys-*` and `.starter/request.json`), and the
102
+ * lockfile -- with its content digest (`sha256:` and 64 hex digits; a
103
+ * symbolic link's content is its target). A path not listed is read as
104
+ * absent, so this must be complete for those paths.
105
+ */
106
+ readonly files: readonly { readonly path: string; readonly sha256: string }[];
107
+ /** Every entry of the default branch's package.json `dependencies` and `devDependencies`: the name and its value as written. */
108
+ readonly manifestEntries: readonly { readonly placement: DependencyPlacement; readonly name: string; readonly value: string }[];
109
+ /** What the default branch's lockfile resolves each package to. */
110
+ readonly lockedPackages: readonly PinnedPackage[];
111
+ /**
112
+ * The exact bytes of the installed-state ledger, clossys/.state/installed.json,
113
+ * at baseCommit, or null when the base has none. It is trusted only as
114
+ * trustInstalledLedger() allows, against `heldChangeSets`; its generation
115
+ * is the one the set is computed over.
116
+ */
117
+ readonly ledger: Uint8Array | null;
118
+ /**
119
+ * The entries of the base's composed-skill manifest,
120
+ * clossys/.state/skills.json -- each skill's name and the 64 hex digits of
121
+ * its SKILL.md's SHA-256, with no `sha256:` prefix, as
122
+ * serializeComposedSkillsManifest() writes them -- or null when the base
123
+ * has none or it cannot be read. Only a setup set's adoption pass reads it.
124
+ */
125
+ readonly skillsManifest: readonly { readonly name: string; readonly sha256: string }[] | null;
126
+ /** Exact bytes of the Controller repository profile on the default branch, when the caller read them for declare-root-entry (code rule C13). */
127
+ readonly repositoryProfileText?: string | null;
128
+ /**
129
+ * Exact text of pnpm-workspace.yaml on the default branch, when the caller read it (null or absent: not supplied). A pnpm setup
130
+ * set edits this file to exempt the publishing scope from the release-age window; when the file is there and its text is not
131
+ * supplied, the repository is skipped as `release-age-text-absent`. It must be the file `files` digests, or the planner throws.
132
+ */
133
+ readonly pnpmWorkspaceText?: string | null;
134
+ /**
135
+ * Exact text of .npmrc on the default branch, when the caller read it (null or absent: not supplied). A pnpm setup set reads it
136
+ * to refuse an edit that a setting there would contradict; when the file is there and its text is not supplied, the repository
137
+ * is skipped as `release-age-text-absent`. `files` must digest .npmrc too, and the text must be that file, or the planner throws.
138
+ */
139
+ readonly npmrcText?: string | null;
140
+ }
141
+
142
+ /** A staffed repository no change set is computed for, and why, as an id such as `not-in-inventory`. */
143
+ export interface SkippedRepositoryObservation {
144
+ readonly id: string;
145
+ readonly skipped: string;
146
+ readonly verdict?: "violated" | "indeterminate";
147
+ }
148
+
149
+ export interface PlanApplyBundleInputs {
150
+ /** The plan, clossys/advisor/plan.json. It must validate and have `staffing`. */
151
+ readonly plan: AdvisorPlan;
152
+ /** The hub brief, clossys/advisor/brief.json: validated, and without `staffedHere`. Each repository gets its own projection of it. */
153
+ readonly hubBrief: EngagementBrief;
154
+ /** One entry per staffed repository; a staffed repository with no entry is skipped as `not-observed`. */
155
+ readonly repositories: readonly (RepositoryObservation | SkippedRepositoryObservation)[];
156
+ /** The composed SKILL.md text for every staffed role, and for the Advisor voice every staffed repository also gets (D33). */
157
+ readonly skills: readonly { readonly role: string; readonly content: string }[];
158
+ /** The package and exact version computing the sets. */
159
+ readonly producer: { readonly name: string; readonly version: string };
160
+ /** The exact Advisor package the hub pins. */
161
+ readonly engine: PinnedPackage;
162
+ /** The exact Integrator package the hub pins. */
163
+ readonly integrator: PinnedPackage;
164
+ /** Whether the plan read is the one committed at the hub's default-branch head. */
165
+ readonly planCommitted: boolean;
166
+ /** The execution authorization for the plan's package acts, or null when there is none. */
167
+ readonly authorization: { readonly planDigest: string; readonly expiresAt: string } | null;
168
+ /** When the bundle is computed, supplied by the caller: the planner reads no clock. */
169
+ readonly computedAt: string;
170
+ /** Every change set the hub holds (clossys/.state/apply/change-sets/), read by the caller; a ledger is trusted only against these. */
171
+ readonly heldChangeSets: readonly RepositoryChangeSet[];
172
+ /** Package identities by plan digest for ledger history the held sets name; defaults to this plan's acts per staffed repository. */
173
+ readonly planPackageActs?: readonly PlanPackageActs[];
174
+ }
175
+
176
+ export interface PlanApplyBundleResult {
177
+ readonly bundle: ApplyBundle;
178
+ /** One change set per repository that has one, in the plan's staffing order. */
179
+ readonly changeSets: readonly RepositoryChangeSet[];
180
+ }
181
+
182
+ /** The fixed text a brief carries as its problem in a repository that is not private, read from the packed brief contract. */
183
+ export const PUBLIC_PROBLEM_PLACEHOLDER: string = (() => {
184
+ const definitions = loadContract("engagement-brief.json").definitions as Record<string, { const?: unknown }> | undefined;
185
+ const text = definitions?.publicProblemPlaceholder?.const;
186
+ if (typeof text !== "string") throw new Error("the packed brief contract has no publicProblemPlaceholder text");
187
+ return text;
188
+ })();
189
+
190
+ const BASE_ALLOW_LIST = [".agents/skills/clossys-*/**", "clossys/**"];
191
+ const ROOT_ENTRIES_ITEM = "root-entries";
192
+ const PROFILE_ALLOW_PATTERNS = ["**/repository-profile.json", "**/repository-declaration.json"];
193
+
194
+ /** Each setup template act, with the item id a set gives it. */
195
+ const TEMPLATE_ITEMS: readonly { readonly act: TemplateAct; readonly id: string }[] = [
196
+ { act: "add-caller-workflow", id: "caller-workflow" },
197
+ { act: "write-starter-request", id: "starter-request" },
198
+ { act: "add-ci-template", id: "ci-template" },
199
+ { act: "add-path-scope-job", id: "path-scope-job" },
200
+ ];
201
+ /** The patterns a set that names the setup templates adds to its pathAllowList. */
202
+ const TEMPLATE_ALLOW_LIST = [".github/scripts/clossys-*", ".github/workflows/clossys-*", ".starter/request.json"];
203
+ /** The one exempt-release-age item a pnpm set carries: a fixed id no plan text can spell, because a planItem is `repository:package`. */
204
+ const RELEASE_AGE_ITEM = "release-age";
205
+ const RELEASE_AGE_SURFACE = "pnpm-workspace";
206
+ const AGENTS_GUIDE_ITEM = "agents-guide";
207
+ const RESERVED_ITEM_IDS = new Set(["brief", AGENTS_GUIDE_ITEM, "skills", "ledger", ROOT_ENTRIES_ITEM, RELEASE_AGE_ITEM, ...TEMPLATE_ITEMS.map((template) => template.id)]);
208
+ const NPMRC_PATH = ".npmrc";
209
+
210
+ /**
211
+ * The Advisor voice (D33): every staffed repository gets it beside its
212
+ * staffed roles' voices. It is a hub-only role, never staffed itself (plan
213
+ * rule R11), so it can never repeat a staffed role; a packed plan contract
214
+ * that does not list it as hub-only is a build defect.
215
+ */
216
+ const ADVISOR_VOICE = "advisor";
217
+ if (!HUB_ONLY_ROLES.includes(ADVISOR_VOICE)) throw new Error("the packed plan contract's hub-only roles do not include the Advisor voice");
218
+
219
+ function projectRole(role: EngagementBriefRole): EngagementBriefRole {
220
+ return {
221
+ role: role.role,
222
+ why: role.why,
223
+ goal: { metric: role.goal.metric, direction: role.goal.direction },
224
+ inputsFrom: [...role.inputsFrom],
225
+ outputsTo: [...role.outputsTo],
226
+ };
227
+ }
228
+
229
+ function projectContext(context: EngagementContext): EngagementContext {
230
+ return {
231
+ schemaVersion: context.schemaVersion,
232
+ fields: context.fields.map((field) => (field.state === "known" ? { id: field.id, state: field.state, value: field.value } : { id: field.id, state: field.state })),
233
+ };
234
+ }
235
+
236
+ /**
237
+ * One repository's brief, projected from the hub brief: `staffedHere` is set
238
+ * to the roles staffed there, in plan order, and `problem` is replaced by
239
+ * PUBLIC_PROBLEM_PLACEHOLDER unless the repository is private. Nothing else
240
+ * changes. Members are written in one fixed order at every depth -- the order
241
+ * the brief and context contracts declare them -- whatever order the hub
242
+ * brief's file used, because these bytes feed the change-set digest.
243
+ */
244
+ export function projectEngagementBrief(hubBrief: EngagementBrief, staffedHere: readonly string[], visibility: RepositoryVisibility): EngagementBrief {
245
+ return {
246
+ schemaVersion: hubBrief.schemaVersion,
247
+ problem: visibility === "private" ? hubBrief.problem : PUBLIC_PROBLEM_PLACEHOLDER,
248
+ roles: hubBrief.roles.map(projectRole),
249
+ sequence: [...hubBrief.sequence],
250
+ deliverables: [...hubBrief.deliverables],
251
+ staffedHere: [...staffedHere],
252
+ ...(hubBrief.context === undefined ? {} : { context: projectContext(hubBrief.context) }),
253
+ };
254
+ }
255
+
256
+ /** The exact bytes written to clossys/brief.json: two-space JSON and one final newline. */
257
+ export function serializeEngagementBrief(brief: EngagementBrief): string {
258
+ return `${JSON.stringify(brief, null, 2)}\n`;
259
+ }
260
+
261
+ /**
262
+ * The exact bytes of clossys/.state/skills.json for the skills a set writes:
263
+ * each role's name, source catalogue, the hex SHA-256 of its SKILL.md and the
264
+ * producer's version, sorted by name, as two-space JSON and one line feed,
265
+ * with no time in it (apply-change-set-digest.md, Content digests).
266
+ */
267
+ export function serializeComposedSkillsManifest(skills: readonly { readonly role: string; readonly sha256: string }[], producerVersion: string): string {
268
+ const entries = canonicalOrder(skills, (skill) => [skill.role]).map((skill) => ({
269
+ name: skill.role,
270
+ source: "catalogue",
271
+ sha256: skill.sha256.slice("sha256:".length),
272
+ version: producerVersion,
273
+ }));
274
+ return `${JSON.stringify({ schemaVersion: 1, skills: entries }, null, 2)}\n`;
275
+ }
276
+
277
+ function isSkipped(entry: RepositoryObservation | SkippedRepositoryObservation): entry is SkippedRepositoryObservation {
278
+ return Object.hasOwn(entry, "skipped");
279
+ }
280
+
281
+ /** Checks in one order, by check id, then rule. */
282
+ function sortChecks(checks: readonly ApplyCheck[]): ApplyCheck[] {
283
+ return canonicalOrder(checks, (check) => [check.check, check.rule ?? ""]);
284
+ }
285
+
286
+ const CASE_VARIANT = "case-variant";
287
+
288
+ /** The content digest the observation lists for a path (compared case-insensitively, as C3 does); null when none, CASE_VARIANT when several. */
289
+ function observedDigest(observation: RepositoryObservation, path: string): string | null {
290
+ const lower = path.toLowerCase();
291
+ const held = observation.files.filter((file) => file.path.toLowerCase() === lower);
292
+ if (held.length === 0) return null;
293
+ return held.length === 1 ? held[0]!.sha256 : CASE_VARIANT;
294
+ }
295
+
296
+ /** Throws unless each release-age surface text the observation supplies is exactly the file it digests. Names the field, never the text. */
297
+ function checkSurfaceTexts(observation: RepositoryObservation): void {
298
+ for (const { field, path, text } of [
299
+ { field: "pnpmWorkspaceText", path: EXEMPTION_SURFACES[RELEASE_AGE_SURFACE].path, text: observation.pnpmWorkspaceText },
300
+ { field: "npmrcText", path: NPMRC_PATH, text: observation.npmrcText },
301
+ ]) {
302
+ if (typeof text !== "string") continue;
303
+ if (observedDigest(observation, path) !== contentDigest(text)) throw new TypeError(`${field} does not match the observed ${path} file`);
304
+ }
305
+ }
306
+
307
+ /** The files one setup template act writes, with the item that names them. */
308
+ interface SetupTemplate {
309
+ readonly id: string;
310
+ readonly files: readonly { readonly path: string; readonly bytes: string }[];
311
+ }
312
+
313
+ /**
314
+ * Renders the four setup templates for a repository in the setup phase, or
315
+ * names why it cannot: every reason is an id, and the first one found is the
316
+ * one given. The bytes come from renderSetupTemplate() alone; the request
317
+ * takes the manager, the repository id and the plan's one Starter pin, and
318
+ * nothing else reaches a template.
319
+ */
320
+ function prepareSetup(observation: RepositoryObservation, acts: readonly PlanPackageAct[]): { readonly templates: readonly SetupTemplate[] } | { readonly skip: string } {
321
+ const packageManager = observation.packageManager;
322
+ if (packageManager !== "npm" && packageManager !== "pnpm") return { skip: "package-manager-unsupported" };
323
+ const pins = acts.filter((act) => act.act === "pin-starter");
324
+ // Plan rule R10 allows at most one, so any other count is an absent pin: a set pins Starter exactly once (C11).
325
+ if (pins.length !== 1) return { skip: "starter-pin-absent" };
326
+ const pin = pins[0]!;
327
+ const request = renderSetupTemplate("write-starter-request", { packageManager, repository: observation.id, starter: { name: pin.name, version: pin.version, integrity: pin.integrity } });
328
+ if (!request.ok) return { skip: request.refusal.reason === "starter-pin-unsupported" ? "starter-pin-unsupported" : "starter-request-invalid" };
329
+ if (packageManager === "pnpm") {
330
+ // The edit needs the surface's exact text, and the .npmrc's when there is one: nothing is edited from a digest.
331
+ const surface = observedDigest(observation, EXEMPTION_SURFACES[RELEASE_AGE_SURFACE].path);
332
+ if (surface !== null && typeof observation.pnpmWorkspaceText !== "string") return { skip: "release-age-text-absent" };
333
+ const npmrc = observation.releaseAgeSurfaces.some((entry) => entry.path === NPMRC_PATH) || observedDigest(observation, NPMRC_PATH) !== null;
334
+ if (npmrc && typeof observation.npmrcText !== "string") return { skip: "release-age-text-absent" };
335
+ }
336
+ const templates: SetupTemplate[] = [];
337
+ for (const { act, id } of TEMPLATE_ITEMS) {
338
+ const rendered = act === "write-starter-request" ? request : renderSetupTemplate(act, act === "add-caller-workflow" ? { packageManager } : undefined);
339
+ if (!rendered.ok) throw new TypeError(`the setup template ${act} does not render`);
340
+ templates.push({ id, files: rendered.files });
341
+ }
342
+ return { templates };
343
+ }
344
+
345
+ interface ComputedSet {
346
+ readonly changeSet: Omit<RepositoryChangeSet, "branch" | "bundle" | "pullRequest" | "changeSetDigest">;
347
+ readonly checks: readonly ApplyCheck[];
348
+ }
349
+
350
+ /** A repository computeChangeSet() does not give a set, and why: an id such as `integrity-mismatch`. */
351
+ interface SkippedSet {
352
+ readonly skip: { readonly verdict: "violated" | "indeterminate"; readonly reason: string };
353
+ }
354
+
355
+ function computeChangeSet(
356
+ inputs: PlanApplyBundleInputs,
357
+ planDigestValue: string,
358
+ observation: RepositoryObservation,
359
+ ledger: InstalledLedger | null,
360
+ roles: readonly string[],
361
+ acts: readonly PlanPackageAct[],
362
+ skillContent: ReadonlyMap<string, string>,
363
+ setupTemplates: readonly SetupTemplate[] | null,
364
+ ): ComputedSet | SkippedSet {
365
+ // Paths compare case-insensitively (code rule C3): a base file that differs only in case is the same file on many checkouts.
366
+ // Two (or more) observed files at the same lowercase path -- distinct case variants, or a repeated entry -- have no single
367
+ // base digest between them: the path is occupied by other bytes than any one of them, so it can never be kept, updated or
368
+ // adopted, whatever the ledger says (fix for issue #1545). CASE_VARIANT_BASE is a sentinel that never equals a real content
369
+ // digest (those are always `sha256:` and 64 hex digits) or null, so it always reads as "occupied by bytes the flow does not
370
+ // own" wherever a base digest is compared.
371
+ const CASE_VARIANT_BASE = "case-variant";
372
+ const fileGroups = new Map<string, string[]>();
373
+ for (const file of observation.files) {
374
+ const key = file.path.toLowerCase();
375
+ const group = fileGroups.get(key);
376
+ if (group === undefined) fileGroups.set(key, [file.sha256]);
377
+ else group.push(file.sha256);
378
+ }
379
+ const isCaseVariant = (path: string) => (fileGroups.get(path.toLowerCase())?.length ?? 0) > 1;
380
+ const existingAt = (path: string): string | undefined => {
381
+ const group = fileGroups.get(path.toLowerCase());
382
+ if (group === undefined) return undefined;
383
+ return group.length > 1 ? CASE_VARIANT_BASE : group[0];
384
+ };
385
+ // The lockfile and the ledger are derived files this planner writes unconditionally; a case-variant there leaves no single
386
+ // base to compare-and-swap against, so the whole repository is skipped rather than guessing which variant is real.
387
+ const lockfile = lockfilePath(observation);
388
+ if (isCaseVariant(LEDGER_PATH) || (lockfile !== null && isCaseVariant(lockfile))) {
389
+ return { skip: { verdict: "indeterminate", reason: "case-variant-path" } };
390
+ }
391
+ // The trusted ledger's rows: files by path (case-insensitively, as C3 and L8 compare paths), keys by pointer.
392
+ const fileRows = new Map((ledger?.files ?? []).map((row) => [row.path.toLowerCase(), row.after]));
393
+ const fileRowAt = (path: string) => fileRows.get(path.toLowerCase()) ?? null;
394
+ const keyRows = new Map((ledger?.keys ?? []).map((row) => [row.pointer, row.value]));
395
+ const items: ChangeSetItem[] = [];
396
+ const files: FileChange[] = [];
397
+ const keys: KeyChange[] = [];
398
+ const refused: ChangeSetRefusal[] = [];
399
+ const deferred: ChangeSetDeferral[] = [];
400
+ const texts: Record<string, string> = {};
401
+ const pathAllowList = [...BASE_ALLOW_LIST];
402
+ // A path is present when a file is there, or when it is a directory holding one.
403
+ const presentAt = (path: string) => existingAt(path) !== undefined || observation.files.some((file) => file.path.toLowerCase().startsWith(`${path.toLowerCase()}/`));
404
+ // The one compare-and-swap table for a whole file (RFC §12.1); only a setup set may adopt what the base has (§12.2).
405
+ const reconcile = (path: string, desired: string) =>
406
+ reconcileWholeFile({
407
+ path,
408
+ desired,
409
+ row: fileRowAt(path),
410
+ base: existingAt(path) ?? null,
411
+ occupied: presentAt(path),
412
+ phase: observation.phase,
413
+ skillsManifest: observation.skillsManifest,
414
+ });
415
+
416
+ const writeWhole = (path: string, text: string, item: string, mode: "100644" | "120000" = "100644") => {
417
+ if (!isSafeRelativePath(path) || !pathAllowList.some((pattern) => matchesPathPattern(path, pattern))) {
418
+ refused.push({ path, reason: "unsafe-path", item });
419
+ return false;
420
+ }
421
+ const desired = contentDigest(text);
422
+ // A repository set up before the guide existed has a trusted ledger with no row for it, so an apply set may add the guide, and only
423
+ // the guide, and only where nothing at all is on disk. A file or directory already there is the client's: it falls through to reconcile.
424
+ const guideAdd = observation.phase === "apply" && ledger !== null && path === AGENTS_GUIDE_PATH && fileRowAt(path) === null && !presentAt(path);
425
+ const outcome = guideAdd ? ({ write: true, before: null } as const) : reconcile(path, desired);
426
+ if (!outcome.write) {
427
+ refused.push({ path, reason: outcome.reason, item });
428
+ return false;
429
+ }
430
+ // An apply set may keep or update only where the trusted ledger already has a row (issue #1545 fix 7).
431
+ if (observation.phase === "apply" && fileRowAt(path) === null && outcome.before === null && !guideAdd) {
432
+ refused.push({ path, reason: "unowned-existing", item });
433
+ return false;
434
+ }
435
+ // before is null (add), the desired digest (keep), or the bytes the flow last wrote (update).
436
+ files.push({ path, mode, before: outcome.before, after: desired, item });
437
+ texts[path] = text;
438
+ return true;
439
+ };
440
+
441
+ items.push({ id: "brief", act: "write-record", source: "engagement-brief" });
442
+ // The brief's staffedHere names the staffed roles only; the Advisor voice is not staffed (D33).
443
+ const brief = projectEngagementBrief(inputs.hubBrief, roles, observation.visibility);
444
+ const briefValidation = validateEngagementBrief(brief);
445
+ if (!briefValidation.valid) throw new TypeError(`a projected brief does not validate: ${briefValidation.reason}`);
446
+ writeWhole(BRIEF_PATH, serializeEngagementBrief(brief), "brief");
447
+
448
+ // The Launcher's own guide to clossys/ and the clossys-* skills: constant bytes, owned by the ledger row's digest (D10, D30).
449
+ items.push({ id: AGENTS_GUIDE_ITEM, act: "write-record", source: "agents-guide" });
450
+ writeWhole(AGENTS_GUIDE_PATH, AGENTS_GUIDE_TEXT, AGENTS_GUIDE_ITEM);
451
+
452
+ // Every staffed repository gets the Advisor voice beside its staffed roles' voices (D33), first, then plan order.
453
+ const voices = [ADVISOR_VOICE, ...roles];
454
+ items.push({ id: "skills", act: "compose-skills", roles: voices });
455
+ // A discovery link is written under each root the base does not have as a symbolic link: a write through one would land where it points.
456
+ const linkedRoots = DISCOVERY_ROOTS.filter((root) => !observation.symlinkedSkillRoots.includes(root));
457
+ for (const root of linkedRoots) pathAllowList.push(`${root}/clossys-*`);
458
+ const composed: { role: string; sha256: string }[] = [];
459
+ for (const role of voices) {
460
+ const content = skillContent.get(role);
461
+ if (content === undefined) throw new TypeError("a staffed role has no composed skill content in skills");
462
+ const skill = skillPath(role);
463
+ // Never write through a symbolic link: the bytes would land wherever it points.
464
+ if (observation.linkedAgentsPaths.some((link) => skill.startsWith(`${link}/`))) refused.push({ path: skill, reason: "skills-root-is-link", item: "skills" });
465
+ else if (writeWhole(skill, content, "skills")) {
466
+ // Added, kept or updated: the skill is the flow's, so the manifest lists it and its links are written.
467
+ composed.push({ role, sha256: contentDigest(content) });
468
+ // Links only to a skill the set writes: a link to a refused skill would expose one the flow does not own.
469
+ for (const root of linkedRoots) writeWhole(discoveryLinkPath(root, role), discoveryLinkTarget(role), "skills", "120000");
470
+ }
471
+ }
472
+ writeWhole(SKILLS_MANIFEST_PATH, serializeComposedSkillsManifest(composed, inputs.producer.version), "skills");
473
+
474
+ if (setupTemplates !== null) {
475
+ // A setup set holds each template once, its bytes from the renderer alone. The template patterns join the allow list before any
476
+ // write, so a template path is never refused as unsafe; a base file already there is adopted only when its bytes are exactly
477
+ // the set's own, and is unowned-existing otherwise (RFC §12.2).
478
+ pathAllowList.push(...TEMPLATE_ALLOW_LIST);
479
+ for (const { id, files: templateFiles } of setupTemplates) {
480
+ items.push({ id, act: TEMPLATE_ITEMS.find((template) => template.id === id)!.act });
481
+ for (const file of templateFiles) writeWhole(file.path, file.bytes, id);
482
+ }
483
+ } else {
484
+ // The setup templates in an apply set are no-ops: never written anew, only kept where the trusted ledger records them all.
485
+ // A template act the ledger records none of gets no item.
486
+ for (const { act, id } of TEMPLATE_ITEMS) {
487
+ const rows = TEMPLATE_PATHS[act].map((path) => ({ path, row: fileRowAt(path) }));
488
+ const recorded = rows.filter((entry) => entry.row !== null).length;
489
+ if (recorded === 0) continue;
490
+ // A ledger that records some of an act's files and not the others cannot be kept as that act, and nothing here writes the rest.
491
+ if (recorded < rows.length) return { skip: { verdict: "indeterminate", reason: "template-rows-partial" } };
492
+ items.push({ id, act });
493
+ for (const { path, row } of rows) {
494
+ const outcome = reconcile(path, row!);
495
+ if (outcome.write) files.push({ path, mode: "100644", before: row, after: row, item: id });
496
+ else refused.push({ path, reason: outcome.reason, item: id });
497
+ }
498
+ }
499
+ if (items.some((item) => TEMPLATE_ITEMS.some((template) => template.id === item.id))) pathAllowList.push(...TEMPLATE_ALLOW_LIST);
500
+ }
501
+
502
+ const invariants: PackageInvariant[] = [];
503
+ for (const act of acts) {
504
+ if (RESERVED_ITEM_IDS.has(act.planItem)) {
505
+ throw new TypeError("a package act's planItem is an item id the change set reserves (brief, agents-guide, skills, ledger, root-entries, release-age, caller-workflow, starter-request, ci-template or path-scope-job)");
506
+ }
507
+ // A setup set defers every install until after setup (code rule C10): no item, no key, no invariant.
508
+ if (observation.phase === "setup" && act.act === "install") {
509
+ deferred.push({ planItem: act.planItem, reason: "after-setup" });
510
+ continue;
511
+ }
512
+ const pinned: PinnedPackage = { name: act.name, version: act.version, integrity: act.integrity };
513
+ const entries = observation.manifestEntries.filter((entry) => entry.name === act.name);
514
+ const pointer = dependencyPointer(act.placement, act.name);
515
+ // The owned key follows the same table as a whole file (RFC §12.1): no row and nothing there adds it; no row and a value
516
+ // there is unowned-existing; a row and nothing there is deleted; a row and another value there is client-edited; a row and
517
+ // its own value there is the flow's key, updated when the plan names another version. This table is consulted before the
518
+ // satisfied-in-base shortcut below (fix for issue #1545): a trusted row the base no longer holds is a client edit even
519
+ // when the base happens to already carry the version the plan wants, so it is never waved through as satisfied.
520
+ const row = keyRows.get(pointer);
521
+ const baseAt = entries.find((entry) => entry.placement === act.placement);
522
+ const own = row === undefined ? (baseAt === undefined ? null : "unowned-existing") : baseAt === undefined ? "deleted" : baseAt.value !== row ? "client-edited" : null;
523
+ const satisfiedInBase =
524
+ entries.length === 1 &&
525
+ baseAt !== undefined &&
526
+ baseAt.value === act.version &&
527
+ (row === undefined || (row === baseAt.value && row === act.version)) &&
528
+ observation.lockedPackages.some((locked) => locked.name === act.name && locked.version === act.version && locked.integrity === act.integrity);
529
+ items.push({ id: act.planItem, act: act.act, planItem: act.planItem, package: pinned, placement: act.placement, satisfiedInBase });
530
+ if (satisfiedInBase) continue;
531
+ if (observation.packageManager === "none") {
532
+ refused.push({ file: "package.json", pointer, reason: "manifest-absent", item: act.planItem });
533
+ continue;
534
+ }
535
+ const others = new Set(entries.filter((entry) => entry.placement !== act.placement).map((entry) => dependencyPointer(entry.placement, entry.name)));
536
+ if (others.size > 0) {
537
+ // The package is also at the other placement, which the flow never wrote: nothing is written for it.
538
+ for (const other of others) refused.push({ file: "package.json", pointer: other, reason: "unowned-existing", item: act.planItem });
539
+ if (own !== null) refused.push({ file: "package.json", pointer, reason: own, item: act.planItem });
540
+ continue;
541
+ }
542
+ if (own !== null) {
543
+ refused.push({ file: "package.json", pointer, reason: own, item: act.planItem });
544
+ continue;
545
+ }
546
+ if (row === undefined || act.version !== row) {
547
+ // The request a setup set wrote names this pin; an apply set that changes it would leave the request naming another, and
548
+ // rewriting the request is not something an apply set does.
549
+ if (act.act === "pin-starter" && observation.phase === "apply") return { skip: { verdict: "indeterminate", reason: "starter-request-stale" } };
550
+ keys.push({ file: "package.json", pointer, before: row ?? null, after: act.version, item: act.planItem });
551
+ invariants.push({ item: act.planItem, ...pinned });
552
+ continue;
553
+ }
554
+ // The key holds the desired version and the flow wrote it, yet the lockfile does not resolve that version at the plan's
555
+ // integrity: a supply-chain signal, never repaired (§12.6), so the repository gets no set.
556
+ return { skip: { verdict: "violated", reason: "integrity-mismatch" } };
557
+ }
558
+
559
+ if (invariants.length > 0 && lockfile !== null) {
560
+ pathAllowList.push("package.json", lockfile);
561
+ const sorted = canonicalOrder(invariants, CANONICAL_KEYS.invariant);
562
+ files.push({ path: lockfile, mode: "100644", derived: true, item: sorted[0]!.item, invariants: sorted, before: existingAt(lockfile) ?? null });
563
+ }
564
+
565
+ // pnpm reads the release-age window from the workspace file. A setup set exempts the publishing scope there with the one edit
566
+ // editReleaseAgeExemption() makes (code rule C12); an apply set only carries the item when the trusted ledger records that entry,
567
+ // so that it is the setup set's item over again, with no file of its own (RFC D26).
568
+ if (observation.packageManager === "pnpm") {
569
+ const surface = EXEMPTION_SURFACES[RELEASE_AGE_SURFACE];
570
+ const scopeEntry = `${PACKAGE_SCOPE.scope}/*`;
571
+ const recorded = (ledger?.entries ?? []).some((row) => row.file === surface.path && row.key === surface.key && row.value === scopeEntry);
572
+ if (setupTemplates !== null || recorded) {
573
+ items.push({ id: RELEASE_AGE_ITEM, act: "exempt-release-age", scope: PACKAGE_SCOPE.scope, surface: RELEASE_AGE_SURFACE, path: surface.path });
574
+ pathAllowList.push(surface.path);
575
+ }
576
+ if (setupTemplates !== null) {
577
+ const baseDigest = existingAt(surface.path);
578
+ if (baseDigest === undefined && presentAt(surface.path)) {
579
+ // A directory holds the path: there is no file to edit and none to create.
580
+ refused.push({ path: surface.path, reason: "release-age-surface-unparseable", item: RELEASE_AGE_ITEM });
581
+ } else {
582
+ // prepareSetup() skipped every repository that has the file and not its text, and checkSurfaceTexts() held the text to the digest.
583
+ const text = baseDigest === undefined ? null : observation.pnpmWorkspaceText;
584
+ if (typeof text !== "string" && text !== null) throw new TypeError("pnpmWorkspaceText is absent for a pnpm-workspace.yaml the observation digests");
585
+ const edit = editReleaseAgeExemption({ surface: RELEASE_AGE_SURFACE, text, npmrc: observation.npmrcText ?? null });
586
+ if (edit.kind === "edited") {
587
+ files.push({ path: surface.path, mode: "100644", before: baseDigest ?? null, after: contentDigest(edit.text), item: RELEASE_AGE_ITEM });
588
+ texts[surface.path] = edit.text;
589
+ } else if (edit.kind === "refused") {
590
+ refused.push({ path: surface.path, reason: edit.reason, item: RELEASE_AGE_ITEM });
591
+ }
592
+ }
593
+ }
594
+ }
595
+
596
+ const generation = ledger?.generation ?? 0;
597
+ items.push({ id: "ledger", act: "write-ledger" });
598
+ files.push({
599
+ path: LEDGER_PATH,
600
+ mode: "100644",
601
+ derived: true,
602
+ item: "ledger",
603
+ invariants: [{ ledgerGeneration: generation + 1 }],
604
+ before: existingAt(LEDGER_PATH) ?? null,
605
+ });
606
+
607
+ // A profile that needs no new entry needs no item; one that needs entries added is skipped by the caller when it did not
608
+ // supply the profile text, or edited here when it did. That refusal stays even over a ledger with rootEntries rows: code
609
+ // rule C13 requires the item whenever the observed profile needs one. No declare-root-entry is emitted to carry the
610
+ // ledger's entries rows, and the release-age row is the one entries row an apply set names an item for (an
611
+ // exempt-release-age item, added above when the trusted ledger records it): RENDER carries entries rows forward unchanged.
612
+ const profile = observation.repositoryProfile;
613
+ if (
614
+ profile !== null &&
615
+ profile.rootVocabulary === "checked" &&
616
+ profile.undeclaredRoots.length > 0 &&
617
+ profile.prohibitedRoots.length === 0 &&
618
+ typeof observation.repositoryProfileText === "string"
619
+ ) {
620
+ const sortedRoots = canonicalOrder([...new Set(profile.undeclaredRoots)], CANONICAL_KEYS.name);
621
+ const entries = sortedRoots.map((name) => ({ name, classification: "extension" as const, disposition: "allowed" as const }));
622
+ const text = observation.repositoryProfileText;
623
+ const before = contentDigest(text);
624
+ if (existingAt(profile.path) !== before) throw new TypeError("repositoryProfileText does not match the observed profile file");
625
+ let edited: string;
626
+ try {
627
+ edited = editJsonPointer(
628
+ text,
629
+ entries.map((entry) => ({ pointer: "/rootEntries/-", value: entry })),
630
+ );
631
+ } catch (cause) {
632
+ if (cause instanceof JsonEditUnstableError) return { skip: { verdict: "indeterminate", reason: "json-edit-unstable" } };
633
+ throw cause;
634
+ }
635
+ const pattern = PROFILE_ALLOW_PATTERNS.find((candidate) => matchesPathPattern(profile.path, candidate));
636
+ if (pattern !== undefined && !pathAllowList.some((allowed) => matchesPathPattern(profile.path, allowed))) pathAllowList.push(pattern);
637
+ items.push({ id: ROOT_ENTRIES_ITEM, act: "declare-root-entry", path: profile.path, entries });
638
+ files.push({ path: profile.path, mode: "100644", before, after: contentDigest(edited), item: ROOT_ENTRIES_ITEM });
639
+ texts[profile.path] = edited;
640
+ } else if (profile !== null && (profile.rootVocabulary === "unparseable" || profile.prohibitedRoots.length > 0)) {
641
+ items.push({
642
+ id: ROOT_ENTRIES_ITEM,
643
+ act: "declare-root-entry",
644
+ path: profile.path,
645
+ entries: profile.rootVocabulary === "unparseable" ? [] : profile.undeclaredRoots.map((name) => ({ name, classification: "extension", disposition: "allowed" })),
646
+ });
647
+ refused.push({ path: profile.path, reason: profile.rootVocabulary === "unparseable" ? "root-vocabulary-unknown" : "root-entry-prohibited", item: ROOT_ENTRIES_ITEM });
648
+ }
649
+
650
+ // A ledger row the desired state no longer names would be a removal, which this planner does not compute: it is reported,
651
+ // and RENDER carries the row forward. A files row where an entries row names its file is the compare-and-swap record of a
652
+ // file the flow edited (a release-age surface or the Controller profile), carried with its entries, not a removal.
653
+ let unnamed = false;
654
+ if (ledger !== null) {
655
+ const named = new Set([...files.map((file) => file.path), ...refused.flatMap((refusal) => ("path" in refusal ? [refusal.path] : []))].map((path) => path.toLowerCase()));
656
+ const edited = new Set(ledger.entries.map((row) => row.file.toLowerCase()));
657
+ const pointers = new Set(acts.map((act) => dependencyPointer(act.placement, act.name)));
658
+ const planItems = new Set(acts.map((act) => act.planItem));
659
+ unnamed =
660
+ ledger.files.some((row) => !named.has(row.path.toLowerCase()) && !edited.has(row.path.toLowerCase())) ||
661
+ ledger.keys.some((row) => !pointers.has(row.pointer)) ||
662
+ ledger.packages.some((row) => !planItems.has(row.planItem));
663
+ }
664
+
665
+ const checks: ApplyCheck[] = [];
666
+ const reasons = new Set(refused.map((refusal) => refusal.reason));
667
+ if (reasons.has("unsafe-path")) checks.push({ check: "V6", verdict: "violated", rule: "unsafe-path" });
668
+ if (reasons.has("manifest-absent")) checks.push({ check: "V6", verdict: "indeterminate", rule: "manifest-absent" });
669
+ if (reasons.has("root-vocabulary-unknown")) checks.push({ check: "V6", verdict: "indeterminate", rule: "root-vocabulary-unknown" });
670
+ if (reasons.has("root-entry-prohibited")) checks.push({ check: "V6", verdict: "indeterminate", rule: "root-entry-prohibited" });
671
+ if (reasons.has("skills-root-is-link")) checks.push({ check: "V6", verdict: "indeterminate", rule: "skills-root-is-link" });
672
+ if (reasons.has("release-age-surface-conflict")) checks.push({ check: "V6", verdict: "indeterminate", rule: "release-age-surface-conflict" });
673
+ if (reasons.has("release-age-surface-unparseable")) checks.push({ check: "V6", verdict: "indeterminate", rule: "release-age-surface-unparseable" });
674
+ // V6 also regenerates the lockfile and checks its invariants; that part is not run here, so a set that changes a lockfile is not satisfied.
675
+ if (files.some((file) => "derived" in file && file.path !== LEDGER_PATH)) checks.push({ check: "V6", verdict: "indeterminate", rule: "lockfile-not-run" });
676
+ if (checks.length === 0) checks.push({ check: "V6", verdict: "satisfied" });
677
+ // V8, ledger and ownership (RFC §7): every write compare-and-swaps against the trusted ledger, and a refused path or key
678
+ // holds the repository. Computed independently of V6.
679
+ const ownership: ApplyCheck[] = [];
680
+ for (const rule of ["unowned-existing", "client-edited", "deleted"] as const) {
681
+ if (reasons.has(rule)) ownership.push({ check: "V8", verdict: "indeterminate", rule });
682
+ }
683
+ if (unnamed) ownership.push({ check: "V8", verdict: "indeterminate", rule: "removal-unbuilt" });
684
+ if (ownership.length === 0) ownership.push({ check: "V8", verdict: "satisfied" });
685
+ checks.push(...ownership);
686
+
687
+ return {
688
+ changeSet: {
689
+ schemaVersion: 1,
690
+ kind: "clossys.repository-change-set",
691
+ producer: { name: inputs.producer.name, version: inputs.producer.version },
692
+ planDigest: planDigestValue,
693
+ repository: {
694
+ id: observation.id,
695
+ nodeId: observation.nodeId,
696
+ visibility: observation.visibility,
697
+ defaultBranch: observation.defaultBranch,
698
+ baseCommit: observation.baseCommit,
699
+ },
700
+ ledger: { generation },
701
+ phase: observation.phase,
702
+ engine: { name: inputs.engine.name, version: inputs.engine.version, integrity: inputs.engine.integrity },
703
+ integrator: { name: inputs.integrator.name, version: inputs.integrator.version, integrity: inputs.integrator.integrity },
704
+ observed: {
705
+ packageManager: observation.packageManager,
706
+ lockfile: observation.lockfile,
707
+ releaseAgeSurfaces: canonicalOrder(
708
+ observation.releaseAgeSurfaces.map((surface) => ({ surface: surface.surface, path: surface.path })),
709
+ CANONICAL_KEYS.surface,
710
+ ),
711
+ consumerCi: observation.consumerCi,
712
+ symlinkedSkillRoots: canonicalOrder([...new Set(observation.symlinkedSkillRoots)], CANONICAL_KEYS.root),
713
+ repositoryProfile:
714
+ profile === null
715
+ ? null
716
+ : {
717
+ path: profile.path,
718
+ rootVocabulary: profile.rootVocabulary,
719
+ undeclaredRoots: canonicalOrder([...new Set(profile.undeclaredRoots)], CANONICAL_KEYS.name),
720
+ prohibitedRoots: canonicalOrder([...new Set(profile.prohibitedRoots)], CANONICAL_KEYS.name),
721
+ },
722
+ linkedAgentsPaths: canonicalOrder([...new Set(observation.linkedAgentsPaths)], CANONICAL_KEYS.name),
723
+ },
724
+ // Every array whose order carries no meaning is written in the contract's canonical order (code rule C8).
725
+ items: canonicalOrder(items, CANONICAL_KEYS.item),
726
+ files: canonicalOrder(files, CANONICAL_KEYS.file),
727
+ keys: canonicalOrder(keys, CANONICAL_KEYS.key),
728
+ refused: canonicalOrder(refused, CANONICAL_KEYS.refusal),
729
+ // A setup set defers each install until after setup, and an apply set defers nothing (code rule C10).
730
+ deferred: canonicalOrder(deferred, CANONICAL_KEYS.deferral),
731
+ pathAllowList: canonicalOrder(pathAllowList, CANONICAL_KEYS.pattern),
732
+ ...(Object.keys(texts).length > 0
733
+ ? { texts: canonicalOrder(Object.entries(texts).map(([path, text]) => ({ path, text })), (entry) => [entry.path]) }
734
+ : {}),
735
+ },
736
+ checks,
737
+ };
738
+ }
739
+
740
+ /**
741
+ * Computes one change set per staffed repository and the report-mode bundle
742
+ * holding them, from observations only. Pure: it reads no file, clock,
743
+ * network or process, and the same inputs give the same bytes.
744
+ *
745
+ * - Each repository's items are the brief (its projection of the hub brief,
746
+ * with the public placeholder unless it is private), the skills of the
747
+ * Advisor voice and of its staffed roles (D33: compose-skills roles are
748
+ * `advisor` then the staffed roles in plan order; the brief's staffedHere
749
+ * names the staffed roles only) with their discovery links and manifest,
750
+ * every package act the plan names for it, and the ledger. Every act the
751
+ * plan authorizes is an item, never dropped, and no act the plan does not
752
+ * name is ever added.
753
+ * - Trust first: the base's ledger (`ledger`, null when absent) must pass
754
+ * trustInstalledLedger() against `heldChangeSets`, or the repository is
755
+ * skipped with the rule as its reason (`ledger-unreadable`, `identity`,
756
+ * `renamed`, `ledger-chain` or `ledger-foreign-row`), verdict
757
+ * indeterminate. The set is computed over the trusted ledger's generation
758
+ * (0 without one).
759
+ * - Every whole file goes through reconcileWholeFile(), the compare-and-swap
760
+ * table: add where neither the ledger nor the base has the path; keep or
761
+ * update where the base holds the ledger's `after`; otherwise refuse the
762
+ * path as `unowned-existing`, `client-edited` or `deleted`. Adoption of
763
+ * bytes the base already has happens only in a setup set. A package key
764
+ * follows the same table against the ledger's keys rows; a key that
765
+ * already holds the desired version the flow wrote, while the lockfile
766
+ * does not resolve it at the plan's integrity, skips the repository as
767
+ * `integrity-mismatch`, verdict violated: never repaired.
768
+ * - In an apply set the setup templates are no-ops: a template act whose
769
+ * files the trusted ledger records is an item whose files are kept (or
770
+ * refused as `client-edited` or `deleted`); one it records none of has no
771
+ * item; one it records only some of skips the repository as
772
+ * `template-rows-partial`. A pnpm apply set carries the release-age
773
+ * exemption item, with no file, when the trusted ledger records that
774
+ * entry, so it matches the setup set's item; it adds no other entries.
775
+ * - A setup set holds one item for each of the four setup templates, their
776
+ * bytes only from renderSetupTemplate(), the plan's one Starter pin, and
777
+ * every install the plan names as a deferral (`after-setup`) with no item,
778
+ * key or invariant. A template file the base already has is adopted only
779
+ * when its bytes are the set's own (else `unowned-existing`), and a
780
+ * composed skill only when the skills manifest records its digest. For
781
+ * pnpm it also holds one `release-age` item: the workspace file is created
782
+ * or edited by editReleaseAgeExemption() over the observed text (`before`
783
+ * the observed digest, or null), left alone when the entry is already
784
+ * listed, or refused (`release-age-surface-unparseable`,
785
+ * `release-age-surface-conflict`, with a V6 `indeterminate` check).
786
+ * - Two or more observed files at the same path, compared case-insensitively
787
+ * (or a repeated entry), have no single base digest between them: the path
788
+ * is occupied by other bytes than any one of them, so it is never kept,
789
+ * updated or adopted -- `client-edited` with a trusted row, `unowned-existing`
790
+ * without one. When the lockfile's own path or the ledger's has a case
791
+ * variant, the whole repository is skipped as `case-variant-path`, verdict
792
+ * indeterminate, the same way as `template-rows-partial`.
793
+ * - A trusted ledger row the desired state no longer names is not removed:
794
+ * V8 reports `removal-unbuilt`, and the row is carried forward.
795
+ * - V8 carries one indeterminate check per ownership refusal reason present
796
+ * (`unowned-existing`, `client-edited`, `deleted`) and for
797
+ * `removal-unbuilt`, and is satisfied otherwise; V6 is computed apart from
798
+ * it.
799
+ * - A repository whose Controller profile needs root entries added is
800
+ * edited when `repositoryProfileText` is present, and skipped as
801
+ * `root-entry-edit-unbuilt` when that text is absent. A profile that is
802
+ * unparseable, or that prohibits a root name the set introduces, gets a
803
+ * declare-root-entry item refused as `root-vocabulary-unknown` or
804
+ * `root-entry-prohibited`.
805
+ * - A role's skill under a symbolic link (`.agents`, `.agents/skills` or its
806
+ * own directory) is refused as `skills-root-is-link`, never written.
807
+ * - A package act the default branch already satisfies exactly is kept as an
808
+ * item with `satisfiedInBase: true` and writes nothing.
809
+ * - A setup repository is skipped, `indeterminate`, as
810
+ * `package-manager-unsupported` (neither npm nor pnpm), `starter-pin-absent`
811
+ * (no single pin-starter act), `starter-pin-unsupported` (a pin outside the
812
+ * templates' range), `starter-request-invalid` (a request the renderer
813
+ * refuses) or `release-age-text-absent` (a pnpm workspace file or `.npmrc`
814
+ * is there and its text was not supplied). An apply set whose pin-starter
815
+ * writes a key is skipped as `starter-request-stale`.
816
+ * - A staffed repository with no observation, with a skip reason, whose
817
+ * ledger is not trusted, whose profile needs root entries added and has no
818
+ * text, or skipped for any reason above, `integrity-mismatch`,
819
+ * `template-rows-partial` or `case-variant-path` is left out of the bundle
820
+ * digest.
821
+ *
822
+ * Throws, naming positions and never values, when the release-age text an
823
+ * observation supplies is not the file it digests, when the plan or hub brief
824
+ * does not validate, the plan has no staffing, a staffed role is not a lowercase id
825
+ * token (`role-not-an-id`), a package act's planItem is not its repository id,
826
+ * a colon and its package name (`plan-item-not-derived`), the hub brief has `staffedHere`, an
827
+ * observation repeats or names an unstaffed repository, a staffed role or
828
+ * the Advisor voice has no skill content, or a computed set or the bundle
829
+ * fails its contract.
830
+ */
831
+ export function planApplyBundle(inputs: PlanApplyBundleInputs): PlanApplyBundleResult {
832
+ const planValidation = validateAdvisorPlan(inputs.plan);
833
+ if (!planValidation.valid) throw new TypeError(`the plan does not validate: ${planValidation.reason}`);
834
+ const staffing = inputs.plan.staffing;
835
+ if (staffing === undefined) throw new TypeError("the plan has no staffing, so no repository has a change set");
836
+ const briefValidation = validateEngagementBrief(inputs.hubBrief);
837
+ if (!briefValidation.valid) throw new TypeError(`the hub brief does not validate: ${briefValidation.reason}`);
838
+ if (inputs.hubBrief.staffedHere !== undefined) throw new TypeError("the hub brief must not have staffedHere; each repository's brief is projected from it");
839
+
840
+ // Plan text never reaches a public ledger: a role becomes part of paths, and a planItem is written as it is (code rules C16, L5 and L10).
841
+ staffing.forEach((entry, index) => {
842
+ entry.roles.forEach((role, at) => {
843
+ if (!ID_TOKEN.test(role)) throw new TypeError(`staffing[${index}].roles[${at}] is not a lowercase id token (role-not-an-id)`);
844
+ });
845
+ });
846
+ (inputs.plan.packages ?? []).forEach((act, index) => {
847
+ if (act.planItem !== derivedPlanItem(act.repository, act.name)) {
848
+ throw new TypeError(`packages[${index}].planItem is not the repository id, a colon and the package name (plan-item-not-derived)`);
849
+ }
850
+ });
851
+
852
+ const skillContent = new Map<string, string>();
853
+ inputs.skills.forEach((skill, index) => {
854
+ if (skillContent.has(skill.role)) throw new TypeError(`skills[${index}] repeats a role`);
855
+ skillContent.set(skill.role, skill.content);
856
+ });
857
+ // The hub carries every voice, whatever any one repository staffs (D33): a missing Advisor voice is a hub defect.
858
+ if (!skillContent.has(ADVISOR_VOICE)) throw new TypeError("the Advisor voice has no composed skill content in skills");
859
+
860
+ const staffedIds = new Set(staffing.map((entry) => entry.repository));
861
+ const observations = new Map<string, RepositoryObservation | SkippedRepositoryObservation>();
862
+ inputs.repositories.forEach((entry, index) => {
863
+ if (!staffedIds.has(entry.id)) throw new TypeError(`repositories[${index}] is not a repository the plan staffs (ids must be spelled as in staffing)`);
864
+ if (observations.has(entry.id)) throw new TypeError(`repositories[${index}] repeats a repository`);
865
+ observations.set(entry.id, entry);
866
+ });
867
+
868
+ const digestOfPlan = planDigest(inputs.plan);
869
+ const computed: { id: string; staffingIndex: number; phase: ChangeSetPhase; set: Omit<RepositoryChangeSet, "bundle">; checks: readonly ApplyCheck[] }[] = [];
870
+ const entries: (ApplyBundleRepository | { readonly pending: number })[] = [];
871
+ for (const [staffingIndex, staffingEntry] of staffing.entries()) {
872
+ const observation = observations.get(staffingEntry.repository);
873
+ if (observation === undefined || isSkipped(observation)) {
874
+ entries.push({
875
+ id: staffingEntry.repository,
876
+ verdict: observation?.verdict ?? "indeterminate",
877
+ reason: observation === undefined ? "not-observed" : observation.skipped,
878
+ checks: [],
879
+ });
880
+ continue;
881
+ }
882
+ checkSurfaceTexts(observation);
883
+ // A ledger the hub cannot account for refuses the whole repository, and nothing is inferred from it (RFC §12.2, §12.6).
884
+ const repositoryPackages = (inputs.plan.packages ?? [])
885
+ .filter((act) => act.repository === staffingEntry.repository)
886
+ .map(({ planItem, act, name, version, integrity, placement }) => ({ planItem, act, name, version, integrity, placement }));
887
+ const planPackageActs: PlanPackageActs[] = [{ planDigest: digestOfPlan, packages: repositoryPackages }, ...(inputs.planPackageActs ?? [])];
888
+ const trust = trustInstalledLedger(observation.ledger, { id: observation.id, nodeId: observation.nodeId }, inputs.heldChangeSets, { planPackageActs });
889
+ if (trust.state === "refused") {
890
+ entries.push({ id: staffingEntry.repository, verdict: "indeterminate", reason: trust.rule, checks: [] });
891
+ continue;
892
+ }
893
+ const acts = (inputs.plan.packages ?? []).filter((act) => act.repository === staffingEntry.repository);
894
+ let setupTemplates: readonly SetupTemplate[] | null = null;
895
+ if (observation.phase === "setup") {
896
+ // A setup set holds the setup templates (code rule C11); anything that stops them being rendered or the pin being safe is a skip.
897
+ const prepared = prepareSetup(observation, acts);
898
+ if ("skip" in prepared) {
899
+ entries.push({ id: staffingEntry.repository, verdict: "indeterminate", reason: prepared.skip, checks: [] });
900
+ continue;
901
+ }
902
+ setupTemplates = prepared.templates;
903
+ }
904
+ const profile = observation.repositoryProfile;
905
+ if (
906
+ profile !== null &&
907
+ profile.rootVocabulary === "checked" &&
908
+ profile.undeclaredRoots.length > 0 &&
909
+ profile.prohibitedRoots.length === 0 &&
910
+ (observation.repositoryProfileText === null || observation.repositoryProfileText === undefined)
911
+ ) {
912
+ entries.push({ id: staffingEntry.repository, verdict: "indeterminate", reason: "root-entry-edit-unbuilt", checks: [] });
913
+ continue;
914
+ }
915
+ const result = computeChangeSet(inputs, digestOfPlan, observation, trust.ledger, staffingEntry.roles, acts, skillContent, setupTemplates);
916
+ if ("skip" in result) {
917
+ entries.push({ id: staffingEntry.repository, verdict: result.skip.verdict, reason: result.skip.reason, checks: [] });
918
+ continue;
919
+ }
920
+ const { changeSet, checks } = result;
921
+ const digest = changeSetDigest(changeSet);
922
+ const short = digest.slice("sha256:".length, "sha256:".length + 12);
923
+ const set = { ...changeSet, branch: `clossys/apply-${short}`, pullRequest: { title: `Clossys: apply plan ${short}` }, changeSetDigest: digest };
924
+ entries.push({ pending: computed.length });
925
+ computed.push({ id: staffingEntry.repository, staffingIndex, phase: observation.phase, set, checks });
926
+ }
927
+
928
+ const authorizationMismatch = inputs.authorization !== null && inputs.authorization.planDigest !== digestOfPlan;
929
+ // Package acts need an execution authorization; a plan that has them and no authorization permits none of them (code rule A4).
930
+ const authorizationAbsent = inputs.plan.packages !== undefined && inputs.authorization === null;
931
+ const digestOfBundle = bundleDigest(digestOfPlan, computed.map((entry) => ({ id: entry.id, changeSetDigest: entry.set.changeSetDigest })));
932
+ const changeSets: RepositoryChangeSet[] = computed.map((entry) => ({ ...entry.set, bundle: digestOfBundle }));
933
+ changeSets.forEach((set, index) => {
934
+ const validation = validateRepositoryChangeSet(set);
935
+ if (!validation.valid) throw new Error(`the change set computed for staffed repository ${computed[index]!.staffingIndex} does not validate: ${validation.reason}`);
936
+ });
937
+
938
+ const bundle: ApplyBundle = {
939
+ schemaVersion: 1,
940
+ kind: "clossys.apply-bundle",
941
+ mode: "report",
942
+ plan: { path: "clossys/advisor/plan.json", digest: digestOfPlan, committed: inputs.planCommitted },
943
+ snapshot: inputs.plan.resolution === undefined ? null : { path: "clossys/.state/apply/registry-snapshot.json", digest: inputs.plan.resolution.snapshotDigest },
944
+ engine: { name: inputs.engine.name, version: inputs.engine.version, integrity: inputs.engine.integrity },
945
+ authorization: inputs.authorization === null ? null : { planDigest: inputs.authorization.planDigest, expiresAt: inputs.authorization.expiresAt },
946
+ computedAt: inputs.computedAt,
947
+ repositories: entries.map((entry) => {
948
+ if (!("pending" in entry)) return entry;
949
+ const { id, phase, set, checks: own } = computed[entry.pending]!;
950
+ // Pure checks that need no observation (code rule A4): an authorization issued for another plan permits none of this one,
951
+ // and a plan with package acts and no authorization permits none of them.
952
+ const authority: ApplyCheck[] = [
953
+ ...(authorizationMismatch ? [{ check: "V3", verdict: "violated", rule: AUTHORIZATION_PLAN_MISMATCH } as const] : []),
954
+ ...(authorizationAbsent ? [{ check: "V3", verdict: "violated", rule: AUTHORIZATION_ABSENT } as const] : []),
955
+ ];
956
+ const checks = sortChecks([...own, ...authority]);
957
+ return { id, verdict: worstVerdict(checks.map((check) => check.verdict)), phase, changeSet: set.changeSetDigest, checks };
958
+ }),
959
+ bundleDigest: digestOfBundle,
960
+ };
961
+ const bundleValidation = validateApplyBundle(bundle);
962
+ if (!bundleValidation.valid) throw new Error(`the computed bundle does not validate: ${bundleValidation.reason}`);
963
+ return { bundle, changeSets };
964
+ }