@clossys/launcher 0.3.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (290) hide show
  1. package/README.md +1373 -60
  2. package/contracts/conversation-contract.md +2 -2
  3. package/contracts/product-ci-workflow.yml +74 -0
  4. package/contracts/repository-inventory.json +53 -0
  5. package/dist/admission-fixture.d.ts +168 -0
  6. package/dist/admission-fixture.d.ts.map +1 -0
  7. package/dist/admission-fixture.js +467 -0
  8. package/dist/admission-fixture.js.map +1 -0
  9. package/dist/admission.d.ts +124 -0
  10. package/dist/admission.d.ts.map +1 -0
  11. package/dist/admission.js +804 -0
  12. package/dist/admission.js.map +1 -0
  13. package/dist/agents-guide.d.ts +9 -0
  14. package/dist/agents-guide.d.ts.map +1 -0
  15. package/dist/agents-guide.js +26 -0
  16. package/dist/agents-guide.js.map +1 -0
  17. package/dist/apply-command-options.check.d.ts +12 -0
  18. package/dist/apply-command-options.check.d.ts.map +1 -0
  19. package/dist/apply-command-options.check.js +20 -0
  20. package/dist/apply-command-options.check.js.map +1 -0
  21. package/dist/apply-plan-cli.d.ts +39 -1
  22. package/dist/apply-plan-cli.d.ts.map +1 -1
  23. package/dist/apply-plan-cli.js +432 -15
  24. package/dist/apply-plan-cli.js.map +1 -1
  25. package/dist/apply-plan.d.ts +46 -59
  26. package/dist/apply-plan.d.ts.map +1 -1
  27. package/dist/apply-plan.js +112 -97
  28. package/dist/apply-plan.js.map +1 -1
  29. package/dist/apply-step-fixture.d.ts +87 -0
  30. package/dist/apply-step-fixture.d.ts.map +1 -0
  31. package/dist/apply-step-fixture.js +199 -0
  32. package/dist/apply-step-fixture.js.map +1 -0
  33. package/dist/apply-store.d.ts +93 -0
  34. package/dist/apply-store.d.ts.map +1 -0
  35. package/dist/apply-store.js +625 -0
  36. package/dist/apply-store.js.map +1 -0
  37. package/dist/approval-sheet.d.ts +21 -0
  38. package/dist/approval-sheet.d.ts.map +1 -0
  39. package/dist/approval-sheet.js +163 -0
  40. package/dist/approval-sheet.js.map +1 -0
  41. package/dist/body-command.d.ts +42 -0
  42. package/dist/body-command.d.ts.map +1 -0
  43. package/dist/body-command.js +143 -0
  44. package/dist/body-command.js.map +1 -0
  45. package/dist/change-set-contract.d.ts +403 -0
  46. package/dist/change-set-contract.d.ts.map +1 -0
  47. package/dist/change-set-contract.js +781 -0
  48. package/dist/change-set-contract.js.map +1 -0
  49. package/dist/change-set-digest.d.ts +28 -0
  50. package/dist/change-set-digest.d.ts.map +1 -0
  51. package/dist/change-set-digest.js +65 -0
  52. package/dist/change-set-digest.js.map +1 -0
  53. package/dist/check-cli.d.ts.map +1 -1
  54. package/dist/check-cli.js +14 -3
  55. package/dist/check-cli.js.map +1 -1
  56. package/dist/cli.d.ts +17 -6
  57. package/dist/cli.d.ts.map +1 -1
  58. package/dist/cli.js +84 -23
  59. package/dist/cli.js.map +1 -1
  60. package/dist/core.d.ts +79 -22
  61. package/dist/core.d.ts.map +1 -1
  62. package/dist/core.js +843 -268
  63. package/dist/core.js.map +1 -1
  64. package/dist/dry-materialize.d.ts +63 -0
  65. package/dist/dry-materialize.d.ts.map +1 -0
  66. package/dist/dry-materialize.js +330 -0
  67. package/dist/dry-materialize.js.map +1 -0
  68. package/dist/existing-declaration-adoption.check.d.ts +2 -0
  69. package/dist/existing-declaration-adoption.check.d.ts.map +1 -0
  70. package/dist/existing-declaration-adoption.check.js +10 -0
  71. package/dist/existing-declaration-adoption.check.js.map +1 -0
  72. package/dist/generated/contract-schema.generated.d.ts +97 -0
  73. package/dist/generated/contract-schema.generated.d.ts.map +1 -0
  74. package/dist/generated/contract-schema.generated.js +496 -0
  75. package/dist/generated/contract-schema.generated.js.map +1 -0
  76. package/dist/generated/package-scope.generated.d.ts +6 -0
  77. package/dist/generated/package-scope.generated.d.ts.map +1 -0
  78. package/dist/generated/package-scope.generated.js +10 -0
  79. package/dist/generated/package-scope.generated.js.map +1 -0
  80. package/dist/generated/plan-contracts.generated.d.ts +3 -0
  81. package/dist/generated/plan-contracts.generated.d.ts.map +1 -0
  82. package/dist/generated/plan-contracts.generated.js +3101 -0
  83. package/dist/generated/plan-contracts.generated.js.map +1 -0
  84. package/dist/host.d.ts.map +1 -1
  85. package/dist/host.js +11 -0
  86. package/dist/host.js.map +1 -1
  87. package/dist/identity.d.ts +15 -0
  88. package/dist/identity.d.ts.map +1 -0
  89. package/dist/identity.js +48 -0
  90. package/dist/identity.js.map +1 -0
  91. package/dist/index.d.ts +35 -5
  92. package/dist/index.d.ts.map +1 -1
  93. package/dist/index.js +19 -2
  94. package/dist/index.js.map +1 -1
  95. package/dist/inventory-adoption.d.ts +24 -5
  96. package/dist/inventory-adoption.d.ts.map +1 -1
  97. package/dist/inventory-adoption.js +70 -25
  98. package/dist/inventory-adoption.js.map +1 -1
  99. package/dist/inventory-choice.d.ts +40 -0
  100. package/dist/inventory-choice.d.ts.map +1 -0
  101. package/dist/inventory-choice.js +156 -0
  102. package/dist/inventory-choice.js.map +1 -0
  103. package/dist/inventory-contract.d.ts +89 -0
  104. package/dist/inventory-contract.d.ts.map +1 -0
  105. package/dist/inventory-contract.js +121 -0
  106. package/dist/inventory-contract.js.map +1 -0
  107. package/dist/key-editor.d.ts +30 -0
  108. package/dist/key-editor.d.ts.map +1 -0
  109. package/dist/key-editor.js +445 -0
  110. package/dist/key-editor.js.map +1 -0
  111. package/dist/ledger-contract.d.ts +190 -0
  112. package/dist/ledger-contract.d.ts.map +1 -0
  113. package/dist/ledger-contract.js +555 -0
  114. package/dist/ledger-contract.js.map +1 -0
  115. package/dist/ledger-trust.d.ts +90 -0
  116. package/dist/ledger-trust.d.ts.map +1 -0
  117. package/dist/ledger-trust.js +203 -0
  118. package/dist/ledger-trust.js.map +1 -0
  119. package/dist/lockfile-invariants.d.ts +48 -0
  120. package/dist/lockfile-invariants.d.ts.map +1 -0
  121. package/dist/lockfile-invariants.js +375 -0
  122. package/dist/lockfile-invariants.js.map +1 -0
  123. package/dist/lockfile-readers.d.ts +72 -0
  124. package/dist/lockfile-readers.d.ts.map +1 -0
  125. package/dist/lockfile-readers.js +713 -0
  126. package/dist/lockfile-readers.js.map +1 -0
  127. package/dist/lockfile-regen.d.ts +106 -0
  128. package/dist/lockfile-regen.d.ts.map +1 -0
  129. package/dist/lockfile-regen.js +760 -0
  130. package/dist/lockfile-regen.js.map +1 -0
  131. package/dist/lockfile-tool-env.d.ts +29 -0
  132. package/dist/lockfile-tool-env.d.ts.map +1 -0
  133. package/dist/lockfile-tool-env.js +111 -0
  134. package/dist/lockfile-tool-env.js.map +1 -0
  135. package/dist/materialize.d.ts +113 -0
  136. package/dist/materialize.d.ts.map +1 -0
  137. package/dist/materialize.js +881 -0
  138. package/dist/materialize.js.map +1 -0
  139. package/dist/observe-repository.d.ts +90 -0
  140. package/dist/observe-repository.d.ts.map +1 -0
  141. package/dist/observe-repository.js +1367 -0
  142. package/dist/observe-repository.js.map +1 -0
  143. package/dist/plan-bundle-setup-fixture.d.ts +68 -0
  144. package/dist/plan-bundle-setup-fixture.d.ts.map +1 -0
  145. package/dist/plan-bundle-setup-fixture.js +167 -0
  146. package/dist/plan-bundle-setup-fixture.js.map +1 -0
  147. package/dist/plan-bundle.d.ts +256 -0
  148. package/dist/plan-bundle.d.ts.map +1 -0
  149. package/dist/plan-bundle.js +882 -0
  150. package/dist/plan-bundle.js.map +1 -0
  151. package/dist/plan-command.d.ts +29 -0
  152. package/dist/plan-command.d.ts.map +1 -0
  153. package/dist/plan-command.js +523 -0
  154. package/dist/plan-command.js.map +1 -0
  155. package/dist/plan-contract.d.ts +153 -0
  156. package/dist/plan-contract.d.ts.map +1 -0
  157. package/dist/plan-contract.js +61 -0
  158. package/dist/plan-contract.js.map +1 -0
  159. package/dist/plan-digest.d.ts +25 -0
  160. package/dist/plan-digest.d.ts.map +1 -0
  161. package/dist/plan-digest.js +106 -0
  162. package/dist/plan-digest.js.map +1 -0
  163. package/dist/plan-rules.d.ts +23 -0
  164. package/dist/plan-rules.d.ts.map +1 -0
  165. package/dist/plan-rules.js +177 -0
  166. package/dist/plan-rules.js.map +1 -0
  167. package/dist/planned-bundle.d.ts +20 -0
  168. package/dist/planned-bundle.d.ts.map +1 -0
  169. package/dist/planned-bundle.js +191 -0
  170. package/dist/planned-bundle.js.map +1 -0
  171. package/dist/product-repository.d.ts +4 -0
  172. package/dist/product-repository.d.ts.map +1 -1
  173. package/dist/product-repository.js +9 -1
  174. package/dist/product-repository.js.map +1 -1
  175. package/dist/provenance-gate.d.ts +48 -0
  176. package/dist/provenance-gate.d.ts.map +1 -0
  177. package/dist/provenance-gate.js +324 -0
  178. package/dist/provenance-gate.js.map +1 -0
  179. package/dist/pull-request-body.d.ts +45 -0
  180. package/dist/pull-request-body.d.ts.map +1 -0
  181. package/dist/pull-request-body.js +232 -0
  182. package/dist/pull-request-body.js.map +1 -0
  183. package/dist/registry-snapshot.d.ts +141 -0
  184. package/dist/registry-snapshot.d.ts.map +1 -0
  185. package/dist/registry-snapshot.js +483 -0
  186. package/dist/registry-snapshot.js.map +1 -0
  187. package/dist/release-age-edit.d.ts +52 -0
  188. package/dist/release-age-edit.d.ts.map +1 -0
  189. package/dist/release-age-edit.js +413 -0
  190. package/dist/release-age-edit.js.map +1 -0
  191. package/dist/root-entries.d.ts +36 -0
  192. package/dist/root-entries.d.ts.map +1 -0
  193. package/dist/root-entries.js +80 -0
  194. package/dist/root-entries.js.map +1 -0
  195. package/dist/setup-template-scripts.d.ts +36 -0
  196. package/dist/setup-template-scripts.d.ts.map +1 -0
  197. package/dist/setup-template-scripts.js +568 -0
  198. package/dist/setup-template-scripts.js.map +1 -0
  199. package/dist/setup-templates.d.ts +55 -0
  200. package/dist/setup-templates.d.ts.map +1 -0
  201. package/dist/setup-templates.js +438 -0
  202. package/dist/setup-templates.js.map +1 -0
  203. package/dist/skills.d.ts +34 -1
  204. package/dist/skills.d.ts.map +1 -1
  205. package/dist/skills.js +129 -17
  206. package/dist/skills.js.map +1 -1
  207. package/dist/status.d.ts +63 -0
  208. package/dist/status.d.ts.map +1 -0
  209. package/dist/status.js +539 -0
  210. package/dist/status.js.map +1 -0
  211. package/dist/types.d.ts +151 -13
  212. package/dist/types.d.ts.map +1 -1
  213. package/package.json +4 -4
  214. package/skeleton/README.md +14 -9
  215. package/skeleton/package.json +2 -1
  216. package/skill/SKILL.md +23 -7
  217. package/skill-catalogue/advisor/SKILL.md +59 -6
  218. package/skill-catalogue/architect/SKILL.md +2 -2
  219. package/skill-catalogue/bouncer/SKILL.md +2 -2
  220. package/skill-catalogue/builder/SKILL.md +2 -2
  221. package/skill-catalogue/butler/SKILL.md +2 -2
  222. package/skill-catalogue/controller/SKILL.md +2 -2
  223. package/skill-catalogue/customer/SKILL.md +2 -2
  224. package/skill-catalogue/designer/SKILL.md +4 -2
  225. package/skill-catalogue/giver/SKILL.md +2 -2
  226. package/skill-catalogue/influencer/SKILL.md +2 -2
  227. package/skill-catalogue/inspector/SKILL.md +2 -2
  228. package/skill-catalogue/integrator/SKILL.md +2 -2
  229. package/skill-catalogue/keeper/SKILL.md +2 -2
  230. package/skill-catalogue/launcher/SKILL.md +23 -7
  231. package/skill-catalogue/locksmith/SKILL.md +2 -2
  232. package/skill-catalogue/messenger/SKILL.md +2 -2
  233. package/skill-catalogue/observer/SKILL.md +2 -2
  234. package/skill-catalogue/publisher/SKILL.md +2 -2
  235. package/skill-catalogue/starter/SKILL.md +3 -2
  236. package/skill-catalogue/strategist/SKILL.md +12 -4
  237. package/skill-catalogue/writer/SKILL.md +2 -2
  238. package/src/admission-fixture.ts +585 -0
  239. package/src/admission.ts +819 -0
  240. package/src/agents-guide.ts +29 -0
  241. package/src/apply-command-options.check.ts +27 -0
  242. package/src/apply-plan-cli.ts +454 -14
  243. package/src/apply-plan.ts +112 -124
  244. package/src/apply-step-fixture.ts +236 -0
  245. package/src/apply-store.ts +584 -0
  246. package/src/approval-sheet.ts +170 -0
  247. package/src/body-command.ts +162 -0
  248. package/src/change-set-contract.ts +987 -0
  249. package/src/change-set-digest.ts +70 -0
  250. package/src/check-cli.ts +14 -3
  251. package/src/cli.ts +90 -22
  252. package/src/core.ts +973 -275
  253. package/src/dry-materialize.ts +353 -0
  254. package/src/existing-declaration-adoption.check.ts +12 -0
  255. package/src/generated/contract-schema.generated.ts +520 -0
  256. package/src/generated/package-scope.generated.ts +10 -0
  257. package/src/generated/plan-contracts.generated.ts +3101 -0
  258. package/src/host.ts +10 -0
  259. package/src/identity.ts +51 -0
  260. package/src/index.ts +74 -3
  261. package/src/inventory-adoption.ts +107 -29
  262. package/src/inventory-choice.ts +172 -0
  263. package/src/inventory-contract.ts +166 -0
  264. package/src/key-editor.ts +446 -0
  265. package/src/ledger-contract.ts +660 -0
  266. package/src/ledger-trust.ts +272 -0
  267. package/src/lockfile-invariants.ts +421 -0
  268. package/src/lockfile-readers.ts +749 -0
  269. package/src/lockfile-regen.ts +851 -0
  270. package/src/lockfile-tool-env.ts +131 -0
  271. package/src/materialize.ts +915 -0
  272. package/src/observe-repository.ts +1365 -0
  273. package/src/plan-bundle-setup-fixture.ts +200 -0
  274. package/src/plan-bundle.ts +1014 -0
  275. package/src/plan-command.ts +532 -0
  276. package/src/plan-contract.ts +179 -0
  277. package/src/plan-digest.ts +102 -0
  278. package/src/plan-rules.ts +188 -0
  279. package/src/planned-bundle.ts +211 -0
  280. package/src/product-repository.ts +10 -1
  281. package/src/provenance-gate.ts +352 -0
  282. package/src/pull-request-body.ts +261 -0
  283. package/src/registry-snapshot.ts +534 -0
  284. package/src/release-age-edit.ts +430 -0
  285. package/src/root-entries.ts +81 -0
  286. package/src/setup-template-scripts.ts +580 -0
  287. package/src/setup-templates.ts +479 -0
  288. package/src/skills.ts +161 -18
  289. package/src/status.ts +557 -0
  290. package/src/types.ts +148 -13
package/README.md CHANGED
@@ -48,26 +48,99 @@ listed is no longer composed (its source disappeared), so launcher removes
48
48
  its composed output and host discovery links — and only that. It never
49
49
  touches a skill it did not itself write.
50
50
 
51
+ The recorded digest is how launcher tells whether it still owns a composed
52
+ skill. Before rewriting or retiring one, it compares the file on disk
53
+ (`.agents/skills/clossys-<package>/SKILL.md`, and any real-directory copy
54
+ at a host discovery path) with the digest it recorded when it last wrote
55
+ that file:
56
+
57
+ | On disk | Rewrite (skill still composed) | Retire (skill no longer composed) |
58
+ | --- | --- | --- |
59
+ | Matches the recorded digest | Rewritten | Removed, with its discovery links |
60
+ | Edited since launcher wrote it | Left as is and reported | Left as is, with its discovery links, and reported |
61
+ | Missing | Recreated | Retirement completes (discovery links removed) |
62
+ | No recorded digest (first run, or an older install) | Adopted if it already equals what launcher would write; otherwise left as is and reported | Not touched: launcher only retires a skill its manifest records |
63
+
64
+ A `SKILL.md` that exists but cannot be read (a permissions error, or a
65
+ directory in its place) is treated like an edited one: left as is and
66
+ reported. A retiring skill directory that holds files other than `SKILL.md`
67
+ is also left as is and reported; a macOS `.DS_Store` file is ignored for
68
+ this check. Each skill left as is appears in the health report as a
69
+ `skill preserved` line naming the file or directory that failed the check,
70
+ and in the report JSON under
71
+ `skillComposition.preserved`; it marks the report degraded, and it is
72
+ reported again on every run until resolved. Launcher recreates a missing
73
+ composed skill because `.agents/skills` is launcher-generated output, so
74
+ recreating it loses nothing a client wrote. That is also how to take
75
+ launcher's version of a skill you edited: move your copy aside, delete the
76
+ directory the `skill preserved` line names (usually
77
+ `.agents/skills/clossys-<package>/`), and run launcher again. To keep your
78
+ edit instead, leave the file as it is.
79
+
51
80
  ## Health report and staleness
52
81
 
53
82
  After create, resume, or appoint — and on every resume — the command prints
54
83
  a read-only health report. It scans all four dependency buckets
55
84
  (`dependencies`, `devDependencies`, `optionalDependencies`,
56
- `peerDependencies`) for the Advisor pin and for extra `@clossys/*` names.
57
- When the live registry version is known, each pin is graded against it: a
58
- pin older than live is a `stale pin` finding and marks the report
59
- **degraded**. The report is also degraded when Advisor is missing, dual-pinned,
60
- or present in any bucket other than `devDependencies`, and when apply skipped
61
- one or more inventoried roster targets (missing sibling clone, origin mismatch,
62
- and similar — the same `skill roster skipped` lines in the report). Per-package
85
+ `peerDependencies`) for the two hub engine pins, `@clossys/advisor` and
86
+ `@clossys/integrator` (an `advisor pin` and an `integrator pin` line), and
87
+ for extra `@clossys/*` names.
88
+ When an engine's live registry version is known, each of its pins is graded
89
+ against it: a pin older than live is a `stale pin` finding, named by package,
90
+ and marks the report **degraded**. The report is also degraded when either
91
+ engine is missing, dual-pinned, or present in any bucket other than
92
+ `devDependencies`, when a composed skill in the hub was left as is because a
93
+ client edited it (the `skill preserved` lines above), when the hub's stored
94
+ inventory fails its contract, and when the hub's lockfile does not resolve the
95
+ engine pins yet (an `install needed (engine-pins-changed-install-needed)` line;
96
+ see "Engine pins and the lockfile" below). Per-package
63
97
  skill sources missing from the catalogue are noted but do not by themselves mark
64
- degraded. Exit stays 0 on resume
98
+ degraded. Each inventoried repository other than the hub gets a `sibling` line
99
+ (and an entry in `skillComposition.siblings`) saying what the run found for it:
100
+ a checkout beside the hub, one not cloned yet, another account's repository,
101
+ the Foundry supplier tree, a folder that is not a git checkout, a checkout git
102
+ refuses to read (dubious ownership), or a checkout whose git origin does not
103
+ match. The line names that repository by its position in the stored
104
+ inventory's `repositories` array (`repositories[<i>] in the stored
105
+ inventory`), never by its id (#1179), because the whole health report is also
106
+ JSON-dumped into the apply message's `health:` line. For a checkout beside the
107
+ hub or one not cloned yet, the line says a hub run writes nothing there, and
108
+ that once the repository is staffed in an approved plan, `@clossys-advisor`
109
+ and the voices of the roles staffed there arrive with that plan's setup pull
110
+ request. A sibling line never marks the report
111
+ degraded, and a sibling's working tree, output an earlier release wrote into it,
112
+ or its old pins do not change the hub run's result. Exit stays 0 on resume
65
113
  (the report is advisory); adopt prints the same report and an unparseable
66
114
  pin-versus-live comparison is noted as indeterminate rather than stale.
67
115
  `checkInventoryEntries()` additionally validates hub inventory ids read-only,
68
116
  marking ids whose repository no longer resolves (skipped with a note when
69
117
  `gh` is unavailable).
70
118
 
119
+ ### Engine pins and the lockfile
120
+
121
+ For engine pins, resume and appoint change only the hub's `package.json`, never its lockfile,
122
+ and install nothing. A run that changes an engine pin says exactly what it
123
+ changed (`engine pins changed in package.json: @clossys/advisor 0.2.6 -> 0.5.0;
124
+ @clossys/integrator added at 0.8.2`, and `health.enginePins` in the JSON) and
125
+ gives one next step: run the hub's package manager install (`npm install`,
126
+ `pnpm install`, `yarn install` or `bun install`, by the lockfile present), then
127
+ commit `package.json` together with its lockfile. Until then a frozen install
128
+ (`npm ci`, `pnpm install --frozen-lockfile`, `yarn install --immutable`) refuses
129
+ the hub when a pin's version changed or an engine was added; when a pin only
130
+ moved between dependency buckets, or a range became the exact version already
131
+ locked, whether it refuses depends on the package manager (pnpm's frozen
132
+ install does), so such a change is reported the same way. While a lockfile is present that does not resolve the pins yet, the
133
+ report is degraded with an `engine-pins-changed-install-needed` finding
134
+ (`health.installNeeded`): an npm lockfile (`npm-shrinkwrap.json`, which npm
135
+ prefers when both exist, else `package-lock.json`) is read on every run, and
136
+ each engine pinned to a plain version must resolve to that same version in it,
137
+ compared as versions (a `v0.6.0` pin matches a locked `0.6.0`); any other lockfile is not read, so
138
+ it counts as unresolved for the engines the run changed. Resume and appoint
139
+ only raise a pin: one older than live, or not a plain version, becomes the live version,
140
+ and one newer than live is kept. Resume rewrites `package.json` as
141
+ 2-space-indented JSON with a final LF, only when a pin changes, and never
142
+ changes its `name`.
143
+
71
144
 
72
145
  ## Install
73
146
 
@@ -101,31 +174,41 @@ npm install --save-dev --save-exact @clossys/launcher@0.2.0
101
174
  ## Talking to the team
102
175
 
103
176
  First contact is `npx @clossys/launcher` (empty directory or the checkout you
104
- appoint as the hub). After apply, the launcher composes the same
105
- `@clossys-<package>` voices into the hub and into every inventoried repository
106
- checkout that already sits beside the hub (`.agents/skills/clossys-<package>/`
107
- plus host discovery links). Composition is per checkout, not machine-wide, and
108
- is not a catalogue dump into `package.json`.
177
+ appoint as the hub). After apply, the launcher composes the
178
+ `@clossys-<package>` voices into the hub (`.agents/skills/clossys-<package>/`
179
+ plus host discovery links). A launcher run writes no skills, skills manifest,
180
+ discovery links, `AGENTS.md` or `CLAUDE.md` into an inventoried product
181
+ repository, and changes nothing in its checkout beside the hub
182
+ (`--clone-missing`, below, only clones a missing one). Once a product
183
+ repository is staffed in an approved plan, it receives those files with that
184
+ plan's setup pull request; an inventoried repository that is not staffed does
185
+ not receive them. Composition is per checkout, not machine-wide, and is not a
186
+ catalogue dump into `package.json`.
109
187
 
110
188
  Voices are how you talk in a coding agent; they are not engagement engines. The
111
189
  `@clossys/advisor` npm package is the engine that grades evidence;
112
190
  `@clossys-advisor` in chat is its hiring and compatibility voice. Use
113
- `@clossys-advisor` and `@clossys-<package>` in the hub or in any inventoried
114
- product repository. Each voice can talk even when that npm package is not pinned
115
- in that repo. When composing into a checkout, the launcher reads each skill body
116
- from that checkout's installed `@clossys/<package>/skill/SKILL.md` when present,
117
- then from the packed catalogue or a sibling monorepo source. Launcher health
191
+ `@clossys-advisor` and `@clossys-<package>` in the hub, where the whole team
192
+ is composed. A product repository staffed in an approved plan gets
193
+ `@clossys-advisor` and the voices of the roles staffed there, once that plan's
194
+ setup pull request has merged; a role not staffed there is expected to be
195
+ absent. Each voice can talk even when
196
+ that npm package is not pinned in that repo. When composing into the hub, the
197
+ launcher reads each skill body from the hub's installed
198
+ `@clossys/<package>/skill/SKILL.md` when present, then from the packed catalogue
199
+ or a sibling monorepo source. Launcher health
118
200
  notes missing catalogue sources but apply continues.
119
201
 
120
202
  Run `npx @clossys/launcher` again from the hub for a health report and to
121
- refresh composed voices on sibling inventoried clones. By default it does
203
+ refresh the voices composed in the hub. By default it does
122
204
  not `gh repo clone` missing inventory entries -- that is not how you talk to
123
205
  the team. `launcher --clone-missing` is the one explicit, approved
124
206
  exception (#1179): on resume only, it clones every inventoried repository
125
207
  not yet sitting beside the hub, using `cloneMissingInventoryRepositories()`,
126
208
  and only those -- an id skipped for any other reason (wrong account, the
127
209
  Foundry supplier tree, a mismatched git origin) is left exactly as skipped,
128
- never attempted.
210
+ never attempted. Cloning is not composing: a repository cloned this way
211
+ receives its team only once it is staffed in an approved plan, like any other.
129
212
 
130
213
  ## How to run it
131
214
 
@@ -139,9 +222,9 @@ silent fallback.
139
222
 
140
223
  | Current directory | What happens |
141
224
  | --- | --- |
142
- | Empty | Creates `{owner}/workspace` from the in-package skeleton (package name `@owner/workspace`), or clones that hub if it already exists. |
143
- | Already a hub (generated marker; packed template `skeleton/clossys/.state/workspace.json`) | Resumes. No new repository. `--inventory` here is refused with a pointer to the appointed hub's own `clossys/.state/inventory.json`. A legacy `.clossys/` hub state is migrated automatically; see "Layout" above. |
144
- | Any other GitHub repository you control | Appoints it as the account hub. Keeps existing product files. Refuses when the working tree has uncommitted changes (`git status --porcelain` non-empty) — the refusal names the offending remote host when the origin is not on github.com. Refuses when `CLOSSYS_OWNER` names a different account than the repository's github.com origin owner. Writes the hub marker. Pins live `@clossys/advisor` in `devDependencies`, relocating and upgrading any pin left in another bucket. A dedicated `{owner}/workspace` checkout is named `@owner/workspace`; a product repository keeps its package name. Always reads the public Advisor version (needed to pin live and to grade resume health). Refuses if the generated hub inventory is missing or empty (packed template `skeleton/clossys/.state/inventory.json`; that generated path does not ship) unless `--inventory <path>` supplies a populated document — or, when the on-disk inventory is already populated and `--inventory` is also supplied, merges the two by repository id (on-disk order first, new ids appended, first occurrence of an id wins). Does not rewrite the lockfile or dump the catalogue. Prints a read-only health report. |
225
+ | Empty | Creates `{owner}/workspace` from the in-package skeleton (package name `@owner/workspace`, pinning live `@clossys/advisor` and `@clossys/integrator` exactly in `devDependencies`), or, when that repository already exists, clones it and classifies its hub marker like a local run: a current marker resumes; a legacy `.clossys/` marker alone is migrated to `clossys/.state/`; both markers are refused before Launcher writes into the clone; with no marker, the clone is appointed as the hub (refused when its working tree has uncommitted changes; existing `README.md`, `AGENTS.md` and `CLAUDE.md` are kept, and a skeleton `package.json` is written only when the clone has none) and the apply message says "appointed". That appoint needs no inventory, but refuses when the clone carries an inventory that fails its contract or a `package.json` that is not a JSON object; every such refusal leaves the clone unchanged. |
226
+ | Already a hub (generated marker; packed template `skeleton/clossys/.state/workspace.json`) | Resumes. No new repository. `--repositories` writes the repositories the founder chose again into `clossys/.state/inventory.json` before skills are composed (see "Choosing the hub's repositories" below). `--inventory` here is refused, and the refusal points at choosing the repositories again on Advisor's repository card and running `launcher --repositories`, instead of at hand-editing the file. A legacy `.clossys/` hub state is migrated automatically; see "Layout" above. For each hub engine whose live version the registry returned, pins that version exactly in `devDependencies` of the hub's existing `package.json`: a frozen older pin is bumped, a pin newer than live is kept, a missing Integrator pin is added, and a pin in another bucket is moved; other `@clossys/*` entries are left as they are, and the file is rewritten only when a pin changes. The report then names each change and the install to run next (see "Engine pins and the lockfile" above). |
227
+ | Any other GitHub repository you control | Appoints it as the account hub. Keeps existing product files. Refuses when the working tree has uncommitted changes (`git status --porcelain` non-empty) — the refusal names the offending remote host when the origin is not on github.com. Refuses when `CLOSSYS_OWNER` names a different account than the repository's github.com origin owner. Writes the hub marker. Pins live `@clossys/advisor` and `@clossys/integrator`, each exactly, in `devDependencies`, relocating any pin left in another bucket, raising an older one (a pin newer than live is kept), and leaving other `@clossys/*` entries as they are; the report names each change and the install to run next. An existing `package.json` `name` is kept; a `{owner}/workspace` checkout whose manifest has no name is named `@owner/workspace`. Refuses, before writing anything, an existing `package.json` that is not a JSON object (unparseable, an array, or a primitive). Always reads the public Advisor and Integrator versions (needed to pin live, and to pin and grade health on resume); refuses as indeterminate when the registry returns either one unreadable. Refuses if the generated hub inventory is missing or empty (packed template `skeleton/clossys/.state/inventory.json`; that generated path does not ship), and the refusal points the founder at choosing the hub's repositories on Advisor's repository card and passing them to `--repositories`, which writes the inventory (see "Choosing the hub's repositories" below). `--inventory <path>` still supplies a populated document instead — and, when the on-disk inventory is already populated and `--inventory` is also supplied, merges the two by repository identity (on-disk order first, new repositories appended, the first occurrence of a repository kept, and every kept entry kept whole, its `packages` included). Does not rewrite the lockfile or dump the catalogue. Prints a read-only health report. |
145
228
 
146
229
  It does not have to be a brand-new exclusive repository, and it does not
147
230
  have to already match a Foundry layout. Informal "workspace-looking" trees
@@ -158,10 +241,91 @@ yes in chat is permission for that one step only; it is not a lasting
158
241
  grant and it does not write git unless a file is saved later. The same
159
242
  command resumes later.
160
243
 
244
+ ## Choosing the hub's repositories
245
+
246
+ A founder never writes the inventory by hand (#1179). Advisor's repository
247
+ card (`@clossys/advisor`'s `repositoryChoiceCard()`, or its
248
+ `advisor-repository-card` bin before the hub exists) offers the
249
+ repositories the agent listed from GitHub for the founder's sign-in; the
250
+ founder chooses; and the agent passes the chosen ids to Launcher:
251
+
252
+ ```bash
253
+ launcher --repositories example-owner/example-app,example-owner/example-site
254
+ ```
255
+
256
+ `--repositories` takes one argument of comma-separated repository ids (an
257
+ id never contains a comma). It works when appointing a repository as the
258
+ hub and on an existing hub checkout; in an empty directory it is refused,
259
+ because there is no hub to write into yet. Launcher writes the chosen ids to
260
+ `clossys/.state/inventory.json` (a generated hub path, not shipped in this package)
261
+ -- the one inventory location it reads on
262
+ every later run -- and, on resume, writes it before the health report is
263
+ built, so the same run lists the repositories just chosen as `sibling` lines.
264
+
265
+ - The ids, and the document built from them, are checked against
266
+ `docs/contracts/repository-inventory.json` (in the public repository;
267
+ that exact path does not ship in this package, but this package's build
268
+ packs and ships its own copy of the contract) through the same shared
269
+ contract checker every read of the inventory uses, and the document is
270
+ checked again by that reader before it is written. What Launcher writes is
271
+ what Launcher reads back.
272
+ - A malformed choice -- an empty id, an id that is not a bare name or
273
+ `owner/name`, or two ids naming the same repository -- is refused by
274
+ position (`repositories[1].id ...`), never echoed, and nothing is written.
275
+ - Launcher decides "is this the same repository" one way everywhere: a
276
+ bare id names a repository of the hub's own owner, and letter case is
277
+ ignored, so `app`, `App` and `<owner>/app` are one repository. Owners are
278
+ compared the same way. A stored inventory, or a choice, that lists one
279
+ repository twice this way is refused, naming the two positions; it is
280
+ never merged. The hub itself is recognised by its origin's `owner/name`
281
+ (or, without a github.com origin, the repository its marker records),
282
+ never by its folder path, so an inventory that names the hub in another
283
+ letter case never lists it as its own sibling.
284
+ - An inventory that already lists exactly the chosen repositories, in any
285
+ order or letter case, or with a bare id for the hub's own owner, is left
286
+ as it is.
287
+ - An inventory that lists a different set is never merged into or
288
+ overwritten silently: the run is refused, stating how many repositories
289
+ each side has and which positions would be added and removed --
290
+ `--repositories[<i>]` for an added id's position in the `--repositories`
291
+ argument, `repositories[<i>] in the stored inventory` for a removed id's
292
+ position in the file -- never the ids themselves, because both the stored
293
+ inventory file and `--repositories` are input an agent may relay
294
+ verbatim, and a repository id is exactly the kind of short string a
295
+ hostile inventory entry could shape as prompt-injection text. Run again
296
+ with `--replace-inventory` to approve the replacement; a repository that
297
+ stays keeps its existing entry, `packages` included. An agent acting on
298
+ the refusal looks each reported position up in its own copy of the
299
+ stored inventory file or its own `--repositories` argument to learn which
300
+ repository it names, and tells the founder that name -- never the
301
+ position string itself, and never text read back out of the inventory
302
+ file or the argument without that lookup. That replacement run's own
303
+ success line reports the same removed positions again, distinctly
304
+ labeled `repositories[<i>] in the replaced inventory`: by the time that
305
+ line prints, `clossys/.state/inventory.json` is already the new file, so
306
+ reusing "in the stored inventory" there would point a reader at the
307
+ wrong document. The positions still index into the file as it stood
308
+ before this run -- the same one the refusal step already named -- so an
309
+ agent that already looked a position up there does not need to look it
310
+ up again. The same position-only rule, and the same "in the stored
311
+ inventory" wording, applies to every other message this command prints
312
+ that names a repository from the file currently on disk -- each
313
+ `sibling (...)` line and `launcher --clone-missing`'s output. (Since
314
+ #1511 the `skill roster written` health-report line names only the
315
+ hub's own id, never a stored-inventory position, so it is no longer on
316
+ this list.)
317
+ - An inventory that fails its contract is likewise replaced only with
318
+ `--replace-inventory`.
319
+ - `--repositories` and `--inventory` each supply the whole inventory, so
320
+ they are refused together, and `--replace-inventory` without
321
+ `--repositories` is refused.
322
+
161
323
  ## CLI
162
324
 
163
325
  ```bash
164
326
  launcher
327
+ launcher --repositories example-owner/example-app,example-owner/example-site
328
+ launcher --repositories example-owner/example-app --replace-inventory
165
329
  launcher --inventory path/to/inventory.json
166
330
  launcher --clone-missing
167
331
  launcher --help
@@ -169,6 +333,12 @@ launcher-check --help
169
333
  launcher-check --input observation.json
170
334
  launcher-doctor
171
335
  launcher-apply-plan --plan plan.json --brief brief.json --repo ./product-checkout
336
+ launcher-apply-plan plan
337
+ launcher-apply-plan materialize --repo ./site-checkout
338
+ launcher-apply-plan verify --repo ./site-checkout
339
+ launcher-apply-plan status --repo ./site-checkout
340
+ launcher-apply-plan body --repo "<id>" --task-record 12
341
+ launcher-apply-plan snapshot --request package-request.json
172
342
  ```
173
343
 
174
344
  Exit codes preserve the ternary:
@@ -176,8 +346,10 @@ Exit codes preserve the ternary:
176
346
  | Exit | State | Meaning |
177
347
  | --- | --- | --- |
178
348
  | `0` | `satisfied` | Created, resumed, or appointed the hub. The message includes a read-only health report. |
179
- | `1` | `violated` | Known refusal: not GitHub, not empty, missing appoint inventory, the supplier tree, uncommitted changes in the appoint tree, or a `CLOSSYS_OWNER` that disagrees with the origin owner. |
180
- | `2` | `indeterminate` | Missing `gh`, unreadable registry pin, or an owner that could not be inferred. |
349
+ | `1` | `violated` | Known refusal: not GitHub, not empty, missing appoint inventory, a malformed `--repositories` choice or one that differs from the stored inventory without `--replace-inventory`, the supplier tree, uncommitted changes in the appoint tree, or a `CLOSSYS_OWNER` that disagrees with the origin owner. |
350
+ | `2` | `indeterminate` | Missing `gh`, an Advisor or Integrator registry version that could not be read when creating or appointing, or an owner that could not be inferred. |
351
+
352
+ `launcher-apply-plan` has its own exit codes, described in [Applying an approved plan](#applying-an-approved-plan) and [Taking the registry snapshot](#taking-the-registry-snapshot).
181
353
 
182
354
  `launcher-check` grades a captured observation JSON through `planWorkspace` and does not create a hub. Same ternary: 0 is a create/resume/adopt plan, 1 is a known refusal, 2 could not run or could not decide. Appoint grades as a plan only when the observation already records a populated inventory; `--inventory` is a live CLI flag, not a check-cli input.
183
355
 
@@ -185,41 +357,54 @@ Exit codes preserve the ternary:
185
357
 
186
358
  | Export | Description |
187
359
  | --- | --- |
188
- | `planWorkspace()` | Decides create, resume, or adopt from a cwd observation. Optional `{ inventoryPath }` is the only way to appoint without a populated on-disk inventory. |
189
- | `applyWorkspacePlan()` | Copies the in-package skeleton or hub marker through a host port and returns a `WorkspaceApplyResult` with health. Composes the same skill voices (with the shared conversation contract injected) on the hub and on inventoried sibling checkouts beside it; refreshes stale hub guidance and the generated `clossys/` README on every path, including resume; migrates a legacy `.clossys/` hub state automatically. Optional `{ skillCatalogueRoot, launcherPackageRoot, contractPath, liveLauncherVersion }` selects where skill and contract bodies are read and grades skill-manifest staleness. |
190
- | `observeWorkspace()` | Reads `gh`, git remotes, cwd, inventory classification, hub-state migration status, and the public Advisor version. |
191
- | `readInventoryRepositories()` | Reads repository ids from a `schemaVersion: 1` inventory document. |
360
+ | `planWorkspace()` | Decides create, resume, or adopt from a cwd observation. Optional `PlanWorkspaceOptions`: `{ repositories, replaceInventory }` (`--repositories` / `--replace-inventory`; appoint or resume) or `{ inventoryPath }` (`--inventory`; appoint only) are the ways to appoint without a populated on-disk inventory. A plan carrying `repositories` holds the resulting `ChosenInventory`: the exact document to write, or `unchanged`. An appoint plan that merges `--inventory` into a populated stored inventory carries `mergedInventoryRepositories`, the merged entries, each kept whole, and `mergedInventoryDocument`, the exact merged document those entries render to, which apply writes. |
361
+ | `applyWorkspacePlan()` | Copies the in-package skeleton or hub marker through a host port and returns a `WorkspaceApplyResult` with health. Composes the skill voices (with the shared conversation contract injected) on the hub only, writes nothing into an inventoried checkout beside it, and reports each inventoried repository other than the hub under `skillComposition.siblings`; refreshes stale hub guidance and the generated `clossys/` README on every path, including resume; migrates a legacy `.clossys/` hub state automatically. Optional `{ skillCatalogueRoot, launcherPackageRoot, contractPath, liveLauncherVersion }` selects where skill and contract bodies are read and grades skill-manifest staleness. |
362
+ | `observeWorkspace()` | Reads `gh`, git remotes, cwd, inventory classification, hub-state migration status, and the public Advisor and Integrator versions (`npm view <package> version`). |
363
+ | `readInventoryRepositories()` | Reads repository ids from an inventory file, as bytes, routed through `validateInventoryDocument()` (a missing file reads as no ids; anything present but invalid throws, naming the offending field by position -- never silently accepted or silently emptied). An optional fourth argument, the hub's owner, makes a bare id and `<owner>/<id>` one repository. |
192
364
  | `readLiveLauncherVersion()` | Reads the public `@clossys/launcher` registry version, used only to grade catalogue-sourced skill staleness. |
193
365
  | `launcherPackageRootFromModule()` | Resolves this package's root from `import.meta.url` so apply can find the packed skill catalogue and contract. |
194
366
  | `parseGitHubRemote()` | Parses a github.com remote and rejects any other host. |
195
367
  | `isHubDocument()` | Type guard for the generated hub marker (packed template: `skeleton/clossys/.state/workspace.json`). |
196
- | `inspectInventory()` | Classifies inventory JSON as missing, empty, or populated. |
197
- | `reportHubHealth()` | Read-only pin, inventory, migration, and skills-manifest report. Does not install or uninstall. |
368
+ | `inspectInventory()` | Classifies an inventory document -- pass the file's bytes, and the hub's owner when known -- as missing, empty, populated, or invalid (malformed or schema-mismatched -- never silently folded into empty; see `validateInventoryDocument()`). |
369
+ | `validateInventoryDocument()` | Strictly validates an inventory document -- a file's exact bytes, or text built in memory -- against `docs/contracts/repository-inventory.json` (in the public repository; that exact path does not ship in this package, but this package's build packs and ships its own copy of the contract; `schemaVersion: 1`, a `repositories` array of `{ id, packages? }` entries -- `id` a bare repository name or `owner/name` in the same format Launcher's own sibling/clone resolution requires, case-insensitively unique; `packages`, when present, shaped exactly as `@clossys/integrator`'s `InventoryPackageEntry`, no other key). It is read as strict JSON (a syntax error is reported by position only; a key repeated in any object, bytes that are not valid UTF-8, a leading byte order mark, or a lone surrogate, escaped or raw, is refused) and checked by the same shared contract checker, packed from `@clossys/advisor`, that checks the plan and the brief. With `InventoryReadOptions` `{ hubOwner }`, a bare id and `<hubOwner>/<id>` are also one repository, and a document listing both is refused. Returns `{ valid: true, ids }` or `{ valid: false, reason }`, where `reason` names every field at fault by position and never quotes a value. Every read and write of an inventory document -- `--inventory`, `--repositories`, the on-disk `clossys/.state/inventory.json` (a hub path, not shipped in this package) on every run, and `readInventoryRepositories()` -- passes the file's exact bytes, read with `WorkspaceHost.readBytes()`, never text decoded first, so invalid bytes cannot be silently replaced before the check; an inventory Launcher copies (`--inventory`, or a legacy `.clossys/` inventory it migrates) is written back byte for byte; a document that merely resembles an inventory (for example a governance record whose entries also carry `role`, `visibility`, `status`, `notes`) is refused, never adopted or silently read as though it validated (#1334). |
370
+ | `reportHubHealth()` | Read-only pin, inventory, migration, and skills-manifest report: `advisorPin` and `integratorPin`, each engine graded against its own live version (the live Integrator version is the last, optional argument). Does not install or uninstall. |
198
371
  | `formatHubHealth()` | Human lines plus a `health:` JSON line for the same report. |
199
372
  | `hasAdvisorPin()` | True when a manifest already pins Advisor in any dependency bucket. |
200
373
  | `checkInventoryEntries()` | Read-only inventory id validation through `gh repo view` (batched; skips with a note when `gh` is unavailable). |
201
- | `DEFAULT_REPOSITORY_NAME` | Default new-hub repository name (`workspace`). Used only when creating, never when appointing. |
374
+ | `DEFAULT_REPOSITORY_NAME` | Default hub repository name (`workspace`): the repository an empty-directory run creates, or clones when it already exists. |
202
375
  | `CLOSSYS_DIR_REL` | Relative path of the one visible per-repository Clossys folder (`clossys`). |
203
376
  | `STATE_DIR_REL` | Relative path of the machine-state folder (`clossys/.state`). |
204
377
  | `WORKSPACE_MARKER_REL` | Relative path of the hub marker. |
205
378
  | `WORKSPACE_INVENTORY_REL` | Relative path of the hub inventory. |
206
379
  | `CLOSSYS_README_REL` | Relative path of the generated index README at the root of `clossys/`. |
207
380
  | `LEGACY_STATE_DIR_REL` / `LEGACY_WORKSPACE_MARKER_REL` / `LEGACY_WORKSPACE_INVENTORY_REL` | Pre-#1171 `.clossys/` paths, kept only so resume can detect and migrate them. |
208
- | `CommandResult` / `CwdObservation` / `DependencyBucket` / `HubDocument` / `HubHealthReport` / `HubMigrationState` / `InventoryObservation` / `InventoryValidationEntry` / `InventoryValidationReport` / `PinFinding` / `PinGrade` / `SkillManifestDocument` / `SkillManifestEntry` / `SkillsManifestSummary` / `ApplyWorkspaceOptions` / `WorkspaceApplyResult` / `WorkspaceDecision` / `WorkspaceHost` / `WorkspaceObservation` / `WorkspacePlan` / `WorkspaceRefusal` / `WorkspaceState` | Typed host, observation, plan, health, and outcome contracts. |
209
- | `cloneMissingInventoryRepositories()` | Explicit, approved action (#1179): clones every inventoried repository `resolveSisterCloneTargets` skipped for "not beside the hub", and only those. Returns a `CloneMissingOutcome[]`. |
381
+ | `CommandResult` / `ChosenInventory` / `CwdObservation` / `DependencyBucket` / `EngineInstallFinding` / `EnginePinChange` / `HubDocument` / `HubEnginePin` / `HubHealthReport` / `HubMigrationState` / `InventoryObservation` / `InventoryValidationEntry` / `InventoryValidationReport` / `InventoryReadOptions` / `PinFinding` / `PinGrade` / `PlanWorkspaceOptions` / `SkillManifestDocument` / `SkillManifestEntry` / `SkillsManifestSummary` / `ApplyWorkspaceOptions` / `WorkspaceApplyResult` / `WorkspaceDecision` / `WorkspaceHost` / `WorkspaceObservation` / `WorkspacePlan` / `WorkspaceRefusal` / `WorkspaceState` | Typed host, observation, plan, health, and outcome contracts. A `WorkspaceHost` reads and writes text and, for inventory files, exact bytes (`readBytes()` / `writeBytes()`). |
382
+ | `cloneMissingInventoryRepositories()` | Explicit, approved action (#1179): clones every inventoried repository of the hub's account that is not cloned beside the hub, and only those; a checkout already beside the hub is left out of the outcomes, and every other inventoried repository is reported as `skipped-other-reason`, never attempted. Returns a `CloneMissingOutcome[]`. |
210
383
  | `runDoctorChecks()` | Read-only prerequisite checks in fix-in-this-order sequence: git, `gh`, signed in, Node.js, npm, then the advisory coding-agent step. Returns a `DoctorReport`. |
211
384
  | `renderDoctorReport()` | Renders a `DoctorReport` one step at a time, the way `launcher-doctor` prints it. |
212
385
  | `checkCloudSessionBootstrap()` | Read-only: the three product-repository-layout.json cloud-session-bootstrap checks against a directory. Returns a `CloudBootstrapReport`. |
213
- | `reportInventoryDrift()` | Compares a declared external inventory against the launcher-written one; reports external-only, launcher-only, and agreeing repository ids. Returns an `InventoryDriftReport`. |
386
+ | `reportInventoryDrift()` | Compares a declared external inventory against the launcher-written one; reports external-only, launcher-only, and agreeing repositories as a count plus each one's position, never its id (`externalInventory[<i>]` into the declared external document, `repositories[<j>]` into the hub's own stored inventory) -- both are document content, and this whole report is JSON-dumped into the apply message's `health:` line on every resume of a hub that declares `externalInventory`. Both files are read as bytes by the strict reader. An optional fifth argument, the hub's owner, compares ids as every other Launcher comparison does (a bare id is that owner's; case is ignored). The hub's own inventory is read with `validateInventoryDocument()`: a missing one lists nothing, and one that is present but invalid makes the report `indeterminate`, never a comparison against an empty list. Returns an `InventoryDriftReport`. |
214
387
  | `detectLinkedHosts()` | Read-only: which of `claude-code`, `cursor`, `codex` can currently discover skills in a directory. |
215
388
  | `serializeHostRecord()` / `parseHostRecord()` | Round-trip `clossys/.state/hosts.json` (`HOSTS_REL`). |
216
389
  | `parsePreferences()` | Reads `clossys/preferences.json`'s budget stance; defaults to `"balanced"` on absence or malformed input. |
217
390
  | `readHostModelProfile()` | Reads a packed `model-profiles/<host>.json`; returns `undefined`, never throws, on a missing or malformed file. |
218
391
  | `resolveModelForTier()` | Resolves a tier and budget preference to one model name for a host, reporting `belowFloor` rather than silently substituting a weaker tier's model. |
219
- | `validateAdvisorPlan()` / `validateEngagementBrief()` | Shape validation against the #1175 "Plan file contract" for `clossys/advisor/plan.json` and `clossys/brief.json`. |
220
- | `isPlanApproved()` | True only when a plan's most recent decision (by timestamp) has `chosen === "approved"`. |
221
- | `applyEngagementBrief()` | Writes `clossys/brief.json` into a repository directory once both the plan and the brief validate; refuses and writes nothing otherwise. |
222
- | `CloneMissingOutcome` / `DoctorCheckHost` / `DoctorReport` / `DoctorStepId` / `DoctorStepResult` / `CloudBootstrapCheck` / `CloudBootstrapReport` / `ExternalInventoryDeclaration` / `InventoryDriftReport` / `DiscoveredHost` / `HostRecord` / `BudgetPreference` / `HostModelProfile` / `HostTierMapping` / `ModelResolution` / `PreferencesDocument` / `ReasoningTier` / `SupportedHost` / `AdvisorPlan` / `ApplyBriefResult` / `BlockerKind` / `EngagementBrief` / `EngagementBriefRole` / `PlanBlocker` / `PlanDecision` / `ValidationResult` | Typed contracts for the sections above. |
392
+ | `validateAdvisorPlan()` / `validateEngagementBrief()` | Validation of `clossys/advisor/plan.json` and `clossys/brief.json` (with its `context` snapshot) against the shared plan and brief contracts Advisor also validates against, including the contracts' code rules (R1-R11 for a plan, B1-B2 for a brief). Unknown fields are refused; the reason names every field at fault. |
393
+ | `approvedSubject()` | What an approval binds: the `subjectDigest` of the plan's latest decision (by timestamp) when that decision has `chosen === "approved"`, else `null`. `null` when the approval has no `subjectDigest`, when decisions at the latest time disagree or name different subjects, when any decision time does not parse, or when the plan does not validate. Anything that applies a plan must use this; the one stated exception is the legacy brief-only path (`applyEngagementBrief()` and `launcher-apply-plan`), which predates the binding and uses `isPlanApproved()`. |
394
+ | `isPlanApproved()` | True only when a plan's most recent decision (by timestamp) has `chosen === "approved"`. False when decisions at that latest time disagree, or when any decision time does not parse. It binds no bytes: it ignores `subjectDigest`, so it is also true for an approval that names no change. |
395
+ | `applyEngagementBrief()` | Writes `clossys/brief.json` into a repository directory once the plan validates and is approved and the brief validates; refuses and writes nothing otherwise. Reports the plan's canonical digest. |
396
+ | `planDigest()` / `canonicalJson()` / `canonicalDigest()` / `PLAN_DIGEST_EXCLUDED_FIELDS` | The canonical plan digest: `sha256:` over the RFC 8785 canonical JSON of the plan without `asOf` and `decisions`. Identical to Advisor's for every plan. `canonicalDigest()` is the shared step: `sha256:` over the canonical JSON of any value, which the plan, change-set and bundle digests all use. |
397
+ | `planApplyBundle()` | The pure apply planner: from a validated plan, the hub brief, observations of each staffed repository's default branch (including the exact bytes of its installed-state ledger and its composed-skill manifest), the change sets the hub holds, the composed skill text, the producer version and the hub's Advisor and Integrator pins, computes one change set per staffed repository (a setup set for a repository in the setup phase, an apply set otherwise) and returns a report-mode bundle. Trusts a repository's ledger only through held change sets, or skips the repository with the trust rule as its reason (`ledger-unreadable`, `identity`, `renamed`, `ledger-chain`, `ledger-foreign-row`); computes each owned path and key by compare-and-swap against the trusted ledger (add, keep, update, or a refusal: `unowned-existing`, `client-edited`, `deleted`), reported under V8; composes the Advisor voice with the staffed roles' voices; skips a repository, `indeterminate` and outside the bundle digest, when a setup set cannot be computed safely (`package-manager-unsupported`, `starter-pin-absent`, `starter-pin-unsupported`, `release-age-text-absent`, `starter-request-invalid`), when an apply set would change the Starter pin its request names (`starter-request-stale`), one whose Controller profile needs root entries added when the observation omits the profile text (`root-entry-edit-unbuilt`), one whose owned package the lockfile no longer resolves to the recorded version and integrity (`integrity-mismatch`, violated), one whose ledger holds only some of a setup template's files (`template-rows-partial`), and one whose lockfile or ledger path has a case variant among the observed files (`case-variant-path`). Two or more observed files at the same path, compared case-insensitively, have no single base digest between them, so that path is never kept, updated or adopted. Refuses, by throwing before computing anything, a staffed role that is not a lowercase id token (`role-not-an-id`) and a planItem that is not its repository id, a colon and its package name (`plan-item-not-derived`). Reads no file, network, process or clock; the same inputs give the same bytes. Throws, naming positions and never values, on inputs it cannot plan from. |
398
+ | `AGENTS_GUIDE_PATH` / `AGENTS_GUIDE_TEXT` / `CLOSSYS_SKILL_PATTERNS` / `verifyAgentsGuide()` | The Launcher-owned guide, the `AGENTS.md` file inside `clossys/`: its path, its one constant text, the three `clossys-*` skill path patterns the text carves out, and a check that is true only for exactly those bytes. The text holds no plan text, repository name or client detail, and no marker sits inside it. |
399
+ | `trustInstalledLedger()` / `reconcileWholeFile()` | Whether a repository's ledger bytes may be trusted: exactly canonical and valid (`ledger-unreadable`), for the observed node id (`identity`) and id, compared exactly -- a difference in letter case alone is still refused (`renamed`) -- every generation a held, valid change set whose digest recomputes and that agrees with its history entry (`ledger-chain`), and every row a write of the set it names (`ledger-foreign-row`); a refusal carries the rule only. `reconcileWholeFile()` is the compare-and-swap table for one whole file, with the generation-0 adoption pass allowed only in a setup set; `clossys/.state/skills.json` is adopted through that pass only when its base already holds the exact bytes the set would write, never merely because a skills manifest was read. Pure. Types: `LedgerTrust`, `LedgerTrustRule`, `WholeFileState`, `WholeFileOutcome`. |
400
+ | `projectEngagementBrief()` / `serializeEngagementBrief()` / `PUBLIC_PROBLEM_PLACEHOLDER` | One repository's brief: the hub brief with `staffedHere` set to that repository's roles in plan order, and `problem` replaced by the brief contract's fixed placeholder unless the repository is private, members in the brief contract's order at every depth; and the exact bytes written for it (two-space JSON and a final newline). |
401
+ | `changeSetDigest()` / `changeSetDigestSubject()` / `CHANGE_SET_DIGEST_EXCLUDED_FIELDS` / `DERIVED_FILE_DIGEST_FIELDS` | The change-set digest: `canonicalDigest()` of the change set without `changeSetDigest`, `branch`, `bundle`, `pullRequest`, `inverse`, `tooling` and `texts`, with each derived file reduced to `path`, `mode`, `derived`, `item` and `invariants`. |
402
+ | `bundleDigest()` | The bundle digest an approval binds: `canonicalDigest()` of the plan digest and, sorted by id, the id and change-set digest of each repository that has a change set. Nothing else. |
403
+ | `validateRepositoryChangeSet()` / `validateApplyBundle()` | Validation of a change set and a bundle against the shared change-set and bundle contracts, including their code rules (C1-C16 for a change set: references, allow-list and case-insensitive path rules, the ledger, the digest, only the ledger and lockfile derived, canonical order, each item's writes matching it -- discovery links, the skills manifest, the pointer files and the setup templates included -- a pin-starter in devDependencies and at most once, phase, a complete setup set, the release-age exemption's surface and scope, the root entries a Controller profile needs, no skill written through a symbolic link, every refusal at a path or key its item binds, every whole file changed only as its act's write kind allows (none deletes), and each planItem derived from the repository id and package name; A1-A7 for a bundle: unique ids, the digest, verdicts that are the worst of their checks, the authorization-mismatch and authorization-absent checks, no state or binding in a report bundle, and in a planned bundle a state only where all nine checks passed and a binding exactly where V3 passed). Unknown fields are refused; no reason echoes a value. |
404
+ | `wouldViolateRootEntries()` / `isRootEntryName()` | Whether the root names some paths introduce (each path's first segment) would fail a Controller repository profile's closed root vocabulary: `satisfied` when the profile has no vocabulary Controller checks (schema version 1 or 2, or an empty `rootEntries`) or declares every name allowed or required; `violated`, with the undeclared and the prohibited names, sorted; `indeterminate` (`root-vocabulary-unknown`) for anything Controller could not read as a root vocabulary. Reads only `schemaVersion` and `rootEntries`, by the rules Controller's README states; pure. `isRootEntryName()` is Controller's rule for one direct-child name. |
405
+ | `validateInstalledLedger()` / `readInstalledLedger()` / `ledgerSuccession()` / `serializeInstalledLedger()` / `renderInstalledLedger()` | The installed-state ledger (`clossys/.state/installed.json`): validation against the shared ledger contract and its code rules L1-L10 (history, each generation's approval binding, rows naming history, owned paths and link modes, keys matching packages, no act twice, canonical order, root entries in one Controller profile among fixed names, id-token roles in skill paths, and derived planItems); the contract's succession rules for a pull request's head ledger against its base's, each given as its exact bytes, a `Uint8Array` (both must be exactly canonical; then unchanged, or one next generation keeping the base's history, and an admitted generation installing exactly what the setup deferred and changing nothing else, except that an apply set may add the one guide row, for the `AGENTS.md` file inside `clossys/`, with the guide's digest), reporting whether a next generation was proved `admitted` or only claims an approval (`approval-claimed`); the exact bytes of a valid ledger; a strict read of a ledger from its bytes, also a `Uint8Array` (null unless it is a `Uint8Array`, valid and exactly canonical -- decoded with the same strict, BOM- and invalid-UTF-8-refusing reader the plan and brief contracts use, never a caller's own decode); and the bytes of the next generation a change set writes over the previous ledger, under the contract's RENDER section (each deferred row takes its identity from the plan's package acts, `LedgerPackageIdentity`), refusing a set computed from another generation or for another repository, and an apply set that would adopt a file. A valid ledger is well formed, not trusted: see `trustInstalledLedger()`. |
406
+ | `storeChangeSet()` / `storeApplyBundle()` / `readStoredChangeSet()` / `readStoredApplyBundle()` / `CHANGE_SET_STORE_REL` / `BUNDLE_STORE_REL` | The hub's content-addressed stores under `clossys/.state/apply/change-sets/` and `clossys/.state/apply/bundles/`, one file per digest named by its 64 hex digits: a write validates first and does nothing when the file already holds the same bytes; a change set is append-only and different bytes under its name are refused, while a bundle's digest excludes its authorization, clock and verdicts, so storing a bundle under a digest that already names a file atomically replaces that one file with the newest computation (superseded computations are not recorded); a read is strict, validated, and returns the document only when its recomputed digest matches its name, else null. A digest argument that is not `sha256:` and 64 lowercase hex digits is refused before any path is built. A stored file's recomputed digest proves its integrity, not its provenance: anyone who can write the hub directory can add a set that verifies. Every store directory segment down to `change-sets/` or `bundles/` must be a real directory; a symbolic link anywhere in that chain is refused rather than followed, and a filesystem error other than a missing directory or file is rethrown naming only the operation and its error code, never a path. |
407
+ | `CloneMissingOutcome` / `DoctorCheckHost` / `DoctorReport` / `DoctorStepId` / `DoctorStepResult` / `CloudBootstrapCheck` / `CloudBootstrapReport` / `ExternalInventoryDeclaration` / `InventoryDriftReport` / `DiscoveredHost` / `HostRecord` / `BudgetPreference` / `HostModelProfile` / `HostTierMapping` / `ModelResolution` / `PreferencesDocument` / `ReasoningTier` / `SupportedHost` / `AdvisorPlan` / `ApplyBriefResult` / `BlockerKind` / `EngagementBrief` / `EngagementBriefRole` / `EngagementContext` / `EngagementContextField` / `EngagementContextFieldId` / `GoalDirection` / `PlanBlocker` / `PlanDecision` / `PlanKit` / `PlanPackageAct` / `PlanStaffing` / `ValidationResult` / `PlanApplyBundleInputs` / `PlanApplyBundleResult` / `RepositoryObservation` / `SkippedRepositoryObservation` / `BundleDigestEntry` / `ApplyBundle` / `ApplyBundleRepository` / `ApplyCheck` / `ApplyCheckId` / `ChangeSetDeferral` / `ChangeSetItem` / `ChangeSetPhase` / `ChangeSetRefusal` / `CheckVerdict` / `ContentDigest` / `DependencyPlacement` / `DerivedFileChange` / `FileChange` / `KeyChange` / `LedgerInvariant` / `LockfileName` / `PackageInvariant` / `PackageManagerKind` / `PinnedPackage` / `RefusalReason` / `ReleaseAgeSurfaceKind` / `RepositoryChangeSet` / `RepositoryVisibility` / `WholeFileChange` / `ApprovalBinding` / `DiscoveryRoot` / `ExemptionSurfaceKind` / `WriteRecordSource` / `InstalledLedger` / `LedgerSuccession` / `LedgerViolation` / `RepositoryProfileObservation` / `RootEntryDeclaration` / `RootEntriesVerdict` | Typed contracts for the sections above. |
223
408
 
224
409
  ## Doctor
225
410
 
@@ -247,7 +432,67 @@ session needs: a resolvable `package.json` plus `package-lock.json` pair,
247
432
  an `AGENTS.md` that mentions `clossys/`, and a hub marker at the same
248
433
  relative path as the packed template `skeleton/clossys/.state/workspace.json`.
249
434
  It never runs `npm ci` itself and never mutates anything; it only reports
250
- which of the three is missing.
435
+ which of the three is missing. A launcher run in the hub writes nothing
436
+ into a product repository, so until the repository is staffed in an approved
437
+ plan and that plan's setup pull request merges, an unsatisfied
438
+ `agents-pointer` check is the expected state; its note says so.
439
+
440
+ ### Setup templates
441
+
442
+ `renderSetupTemplate()` and the renderers beside it are pure functions that
443
+ return the exact bytes of the files a setup change writes. Nothing writes those
444
+ bytes yet; a later step plans them into a change set. The renderers behind it
445
+ are exported too: `renderStarterRequest()`, `renderAdoptionDecisionWorkflow()`,
446
+ `renderProductCiWorkflow()`, `renderAdoptionEvidenceWorkflow()`,
447
+ `renderSnapshotCollector()`, `renderPathScopeWorkflow()` and
448
+ `renderPathScopeScript()`, the standalone script the path-scope workflow embeds.
449
+ A `TemplateResult` is either `{ ok: true, files }`, a list of `TemplateFile`
450
+ entries (`path` and `bytes`), or `{ ok: false, refusal }` with a
451
+ `TemplateRefusal`; the package manager is a `SetupPackageManager` and the Starter
452
+ pin a `StarterPinInput`.
453
+
454
+ There are four acts, each with a fixed file list that `renderSetupTemplate()`
455
+ returns in this order:
456
+
457
+ - `add-caller-workflow` takes `{ packageManager }` and returns
458
+ `.github/workflows/clossys-adoption-evidence.yml`,
459
+ `.github/workflows/clossys-adoption-decision.yml` and
460
+ `.github/scripts/clossys-collect-adoption-snapshot.mjs`.
461
+ - `write-starter-request` takes a `StarterRequestInput` and returns
462
+ `.starter/request.json`.
463
+ - `add-ci-template` takes no input and returns `.github/workflows/clossys-ci.yml`.
464
+ - `add-path-scope-job` takes no input and returns
465
+ `.github/workflows/clossys-path-scope.yml`.
466
+
467
+ The request is in the admission phase and names the Starter as both its own
468
+ engine and its target, and it names both evidence paths, the assessment file
469
+ and the target-input file. It carries no advisor and no hub. The Starter pin must be an exact version in `>=0.2.0 <0.4.0`
470
+ (`STARTER_PIN_RANGE`); any other pin is refused as `starter-pin-unsupported`,
471
+ and a package manager other than npm or pnpm, Yarn included, is refused as
472
+ `package-manager-unsupported`. A refusal names a position such as
473
+ `starter.version` and never quotes the value it refused.
474
+
475
+ The decision workflow starts only on `workflow_run` completion of the evidence
476
+ workflow, and its job carries no condition, so it starts for every conclusion.
477
+ It checks out the protected pull request base, runs one fixed frozen install
478
+ (`npm ci --ignore-scripts` or `pnpm install --frozen-lockfile --ignore-scripts`),
479
+ and runs the installed Starter's `admit` command over a sparse checkout of the
480
+ `workflow_run` head that holds only `/clossys/.state/installed.json`. The
481
+ trusted decision job never reads or trusts the snapshot artifact the evidence
482
+ workflow uploads, because a pull request controls that workflow. The collector
483
+ script is still written because the contract's file set for the caller
484
+ workflows names it.
485
+
486
+ The path-scope job applies to pull requests whose head branch starts with
487
+ `clossys/apply-`, and fails when a changed path is outside the paths Clossys may
488
+ own (`OWNED_PATH_PATTERNS`) or, apart from the ledger, `package.json` and the
489
+ lockfiles, is not named by the pull request's own ledger. It runs in the pull
490
+ request's own context, so it catches an agent's mistakes, not a hostile author;
491
+ the admission job runs from the protected base.
492
+
493
+ A change to any of these workflows, or to `.starter/request.json`, is proved
494
+ only by the first pull request after it merges, because the decision runs from
495
+ the base: a one-merge lag.
251
496
 
252
497
  ## Inventory: adopting an existing source
253
498
 
@@ -277,8 +522,8 @@ presence of `.agents/skills` itself, since Codex reads repository skills
277
522
  from that path directly and needs no separate discovery link (verified
278
523
  against developers.openai.com/codex/skills, 2026-09-22). The snapshot is
279
524
  written to `clossys/.state/hosts.json` (`HOSTS_REL`, via
280
- `serializeHostRecord()` / `parseHostRecord()`) for the hub and for every
281
- sibling clone launcher composes skills into -- so a consumer such as
525
+ `serializeHostRecord()` / `parseHostRecord()`) for the hub, the one checkout
526
+ a launcher run composes skills into -- so a consumer such as
282
527
  Advisor's next-action phrasing can name the client's actual tool instead
283
528
  of guessing.
284
529
 
@@ -302,21 +547,1039 @@ tier cannot be met.
302
547
  `launcher-apply-plan --plan <plan.json> --brief <brief.json> --repo <dir>`
303
548
  writes `clossys/brief.json` into a staffed repository once the plan is
304
549
  approved (#1178). `validateAdvisorPlan()` and `validateEngagementBrief()`
305
- check both files against the exact shapes recorded on issue #1175's "Plan
306
- file contract"; `isPlanApproved()` reads a plan's most recent decision (by
307
- timestamp, not array position) and requires it to be `"approved"` --
308
- absence of any decision is never treated as approval.
309
- `applyEngagementBrief()` refuses, and writes nothing, unless both checks
310
- pass, then writes the brief byte-identically -- it never re-authors its
311
- prose. This package does not compute a brief's content (that is
312
- `@clossys/advisor`'s `EngagementBrief`, landing in #1193) and does not
313
- decide whether a plan should be approved (that is Advisor's job); it only
314
- validates the two landed shapes and writes the one file. Multi-repository
315
- orchestration -- branch creation, exact package installs, adding Starter's
316
- caller workflow, and opening one pull request per repository -- is
317
- deferred: the landed contract does not yet specify how a plan's approved
318
- roles map to inventory repository ids or to install/remove/relocate work
319
- items.
550
+ check both files against the shared contracts in this repository's
551
+ `docs/contracts/` -- `advisor-plan.json`, `engagement-brief.json`, and
552
+ `engagement-context.json` for the brief's `context` snapshot (#1475). This
553
+ package's build packs those files, with a copy of the one contract checker
554
+ `@clossys/advisor` uses, so Launcher and Advisor accept exactly the same
555
+ plans and briefs while Launcher keeps no runtime dependency on Advisor.
556
+ Every object is closed: a field the contracts do not declare is refused,
557
+ and a known context value must be one of that field's fixed choice ids,
558
+ because the brief is committed in every staffed repository. A string or
559
+ key containing a lone surrogate is refused, so every plan that validates has
560
+ a digest. Every plan time must be a real calendar date or date-time in
561
+ ISO 8601 form, a date-time with `Z` or a `±hh:mm` offset, checked
562
+ field by field (so `2026-02-30` or `T24:30` is refused). A brief's
563
+ `problem`, `role`, `why` and `metric` must contain a non-whitespace
564
+ character, and an item of `inputsFrom`, `outputsTo`, `sequence` or
565
+ `deliverables` must not be empty. A refusal names each declared field at
566
+ fault and never echoes its value, and never names a key the contracts do
567
+ not declare: such a field is reported at the object that holds it, by its
568
+ 1-based position there (`plan.mandate has a field the contract does not
569
+ declare (key 4 of this object), and unknown fields are refused`), counted
570
+ in the order the file wrote the keys when `launcher-apply-plan` reads it; a
571
+ value a caller passes to a validator directly is counted in JavaScript's
572
+ own key order, which lists array-index keys such as `"7"` first.
573
+ `launcher-apply-plan` reads both files as strict JSON: bytes that are not
574
+ valid UTF-8, a leading byte order mark, or an object that repeats a key at
575
+ any depth exit `2`, with a repeated key reported by its position in its
576
+ object and, below the top level, that object's position, never by name,
577
+ and a syntax error by position only, never quoting the file's text, so the
578
+ value validated is exactly the one a reader of the file sees. A position is
579
+ a 0-based index into the decoded text in UTF-16 code units (JavaScript's
580
+ string index): the byte offset for ASCII text, with a character outside
581
+ the Basic Multilingual Plane counting as two.
582
+ A plan may say which roles work in which repository (`staffing`, by
583
+ repository inventory id), which kits were recommended (`kits`), which exact
584
+ package acts are authorized (`packages`, each one exact version and one
585
+ `sha512-` integrity value) and where those versions were resolved from
586
+ (`resolution`) (#1178). Once the schema passes, the contract's code rules
587
+ run: no repository is staffed twice (ids compare case-insensitively),
588
+ every staffed role is one of the mandate's roles and every mandate role is
589
+ staffed somewhere unless it is a hub-only role, every
590
+ package act names a staffed repository spelled exactly the same, no
591
+ `planItem` repeats, no package appears twice in one repository,
592
+ `resolution` is present exactly when `packages` is, no kit id repeats, no
593
+ role repeats within one staffing entry, no role is named twice in
594
+ `mandate.roles`, a repository has at most one `pin-starter` act, always
595
+ placed in `devDependencies`, no hub-only role is staffed, and every
596
+ `planItem` is exactly its act's `repository`, a colon and its `name`, in the
597
+ same letter case (R12). The hub-only
598
+ roles, Advisor and Integrator, are read from the plan contract's
599
+ `definitions.hubOnlyRoles`, the same data Advisor reads. The rules read only a document's own fields,
600
+ as the schema does, so an inherited one is ignored. A brief's optional `staffedHere`
601
+ must name only the brief's own roles, each once. This package implements
602
+ those rules separately from Advisor, and both are tested against the same
603
+ corpus, `docs/contracts/advisor-plan-rules.fixture.json` (in the public
604
+ repository, not shipped in this package).
605
+ `approvedSubject()` says what an approval binds: the `subjectDigest` of the
606
+ plan's most recent decision (by timestamp, not array position) when it is
607
+ `"approved"`. It validates the plan itself first and returns null for one
608
+ that does not validate. An approval with no `subjectDigest` binds nothing,
609
+ and so does no decision at all, a decision time that does not parse, or a tie at
610
+ the latest instant between decisions that disagree or name different
611
+ subjects. It says what was approved, not that it matches: a caller must
612
+ recompute the digest of the change it holds and compare.
613
+ `isPlanApproved()` reads the same most recent decision and is true when it
614
+ is `"approved"`, with the same rules for ties and unreadable times, but it
615
+ binds no bytes: it ignores `subjectDigest`, so it is also true for an
616
+ approval that names no change. Anything that applies a plan must use
617
+ `approvedSubject()` instead.
618
+ `applyEngagementBrief()` and `launcher-apply-plan` are the brief-only path,
619
+ which predates that binding and is kept as it was, not extended: they
620
+ require `isPlanApproved()` and accept an approval with or without a
621
+ `subjectDigest`, checking no binding.
622
+ `applyEngagementBrief()` refuses, and writes nothing, unless all three
623
+ checks pass and the plan's digest is computed; only then does it write the brief byte-identically -- it never re-authors
624
+ its prose -- and reports `planDigest()` of the plan it applied, which the
625
+ CLI prints as `plan digest sha256:...`. That digest is defined once, in
626
+ `docs/contracts/advisor-plan-digest.md` (in the public repository, not shipped in this package);
627
+ this package and Advisor each
628
+ implement it and are tested against the same fixture corpus. On this
629
+ brief-only path, this package does not compute the brief's content -- it
630
+ writes the brief it is given, which `@clossys/advisor`'s
631
+ `toEngagementBrief()` builds, and applies no per-repository projection --
632
+ and it does not decide whether a plan should be approved (that is
633
+ Advisor's job); it only validates the two shapes and writes the one file.
634
+ The apply planner below is different: it projects each repository's brief
635
+ from the hub brief itself.
636
+
637
+ Launcher's packed skill carries the agent procedure that puts a stored change
638
+ set into a staffed repository, under "Apply an approved plan" (#1762). The
639
+ agent verifies, files the task-record issue, prints the pull request body with
640
+ `launcher-apply-plan body`, commits and pushes only the set's `clossys/apply-`
641
+ branch, opens the pull request, reads `launcher-apply-plan status`, and reports.
642
+ Launcher never pushes, opens a pull request, files an issue or merges, and the
643
+ procedure forbids merging and enabling auto-merge. `check-package-skills` pins
644
+ each of those rules in the packed skill text.
645
+
646
+ ### Release-age exemption
647
+
648
+ `editReleaseAgeExemption()` computes the edit that lists the publishing
649
+ scope's `<scope>/*` entry as exempt from a package manager's release-age
650
+ delay (#1178). It is pure: it does no I/O. It takes a surface
651
+ (`pnpm-workspace` or `yarnrc`), the surface file's text or `null`, and, for
652
+ pnpm, the `.npmrc` text or `null`. It returns `edited` with the exact new
653
+ text, `unchanged` when the scope entry is already listed, or a refusal
654
+ (`ReleaseAgeEdit`, with `ReleaseAgeEditInput` and
655
+ `ReleaseAgeEditRefusalReason`).
656
+
657
+ The entry goes under `minimumReleaseAgeExclude` (pnpm, single-quoted) or
658
+ `npmPreapprovedPackages` (Yarn, double-quoted). A missing file becomes the
659
+ key alone, a top-level block sequence of scalars gets one new entry after
660
+ its last item, and a file without the key gets the key appended. Other
661
+ bytes, including comments and the final newline, are kept. The function
662
+ reads only the shapes it recognises: `release-age-surface-unparseable` covers
663
+ a flow sequence, an anchor, an alias, a tag, a comment inside the list,
664
+ several documents, a tab, a carriage return or byte order mark, a repeated
665
+ key, and a value that is not a block sequence of scalars.
666
+
667
+ For pnpm the `.npmrc` is read under a fixed grammar and refused otherwise.
668
+ Every line must be blank, a comment (first non-space character `#` or `;`),
669
+ or a plain `key=value` assignment, optionally spaced around the `=`, whose key
670
+ is only ASCII letters, digits and `@ : _ . / -`. An `.npmrc` containing any
671
+ line outside those shapes (a quoted or bracketed key, a comment or escape
672
+ inside the key, a tab, a section header, a key with no `=`) is refused as
673
+ `release-age-surface-unparseable`, because npm's ini reader could read such a
674
+ line as the exclusion setting. A plain key that is `userconfig`,
675
+ `globalconfig` or `prefix` (in any case, with `-` and `_` ignored) is refused
676
+ the same way, because it points npm at another config file or prefix
677
+ directory whose own exclusion list the editor cannot read. A plain key that is
678
+ `minimum-release-age-exclude` in any case, with `-` and `_` ignored (so the
679
+ camel-case spelling too), is refused as `release-age-surface-conflict`.
680
+ Refusing is the default: an unrelated `.npmrc` line the grammar does not list
681
+ also refuses the whole file, and the caller resolves the file by hand.
682
+
683
+ `verifyReleaseAgeExemption()` takes the surface, the text before, the text
684
+ after, and, for pnpm, the `.npmrc` text. It reports a `ReleaseAgeVerdict`, `{ verified: true, value }`
685
+ (its input is a `ReleaseAgeVerifyInput`), only when the two texts differ by that one added entry, read again with the
686
+ same rules; `value` is the `<scope>/*` string the installed-state ledger's
687
+ `entries` row holds. It returns `{ verified: false }` for an unchanged file
688
+ (`before` equal to `after`) and for any `.npmrc` that is a conflict or outside
689
+ the grammar above, so a wiring unit must not verify after an `unchanged` or
690
+ `refused` result. It says nothing about whether a given pnpm or Yarn
691
+ version honours the key; that is proved separately with pinned tools.
692
+
693
+ ### Computing each repository's change
694
+
695
+ `planApplyBundle()` computes, for each repository a plan staffs, the change
696
+ set one pull request would make there, and a bundle that holds them (#1178).
697
+ It is pure: it takes the plan, the hub brief (`clossys/advisor/brief.json`),
698
+ what the caller observed on each repository's default branch (including the
699
+ exact bytes of its installed-state ledger and its composed-skill manifest),
700
+ the change sets the hub holds, the composed skill text for each role and for
701
+ Advisor, this package's version and the hub's Advisor and Integrator pins,
702
+ and it reads nothing itself. The shapes are the shared contracts
703
+ `docs/contracts/repository-change-set.json` and `apply-bundle.json`, packed
704
+ into this package, and every set and the bundle are validated against them,
705
+ code rules included, before they are returned. The digests are defined in
706
+ `docs/contracts/apply-change-set-digest.md`, with a corpus computed
707
+ independently of this package (both in the public repository, not shipped
708
+ in this package).
709
+
710
+ - Each set describes the repository's brief, projected from the hub brief with
711
+ `staffedHere` set to its roles and, unless the repository is private, the
712
+ brief contract's fixed placeholder in place of the client's problem. It
713
+ composes the Advisor voice together with each staffed role's voice (D33;
714
+ `staffedHere` still lists the staffed roles only), and holds each of those
715
+ skills, a discovery link to it under
716
+ `.claude/skills` and `.cursor/skills` (none under a root the default
717
+ branch has as a symbolic link), and `clossys/.state/skills.json` listing
718
+ the skills it writes. It carries every package act the plan names for
719
+ that repository, the hub's exact Integrator pin, and names the
720
+ installed-state ledger as a derived file. No act the plan authorizes is
721
+ dropped and no other act is added. An act the default branch already
722
+ satisfies exactly is kept with `satisfiedInBase: true` and writes nothing.
723
+ - Each set also writes the Launcher's guide, an `AGENTS.md` file inside
724
+ `clossys/`, which covers `clossys/` and the `clossys-*` skills (`agents-guide` item, write-record
725
+ source `agents-guide`, bound by code rule C9 to that one path). Its bytes are
726
+ the constant `AGENTS_GUIDE_TEXT`, the same for every repository: it says that
727
+ `clossys/` and the `clossys-*` skills (`.agents/skills/clossys-*`,
728
+ `.claude/skills/clossys-*`, `.cursor/skills/clossys-*`) are Launcher-owned
729
+ and are not edited, renamed or duplicated, and that the repository's own
730
+ skill policy carves that namespace out. Ownership is the ledger row's digest
731
+ of the whole file, like any other whole file, with no marker inside it: an
732
+ existing guide file the ledger does not record is refused as
733
+ `unowned-existing`, and only a setup set adopts one whose bytes are already
734
+ exactly the guide. A repository set up before the guide existed has neither
735
+ a ledger row nor a file there, so an apply set adds the guide. The planner
736
+ adds it only where the trusted ledger has no row for it and nothing, file or
737
+ directory, is at its path in any letter case. Admission accepts that one
738
+ add and no other only where the setup set wrote no guide and the base tree
739
+ at the set's base commit holds nothing at its path in any letter case. The
740
+ ledger's succession rule S3 reads the two ledgers, not the tree: it accepts
741
+ the one new row only where the base ledger has none at that path, with mode
742
+ `100644` and the digest of `AGENTS_GUIDE_TEXT` as its `after`.
743
+ `verify` compares the head's bytes with the set's digest
744
+ and with `verifyAgentsGuide()`; any difference is `diverged`, and `status`
745
+ reports it as `agents-guide-mismatch`, printing only that token and the pull
746
+ request number, never file text. The repository's own root `AGENTS.md` and
747
+ `CLAUDE.md` are not touched. A follow-up will append one fixed pointer line
748
+ to the root `AGENTS.md`, only when it is absent, and never rewrite the
749
+ file.
750
+ - A skill under a symbolic link on the default branch (`.agents`,
751
+ `.agents/skills` or the role's own skill directory) is refused as
752
+ `skills-root-is-link`: the planner never writes through a link.
753
+ - A repository in the `setup` phase gets a setup set, which the change-set
754
+ contract requires to carry exactly one item for each of four setup
755
+ templates (`caller-workflow`, `starter-request`, `ci-template` and
756
+ `path-scope-job`), the plan's one Starter pin, and, for pnpm, one
757
+ release-age exemption. The template bytes come only from
758
+ `renderSetupTemplate()`; the template patterns join `pathAllowList` before
759
+ any template file is written, and the Starter request takes the package
760
+ manager, the repository id and the plan's pin and nothing else. A template
761
+ file the base does not have is created; one it has is adopted only when its
762
+ bytes are exactly the set's own, and is `unowned-existing` otherwise.
763
+ Adoption follows the same compare-and-swap table as any whole file, and is
764
+ the one place the generation-0 adoption pass runs: a composed skill is
765
+ adopted only when the base's skills manifest records the digest of its
766
+ bytes. The plan's `pin-starter` act is the set's Starter pin. Every
767
+ `install` act goes to `deferred` with the reason `after-setup`: it gets no
768
+ item, no key and no lockfile invariant, and is written by the apply set
769
+ that follows.
770
+ - For pnpm, a setup set carries one `exempt-release-age` item, with the fixed
771
+ item id `release-age`, for the pnpm workspace file. Its text comes only from
772
+ `editReleaseAgeExemption()`, over the exact text of `pnpm-workspace.yaml`
773
+ and of `.npmrc` that the observation carries. An edit is a whole-file write
774
+ whose `before` is the digest the base has (or null, when the file is
775
+ created); an entry the file already lists gives the item and no file; a
776
+ file the editor will not read, or one whose `.npmrc` sets the same list,
777
+ gives a path refusal with the editor's reason
778
+ (`release-age-surface-unparseable`, `release-age-surface-conflict`) and a V6
779
+ `indeterminate` check with the same rule. A directory at the file's path is
780
+ refused as unparseable. An apply set carries the same item, with no file,
781
+ when its trusted ledger records that entry, so that it matches the setup
782
+ set item for item. npm has no exemption key, so an npm set has no item.
783
+ - A repository is skipped, `indeterminate` and outside the bundle digest,
784
+ with a reason of its own, wherever a setup set cannot be computed safely:
785
+ `package-manager-unsupported` (neither npm nor pnpm), `starter-pin-absent`
786
+ (the plan names no single Starter pin there), `starter-pin-unsupported`
787
+ (a pin outside the templates' range, `STARTER_PIN_RANGE`),
788
+ `starter-request-invalid` (a request the renderer refuses, such as a
789
+ repository id that is not `owner/name`), and `release-age-text-absent` (a
790
+ pnpm repository whose workspace file or `.npmrc` is there and whose exact
791
+ text the observation does not carry). An apply set whose Starter pin would
792
+ write a key is skipped as `starter-request-stale`, because the request a
793
+ setup set wrote would then name another pin and an apply set does not
794
+ rewrite it. The planner throws if the text an observation carries is not
795
+ the file its `files` digest.
796
+ - The planner reads the repository's installed-state ledger at the base
797
+ and trusts it only through change sets the hub holds (see
798
+ `trustInstalledLedger()` above): every generation must be a held set whose
799
+ digest recomputes and that agrees with its history entry, and every row a
800
+ write of the set it names. A ledger that fails skips the repository,
801
+ `indeterminate`, with the trust rule as its reason (`ledger-unreadable`,
802
+ `identity`, `renamed`, `ledger-chain` or `ledger-foreign-row`), outside the
803
+ bundle digest. No ledger is generation 0. The set's `ledger.generation`
804
+ comes from the trusted ledger.
805
+ - Each file the set writes whole is computed by compare-and-swap against
806
+ the trusted ledger's row at its path: with no row, an absent path is added
807
+ and a present one is refused as `unowned-existing`; with a row, a base that
808
+ still holds the row's bytes is kept (`before` equal to `after`) or updated,
809
+ a base with other bytes is refused as `client-edited`, and an absent one as
810
+ `deleted`, never folded into an update; other paths still proceed. Taking
811
+ over a present file with no row (the generation-0 adoption pass) is
812
+ allowed only in a setup set, never in an apply set. A `package.json` key
813
+ follows the same table against the ledger's key row; a key whose row, base
814
+ value and desired version agree while the lockfile does not resolve that
815
+ version and integrity skips the repository as `integrity-mismatch`,
816
+ `violated`, and is never repaired. The lockfile and the ledger are derived
817
+ files, checked by their invariants, and are not refused this way.
818
+ - In an apply set, each setup template whose files all have ledger rows is
819
+ carried as a no-op item, one keep entry per file (or `client-edited` /
820
+ `deleted`); a template with only some of its files in the ledger skips the
821
+ repository as `template-rows-partial`. A set edits the Controller repository
822
+ profile: when the observation carries
823
+ its text and the edit is stable, a `declare-root-entry` item adds each root
824
+ name the set introduces and the vocabulary lacks, as an allowed extension,
825
+ changing nothing else in the profile (`editJsonPointer`, the same editor
826
+ materialize uses for `package.json` keys). When that text is absent, the
827
+ repository is skipped as `root-entry-edit-unbuilt`. A profile it cannot read
828
+ (`root-vocabulary-unknown`), or one that prohibits a root name the set
829
+ introduces (`root-entry-prohibited`), gets the item refused instead; a
830
+ profile that already declares every name, or checks no root vocabulary,
831
+ gets no item.
832
+ - A trusted row the desired state no longer names (a role no longer
833
+ staffed, a package act the plan no longer names there) is left in place,
834
+ and the set reports V8 `indeterminate` with rule `removal-unbuilt`: removal
835
+ sets are not built.
836
+ - The change-set digest leaves out what is computed from it or from what it
837
+ covers -- the digest itself, the branch, the bundle digest, the pull
838
+ request text and the inverse set -- and `tooling`, which records the
839
+ machine, and `texts`, which holds whole-file bytes for materialize only.
840
+ It also leaves out a derived file's `before` and `after`, for two
841
+ different reasons: the ledger's bytes cite the digest, and a lockfile's
842
+ bytes depend on the package manager's version, so both are checked by
843
+ their invariants, which stay covered. Only those two files may be
844
+ derived. So a moved base, a different Launcher version, a visibility
845
+ change, a staffing change, different package bytes or a different ledger
846
+ generation is a new set, and recomputing any excluded value is not.
847
+ - Every array whose order carries no meaning is written in one canonical
848
+ order, and the contract refuses any other order, so observing the same
849
+ repository twice, in any order, gives the same bytes and the same digest.
850
+ The contract's code rules also tie each kind of item the planner computes
851
+ to exactly what it writes; the acts nothing computes yet are declared but
852
+ not yet tied to their files.
853
+ - The bundle digest covers only the plan digest and each computed
854
+ repository's id and change-set digest, so an approval can bind it and a
855
+ repository can recompute it from digests alone.
856
+ - The planner's bundle `mode` is `report`, and it records no repository
857
+ state and no binding. The bundle contract also defines a `planned` mode,
858
+ where a repository that passed all nine pre-apply checks and is bound by an
859
+ approval is `planned`, with that binding; the `plan` command writes one
860
+ only when the hub's committed plan carries an approval that names a bundle
861
+ the hub holds (see the plan command below). The planner reports its
862
+ own dry-materialization check (V6), which covers the file
863
+ layout only: the part of V6 that regenerates the lockfile and checks its
864
+ invariants is not run by the planner, so a set that changes a lockfile carries V6
865
+ `indeterminate` with rule `lockfile-not-run`, and V6 is `satisfied` only
866
+ for a set with no lockfile change; the `plan` command replaces that entry
867
+ by running the rest of V6, and V9, in a temporary tree (see the plan
868
+ command below). A release-age path refusal adds V6
869
+ `indeterminate` with its reason as the rule. Compare-and-swap outcomes are reported under V8:
870
+ `unowned-existing`, `client-edited`, `deleted` and `removal-unbuilt` each
871
+ give V8 `indeterminate`, and a set with none of them gives V8 `satisfied`. Two V3 checks
872
+ need no observation: when the authorization names a different plan
873
+ digest than the plan's, every computed repository gets a violated V3
874
+ check (`authorization-plan-mismatch`), and when the plan has package acts
875
+ and no authorization is given, every computed repository gets a violated
876
+ V3 check (`authorization-absent`). Each repository's verdict is the worst
877
+ of its checks.
878
+
879
+ What a setup set guarantees: it validates against the contract, every write
880
+ is a compare-and-swap against the base, a file is adopted only by byte proof,
881
+ template bytes come from the renderer alone, the Starter pin is one the
882
+ templates support, and materialization and verification both prove that the
883
+ release-age file is the base's bytes plus exactly the one scope entry. What it
884
+ does not handle: root entries in a setup set (the apply set that follows is
885
+ refused by admission because its items differ), lockfile regeneration and the
886
+ provenance check (V9), which the `plan` command runs on a temporary tree, and
887
+ a later change of the pin.
888
+
889
+ Nothing here writes to a product repository, creates a branch or opens a
890
+ pull request; the only files this part of the package writes are the hub's
891
+ change-set and bundle stores (see the ledger section below). Reading the
892
+ repositories, branch creation, exact package installs,
893
+ adding Starter's caller workflow, and opening one pull request per staffed
894
+ repository -- including the setup pull request that brings a staffed
895
+ repository `@clossys-advisor` and the voices of the roles staffed there --
896
+ are not built yet, so until it ships no Launcher command puts those voices
897
+ into a product repository.
898
+
899
+ ### Observing a repository
900
+
901
+ `observeRepository({ id, clone, hubOwner?, ports })` turns one local clone
902
+ into the `RepositoryObservation` that `planApplyBundle()` takes, or into a
903
+ skipped observation `{ id, skipped, verdict }` with a reason id. The input is
904
+ an `ObserveRepositoryInput`: a bare `id` is qualified by `hubOwner`, and the
905
+ `RepositoryObservationPorts` supply the two values a clone cannot hold
906
+ (`nodeId` and `visibility`) and, optionally, `originId`, which maps an origin
907
+ URL to `owner/name`.
908
+
909
+ ```ts
910
+ const observed = await observeRepository({
911
+ id: "acme/site",
912
+ clone: "/work/site",
913
+ ports: { nodeId, visibility },
914
+ });
915
+ if ("skipped" in observed) console.log(observed.skipped, observed.verdict);
916
+ ```
917
+
918
+ - The rule: only this repository's own object database and refs are read,
919
+ through git plumbing, never the working tree and never an alternate object
920
+ store, `commondir` or submodule repository; anything unusual is refused,
921
+ not interpreted. What the object database holds is trusted to match its
922
+ ids: an observation is the committed head as the clone's object database
923
+ stores it. Every field is read from git objects at the default-branch
924
+ head; a file's digest is that of its bytes read as UTF-8 text, as
925
+ materialization computes it (a symbolic link's digest is the digest of its
926
+ target). Nothing is written to the clone: the remote tip is read with
927
+ `git ls-remote` run outside the clone, and the default branch comes from
928
+ the remote's `HEAD`.
929
+ - The committed tree is listed first, and a submodule is refused
930
+ (`submodule-present`), before any command that reads the working tree runs;
931
+ `git status` never considers a submodule, because git would open the
932
+ submodule's own repository and read its configuration.
933
+ - A clone is refused, not observed, when its directory is missing
934
+ (`clone-missing`, `indeterminate`); when its `origin` is another repository;
935
+ when its tree is dirty or has untracked files not ignored, or a tracked file is marked
936
+ skip-worktree or assume-unchanged; when its local head differs from the
937
+ remote tip; when it reads objects from another store
938
+ (`objects/info/alternates`); when its git directory holds a split index's
939
+ shared file (`sharedindex.*`), which git rewrites on every index read, so
940
+ observing it would write to the clone (`clone-config-unsafe`); or when `.git/config` holds a key outside a
941
+ short fixed list (`violated`). The config is read as data, so a filter,
942
+ hook path, pager, `fsmonitor` or alias entry is refused rather than run.
943
+ - The default origin parser names only an exact `https://github.com/` or
944
+ `ssh` GitHub URL, and only those two transports fetch; an `originId` you
945
+ supply is the only way a local path is fetched. git is run from an absolute
946
+ path found among the absolute, non-empty `PATH` entries, never by a search
947
+ of the clone's own directory.
948
+ - `files` lists whatever the head holds at a path the apply flow may write: a
949
+ file, a symbolic link, or, for a directory, each file under it, so a
950
+ directory where a link belongs reads as occupied. Root entries that differ
951
+ from `clossys`, `.agents`, `.claude` or `.cursor` only by letter case,
952
+ Unicode form or a trailing dot, and two spellings of `.github` or
953
+ `.starter`, are refused (`case-variant-owned-path`); a `consumerCi` workflow
954
+ is a regular file spelled `.github/workflows/`. A regular `.npmrc` is listed
955
+ as well, though the flow never writes it, because a pnpm setup reads it.
956
+ - `pnpmWorkspaceText` and `npmrcText` are the exact UTF-8 text of
957
+ `pnpm-workspace.yaml` and `.npmrc` at the head, or null when the file is
958
+ absent or is not UTF-8; the planner reads a release-age exemption only from
959
+ them, and throws when one is not the file `files` digests.
960
+ - `nodeId` and `visibility` come through the injected ports; a port that
961
+ throws or returns a malformed value is `indeterminate`.
962
+ - `phase` is `apply` only when the base has a valid installed-state ledger,
963
+ every setup-template path is a regular file at the head, and
964
+ `manifestEntries` pins `@clossys/starter` at an exact `0.2.x` version, the
965
+ range the setup templates support (the templates' own predicate; `0.1.9`
966
+ and `0.4.0` read as `setup`), for which its lockfile has a row of that name
967
+ and version, not an alias (an `npm:` alias,
968
+ or another package under its name, is refused as `lockfile-unreadable`; the
969
+ host and integrity are the planner's to check); otherwise it is `setup`.
970
+ - git runs without hooks, `fsmonitor` or a pager, and every tree, blob and
971
+ output read has a size bound. git inside the clone reads no configuration
972
+ but the vetted `.git/config` and fixed `-c` overrides: the system and
973
+ global configuration are switched off, and `GIT_CONFIG_COUNT` and its
974
+ key and value variables, `GIT_CONFIG_PARAMETERS`, `GIT_CONFIG_SYSTEM` and
975
+ `GIT_ATTR_SOURCE` are removed. `git ls-remote`, which runs outside the
976
+ clone, keeps the operator's global and system git config files (credential
977
+ helpers, proxy) and is given `GIT_CONFIG_COUNT`, `GIT_CONFIG_KEY_n` and
978
+ `GIT_CONFIG_VALUE_n`, so an origin that needs credentials supplied through
979
+ them is observed; a fixed `-c` override and `GIT_ALLOW_PROTOCOL` still win
980
+ over them. `GIT_CONFIG_PARAMETERS` (git's internal encoding of `-c`, not an
981
+ interface) and `GIT_CONFIG_SYSTEM` are not forwarded to it, so an origin that
982
+ needs either is skipped as `remote-tip-unreadable`. git's own `GIT_TEST_*`
983
+ switches (`GIT_TEST_SPLIT_INDEX` among them) reach no git command.
984
+
985
+ | Verdict | Skip reasons |
986
+ | --- | --- |
987
+ | `violated` | `invalid-id`, `clone-config-unsafe`, `origin-mismatch`, `not-on-default-branch`, `remote-tip-mismatch`, `working-tree-dirty`, `package-manager-conflict` |
988
+ | `indeterminate` | `clone-missing`, `clone-unreadable`, `id-owner-unknown`, `remote-tip-unreadable`, `node-id-unavailable`, `visibility-unavailable`, `tree-too-large`, `submodule-present`, `manifest-unreadable`, `lockfile-ambiguous`, `package-manager-unknown`, `lockfile-unreadable`, `release-age-surface-invalid`, `agents-link-unreportable`, `observation-too-large`, `case-variant-owned-path`, `ledger-unreadable`, `profile-ambiguous` |
989
+
990
+ What it does not decide: it reports what the committed head holds, not
991
+ whether applying is safe. The checks (configuration, head, index, status)
992
+ and the reads run one after another and are not repeated; the clone is not
993
+ locked. A process that changes the clone between a check and a read is not
994
+ detected, and cannot be detected by checking again, since any later check
995
+ has the same gap, and a process that can write the clone's `.git` can
996
+ already plant a hook or filter that the operator's own git would run. What
997
+ the observation reports is unaffected: every byte comes from an object at the
998
+ remote tip's commit, which a later commit, checkout or edit in the clone does
999
+ not change, so an observation states what that commit held, not that the
1000
+ clone stayed clean after it was checked. Object contents, and links inside
1001
+ `.git/objects`, are trusted to match their ids. Ignored files at owned paths
1002
+ read as absent (`base-mismatch` catches them); a symbolic link at `.github`,
1003
+ `.starter` or `clossys` is unreported here and refused by materialization
1004
+ (`symlink-ancestor`); conversion attributes (`eol`, `ident`, LFS) end in
1005
+ `base-mismatch`; lossy UTF-8 digests can collide, as in materialization; a
1006
+ clone clean only through a custom global `core.excludesFile`, or one that
1007
+ needs `safe.directory`, is refused, which fails closed. Ownership and trust
1008
+ are the planner's judgement, and so is whether the pinned Starter version implements the
1009
+ request beyond that template range.
1010
+ It is not a check of Windows short names or other alias spellings beyond case
1011
+ and Unicode-normalization folding, and which root names a particular set
1012
+ creates is the planner's contract check; the observation reports over
1013
+ `clossys`, `.agents`, `.claude` and `.cursor`, and, for a repository in its
1014
+ setup phase, `.github`, `.starter` and, for a pnpm repository whose workspace
1015
+ file the exemption edit will create, `pnpm-workspace.yaml`.
1016
+
1017
+ ### The installed-state ledger
1018
+
1019
+ Every change set names `clossys/.state/installed.json` as a derived file:
1020
+ the ledger of what the apply flow wrote in that repository, one generation
1021
+ per merged change set (#1178). Its shape, its code rules, the bytes a
1022
+ change set's generation renders to, and the succession rules are the
1023
+ shared contract `docs/contracts/installed-ledger.json`, with a corpus
1024
+ computed independently of this package (both in the public repository, not
1025
+ shipped in this package). This package validates a ledger against it
1026
+ (`validateInstalledLedger()`), compares a pull request's ledger with its
1027
+ base's from their exact bytes (`ledgerSuccession()`), and gives a valid ledger its exact bytes
1028
+ (`serializeInstalledLedger()`), reads one strictly from its bytes, a `Uint8Array`,
1029
+ (`readInstalledLedger()`), renders the next generation a change set writes
1030
+ (`renderInstalledLedger()`), and decides whether a ledger may be trusted
1031
+ against the change sets the hub holds (`trustInstalledLedger()`). Nothing
1032
+ here reads a ledger from a repository: the caller hands its bytes in.
1033
+
1034
+ - Each generation records the change set that wrote it and its binding:
1035
+ `approved`, with the digest of the bundle the founder approved (which
1036
+ may be an earlier run's bundle than the one the set was computed in), or
1037
+ `admitted`, for the apply set that follows an approved setup set under
1038
+ one approval, naming that setup set. An admitted generation must come
1039
+ right after its setup generation, from the same plan and the same
1040
+ approved bundle.
1041
+ - No string in a valid ledger is plan or brief text: a `planItem` is exactly
1042
+ the repository id, a colon and the package name, a role appears only as a
1043
+ lowercase id token in a skill path, and a root entry is one of the fixed
1044
+ names the flow's own paths can introduce.
1045
+ - A valid ledger is a claim, not evidence. `trustInstalledLedger()` trusts
1046
+ it only when every generation is a change set the hub holds, valid and
1047
+ self-verifying, agreeing with its history entry, and every row is a write
1048
+ of the set it names; one row it cannot account for refuses the whole
1049
+ repository, because rendering the next generation would carry that row
1050
+ forward.
1051
+ - `renderInstalledLedger()` gives the bytes of the generation a change set
1052
+ writes over the previous ledger, and refuses a set computed from another
1053
+ generation or for another repository, and an apply set that keeps a file
1054
+ the previous ledger has no row for: an apply generation leaves the
1055
+ ledger's files unchanged.
1056
+ - The hub keeps every change set and bundle it computes under
1057
+ `clossys/.state/apply/change-sets/` and `clossys/.state/apply/bundles/`,
1058
+ one file per digest (`storeChangeSet()`, `storeApplyBundle()`). A stored
1059
+ change set is never replaced; a stored bundle is replaced atomically, and
1060
+ only when the same `bundleDigest` is stored again, which covers the plan
1061
+ digest and the change-set digests only, so only the authorization, verdict,
1062
+ clock and mode fields can differ. A rerun replaces a stored bundle of the
1063
+ same digest, planned or not, with one exception: a report-mode bundle never
1064
+ replaces a stored planned one that verifies (`store-failed`), so an
1065
+ approval-bound record is not downgraded by a run that lost the approval. A
1066
+ stored file that does not verify is replaced. A read returns a file only
1067
+ when its recomputed digest matches its name.
1068
+ - The succession rules compare what two ledgers claim, not the files: an
1069
+ admitted generation must install exactly the packages its setup deferred
1070
+ and change no other row (an apply set may also add the one guide row, for
1071
+ the `AGENTS.md` file inside `clossys/`, with the guide's digest), but whether the pull request's
1072
+ tree matches its ledger is a separate check.
1073
+ - For a reader without the hub, an `approved` head generation is an
1074
+ unauthenticated claim, never an admission or a pass: a pull request
1075
+ could relabel an admitted generation `approved` to escape the admission
1076
+ rules. `ledgerSuccession()` therefore reports such a head as
1077
+ `approval-claimed`, and `admitted` only for an admitted generation that
1078
+ every rule proved. A caller that decides without the hub refuses an
1079
+ `approval-claimed` head on an apply pull request, or treats the pull
1080
+ request as one that needs the client's own review.
1081
+ - Each ledger is read from its bytes and must be exactly the bytes the
1082
+ contract renders for it: a repeated key, a byte order mark or any other
1083
+ spelling is refused (`bytes`), never read as an unchanged ledger.
1084
+
1085
+ ### Planning and the approval sheet
1086
+
1087
+ `launcher-apply-plan plan` runs in the hub and takes no option beyond `--help`.
1088
+ It computes the apply bundle for the plan file in the hub's working tree, reports whether
1089
+ that exact file is the one committed at `HEAD` (the sheet's `Plan committed:` line), and prints the
1090
+ approval sheet a client reads before approving it (RFC section 12.7). It
1091
+ reads the hub only: `clossys/advisor/plan.json` and `brief.json`, the composed
1092
+ skill of each staffed role and of the Advisor voice, the stored change sets,
1093
+ the inventory (a staffed repository the inventory does not list is skipped as
1094
+ `not-in-inventory`), the exact `@clossys/advisor` and `@clossys/integrator`
1095
+ versions in `package.json` with their integrity from the lockfile (a range, or
1096
+ a version the lockfile does not hold, is refused), and the execution
1097
+ authorization committed in `clossys/advisor/assessment-input.json`, read as
1098
+ the blob at `HEAD` and never from the working tree. No blob, or no
1099
+ `engagement.executionAuthorization`, is no authorization; one that is not an
1100
+ object, or lacks a string `planDigest` or `expiresAt`, is refused. Each staffed
1101
+ repository is observed from its clone, a sibling of the hub, by
1102
+ `observeRepository()`, and `planApplyBundle()` does the rest. No option carries
1103
+ an approval or a binding. The result is a `planned` bundle only when the plan
1104
+ is the committed one and the hub's committed plan, read as a git object at an
1105
+ attached `HEAD`, carries an approving decision for this plan digest whose
1106
+ subject is a bundle the hub stores and verifies. Then each repository's V3 is
1107
+ decided by the admission check (`decideSetBinding()`, over the installed-state
1108
+ ledger at the set's base commit), and only a bound repository gets a binding:
1109
+ V3 `violated` (exit 1) or `indeterminate` (exit 2) with its fixed rule token
1110
+ gives none, and a repository whose V3 the bundle already marks violated is
1111
+ not admitted and spawns no readiness run. A repository with a binding and
1112
+ V1 to V9 all satisfied is `planned`; V1, V2, V4, V5 and V7 are recorded
1113
+ satisfied, V9 satisfied only for a set with no derived lockfile (a set that
1114
+ changes one keeps the dry tree's V9, and is not `planned` without it). An apply
1115
+ set names the bundle that will be recorded in the ledger, and admission needs
1116
+ that bundle stored, so the first run after a setup set merges stores it with
1117
+ V3 `indeterminate` (`apply-bundle-unrecorded`) and the next run admits it. The
1118
+ digests and the change sets are the same as in report mode. So is the sheet,
1119
+ apart from its `Mode:` line and, under `Checks not satisfied`, the V3 row of a
1120
+ repository the hub refuses (and the V9 row of a set that changes a lockfile, when
1121
+ the dry tree ran no provenance check), which only planned mode adds.
1122
+
1123
+ It then stores the change sets and the bundle under `clossys/.state/apply/`,
1124
+ the only place it writes, and prints the sheet. Over unchanged inputs and an
1125
+ unchanged clock the sets, their digests and the bundle digest are the same on
1126
+ every run; the V6 and V9 checks stored with them come from the package manager,
1127
+ the registry and the Integrator, and the digest does not cover them. A rerun
1128
+ after the clock, the committed authorization or the result of a check changed
1129
+ keeps the same bundle digest, which covers the plan digest and the change-set
1130
+ digests only, and stores the newest computation under it: the one bundle file
1131
+ is replaced atomically, and the sheet is printed as before. Change sets stay
1132
+ append-only. Earlier computations under a digest are not recorded.
1133
+
1134
+ ```text
1135
+ Approve subjectDigest: sha256:<the bundle digest>
1136
+
1137
+ | Repository | Kind | Item | Change | Digest |
1138
+ | --- | --- | --- | --- | --- |
1139
+ | example-owner/site | pin-starter | example-owner/site:@clossys/starter | @clossys/starter@0.2.0 | 3d2b4f88280d |
1140
+ | example-owner/site | compose-skills | skills | 10 paths | 3d2b4f88280d |
1141
+ ```
1142
+
1143
+ The sheet is ids and digests only: the bundle and plan digests, the mode, the
1144
+ authorization, one row for each item of each change set (`Change` is
1145
+ `name@version` for a package act, else a path count; `Digest` is the first 12
1146
+ hex digits of the set's digest), and then each deferred, refused or skipped
1147
+ entry by id or path and reason token. It carries no brief or plan prose, no
1148
+ stored text, no key value and no file contents. `renderApprovalSheet()` in
1149
+ this package builds it as a pure function of the bundle and its change sets,
1150
+ and refuses, by a fixed token that names no value, a set or bundle that does
1151
+ not validate, sets that are not exactly the bundle's, and any printed value
1152
+ that fails a strict pattern or holds `<`, `>`, a backtick, `|`, a carriage
1153
+ return or a line feed. Every refusal of the command is likewise a fixed token,
1154
+ printed as `launcher-apply-plan plan: <token>; nothing was stored`, except
1155
+ `store-failed` and an unexpected failure, which end without that clause because
1156
+ some of the sets may already be stored.
1157
+
1158
+ Exit `0` when every repository is `satisfied`; `1` when any is `violated`;
1159
+ `2` when an input could not be read, the planner refused, or a repository is
1160
+ `indeterminate`. A bundle that was computed is stored and printed whatever the
1161
+ exit. A Starter pin or a package install changes a lockfile, whose
1162
+ regeneration (V6) the planner does not run, so the command dry-materializes
1163
+ each such repository before it prints the sheet. It reads the clone at the
1164
+ set's base commit through git's object database (`ls-tree` and `cat-file`; no
1165
+ checkout, worktree, index or ref is written), writes that tree and the set's
1166
+ changes into a directory of its own under the operating system's temporary
1167
+ directory, regenerates the lockfile there with the runner `materialize` uses
1168
+ (V6), and, only when that passes, runs the hub's provenance check (V9) on the
1169
+ same tree. The directory is removed before the command returns, whatever
1170
+ happened. A submodule, a link that could reach outside the tree, a link or file
1171
+ whose name a filesystem that folds case or normalization would read as a parent
1172
+ directory of another entry, a `..` or `.git` path segment or a tree over the
1173
+ size cap gives V6 `indeterminate` and launches nothing. A base file whose bytes
1174
+ differ from what the set names as its `before` gives V6 `violated`
1175
+ (`base-mismatch`), and set text whose bytes differ from its digest gives V6
1176
+ `indeterminate` (`change-set-invalid`). A failure of the dry tree gives V6
1177
+ `indeterminate` with rule `dry-tree-failed` for that repository only, and V9 is
1178
+ then `indeterminate` with rule `lockfile-not-regenerated`. Only V6 and V9 change: the sets, their
1179
+ digests and the bundle digest do not. A repository that has a refused path or
1180
+ key, or another check that is not satisfied, is not dry-materialized. No rule
1181
+ carries tool output, a path or an id. The tool version of pnpm and Yarn is not
1182
+ supplied, so a repository that uses one stays `indeterminate`. Supersede is
1183
+ not part of this command.
1184
+
1185
+ ### Materializing and verifying a change set
1186
+
1187
+ `launcher-apply-plan materialize --repo <id>` writes a stored repository
1188
+ change set into that repository's local clone on the branch the set names,
1189
+ including the installed-state ledger. `launcher-apply-plan verify --repo <id>`
1190
+ re-reads the clone and reports whether the working tree still matches the
1191
+ set. Both commands are step 3 in
1192
+ [`docs/rfcs/apply-approved-plan.md`](../../docs/rfcs/apply-approved-plan.md)
1193
+ (section 11); a successful verify corresponds to the `materialized` row in
1194
+ section 4.4 of that RFC.
1195
+
1196
+ ### What authorizes a write
1197
+
1198
+ `materialize` and `verify` decide, from the hub alone, on whose authority a
1199
+ change set is written, and record exactly that in the ledger; no flag, option
1200
+ or default supplies it. The decision reads the plan committed at the hub's
1201
+ current branch head (an uncommitted edit to `clossys/advisor/plan.json` is
1202
+ ignored, and a detached head refuses), the latest approving decision's
1203
+ subject digest, the stored bundle with that digest, and the stored change
1204
+ sets.
1205
+
1206
+ - **Approved.** The set is a member of that bundle, by repository id and
1207
+ change-set digest, and its plan digest equals the plan's. The ledger
1208
+ records `approved` with the bundle's digest.
1209
+ - **Admitted.** An apply set that is not a member is admitted, with no second
1210
+ approval, only when all of the following hold: it has the same plan digest
1211
+ and the approving decision is still the latest; its package acts equal the
1212
+ setup set's by plan item, it defers nothing, has the same `producer`, and
1213
+ every whole-file entry is a no-op except the Launcher guide's add (the
1214
+ `agents-guide` item and its file, before null and after the guide's digest,
1215
+ only where the setup set wrote no guide and the base tree at the set's base
1216
+ commit holds nothing at its path in any letter case); and the base's
1217
+ trusted ledger ends with
1218
+ that setup set, bound `approved` to the same subject, with every byte the
1219
+ setup set wrote present in the base by content, so a squash or rebase merge
1220
+ is admitted. The setup set must itself be a member of the approved bundle,
1221
+ and the ledger the set would write must pass the succession rules as an
1222
+ admitted generation.
1223
+ - **Otherwise** the step reports `indeterminate` with reason
1224
+ `awaiting-approval` and a fixed detail token, and writes nothing.
1225
+
1226
+ The hub's head must be its branch's upstream. `readHubAuthority()` resolves
1227
+ `HEAD` once to a commit id, requires the attached branch's configured upstream
1228
+ to resolve to that same commit (local refs only, nothing is fetched), and reads
1229
+ the plan from that commit id. `HEAD`, its commit and its upstream are read by
1230
+ one `git rev-parse`, and the upstream must be a remote-tracking ref (under
1231
+ `refs/remotes/`). A branch with no upstream, an upstream that is a local
1232
+ branch, or one ahead of or behind it, is `indeterminate` with detail
1233
+ `hub-not-upstream`.
1234
+
1235
+ A set with package acts also needs a current execution authorization: the
1236
+ hub's own `node_modules/.bin/advisor-execution-readiness` (never `npx`) runs
1237
+ against the committed `clossys/advisor/assessment-input.json` at the current
1238
+ instant, and the authorization must name the plan digest, the repository and
1239
+ every package act. The assessment is read at the commit id the plan was read
1240
+ from: a hub whose `HEAD` has since moved, detached, or stopped matching its
1241
+ upstream is `indeterminate` with detail `hub-head-moved`. A set with no package
1242
+ acts skips this step, so its hub head is read once, when the plan is read, and
1243
+ not checked a second time. The authorization's permitted packages must also
1244
+ equal the plan's packages, by name, version and integrity (each compared as one
1245
+ unit, so an empty name cannot stand in for another entry) with each distinct
1246
+ package listed once; an extra, a missing or a repeated
1247
+ entry is `violated` with detail `packages-not-exact`. Readiness's own answer is
1248
+ kept: not current is `violated`; unreadable, absent or failing to run is `indeterminate`. `materialize` checks
1249
+ before its first write, and `verify` checks again, so a withdrawn approval or
1250
+ an expired authorization fails `verify`.
1251
+
1252
+ `readHubAuthority()` reads the committed approval, `planPackagesFor()` gives
1253
+ the plan's package identities for one repository, and `decideSetBinding()`
1254
+ returns the binding or an `AdmissionRefusal` (exit code, reason and a fixed
1255
+ detail token). `HubAuthority` is what `readHubAuthority()` returns: the plan,
1256
+ its digest, the approved subject, and `head`, the commit id the plan was read
1257
+ from. A `ReadinessRunner` replaces the process launch of the readiness executable, for
1258
+ tests.
1259
+
1260
+ This proves that the bytes are those the committed decision names, or that the
1261
+ one-approval rule admits. It does not prove who committed the decision; the
1262
+ hub repository's branch protection governs that.
1263
+
1264
+ ### Rendering the pull request
1265
+
1266
+ `renderPullRequest({ set, binding, taskRecord, supersedes? })` returns the title and body of
1267
+ the pull request for one stored change set, and `bodySha256`, which is
1268
+ `sha256:` and the hex SHA-256 of the body's UTF-8 bytes. It is a pure function
1269
+ of its inputs (`RenderPullRequestInput`: the set, the binding, the task record and the optional superseded numbers): it reads no file, runs no command and opens nothing. The
1270
+ title is exactly `set.pullRequest.title`, which must be `Clossys: apply plan `
1271
+ and the first 12 hex digits of the set's own digest.
1272
+
1273
+ The body is LF only and ends in one LF. Its first line is the marker
1274
+ `<!-- clossys-change-set: sha256:<64 hex> -->`, and no other line is a marker.
1275
+ It then names the repository id, the phase, and the change-set, plan and bundle
1276
+ digests; the binding you pass (`approved` with its subject digest, or `admitted`
1277
+ with its subject digest and setup change set); one line per item, in the set's
1278
+ own order, with `name@version` for `install` and `pin-starter`; each deferred or
1279
+ refused entry by item id and reason code only; and a `## Task record` section
1280
+ that links `#<n>`. With `supersedes`, a list of distinct positive safe integers
1281
+ that are not the task record, it also writes a `## Supersedes` section before
1282
+ the task record, one `- #<n>` line for each number, ascending; without it, or
1283
+ with an empty list, the body is byte for byte what it was. It carries ids, act names, versions and digests only, never
1284
+ brief or plan prose, file contents, key values or paths.
1285
+
1286
+ `readChangeSetMarker(body)` returns the digest only when exactly one line of the
1287
+ body is exactly the marker and the marker appears nowhere else, and `null`
1288
+ otherwise.
1289
+
1290
+ It returns a `PullRequestText`, or a `PullRequestRefusal` whose
1291
+ `PullRequestRefusalReason` is a fixed token that names no id, digest or input
1292
+ text, for a set that fails `validateRepositoryChangeSet` or
1293
+ whose digest does not recompute, a malformed binding, an `admitted` binding on
1294
+ a setup set, a task record that is not a positive safe integer, a `supersedes` list that is
1295
+ not distinct positive safe integers other than the task record
1296
+ (`supersedes-invalid`), and any value it
1297
+ cannot prove safe to write (each must match its own strict pattern and hold no
1298
+ `<`, `>`, backtick, `|`, carriage return or line feed).
1299
+
1300
+ It does not decide the binding: it shows what you pass, so pass the result of
1301
+ `decideSetBinding()`. It does not check that the task-record issue exists, and
1302
+ it cannot stop a pull request's body being edited after it is opened; keeping
1303
+ `bodySha256` and the marker is what lets a later step notice that.
1304
+
1305
+ ### Recording the body
1306
+
1307
+ `launcher-apply-plan body --repo <id> --task-record <n> [--supersedes <n>]...`
1308
+ prints the body of the pull request for the repository's stored change set, and
1309
+ nothing else, then records the `bodySha256` of exactly the bytes it printed as
1310
+ the set's `pullRequest.bodySha256`. Open the pull request from that output.
1311
+
1312
+ ```bash
1313
+ launcher-apply-plan body --repo "<id>" --task-record 12 --supersedes 9
1314
+ ```
1315
+
1316
+ `<id>` is the repository's id, `owner/name`. It is quoted in the examples because
1317
+ an unquoted `<id>` pasted into a shell is read as a redirection; replace the
1318
+ whole quoted word with the id.
1319
+
1320
+ The approval the body shows is what the hub decides at the time of the run, as
1321
+ `materialize` decides it, from the plan committed at the hub's HEAD: it is never
1322
+ an option or an argument, and a hub whose approval was withdrawn refuses with
1323
+ the same reason `materialize` gives, printing no body. If the hub stored a
1324
+ planned bundle for the set, that bundle must record exactly the same approval,
1325
+ or the run is refused as `binding-mismatch`. Each `--supersedes` is the number
1326
+ of the pull request of an older change set of the repository, once each and not
1327
+ the task record, and needs another change set of that repository in the hub's
1328
+ store (`supersedes-unfounded` otherwise). Every number is digits only.
1329
+
1330
+ A set is bound to one body. Running `body` again with the same arguments prints
1331
+ the same body and changes nothing; a run that would produce another body is
1332
+ refused as `body-bound` and prints nothing, so the hash recorded for the set
1333
+ belongs to one body only; whether the pull request that was opened still carries
1334
+ that body is what `status` checks afterwards. Recording the hash replaces the one
1335
+ stored file of the set atomically, refuses a symbolic link, and changes nothing
1336
+ else in the set: its digest, its file name and every other member stay as they
1337
+ were.
1338
+
1339
+ There is no command that undoes a binding, and no `--dry-run`. A `--task-record`
1340
+ or `--supersedes` number mistyped on a first run binds the set to that body, and
1341
+ every later run with other numbers is refused as `body-bound`; nothing in this
1342
+ package unbinds it. Check the numbers before running `body`.
1343
+
1344
+ Hand the output to the pull request as a file, and open the pull request with
1345
+ `--body-file`, not with `--body "$(launcher-apply-plan body ...)"`: shell command
1346
+ substitution drops the final line feed, and the recorded hash covers it.
1347
+
1348
+ Exit `0` prints the body and only the body. Exit `1` is a refusal and exit `2`
1349
+ is indeterminate or a usage error; each prints nothing on standard output and
1350
+ one line on standard error, `launcher-apply-plan body: refused (<reason>)`,
1351
+ `launcher-apply-plan body: indeterminate (<reason>)` or, for a usage error,
1352
+ `launcher-apply-plan body: usage: <the usage line>`, a fixed reason or the usage
1353
+ line and never an argument, a path or any tool output.
1354
+
1355
+ ### Observing the pull request
1356
+
1357
+ `launcher-apply-plan status --repo <id>` reports what the pull request for the
1358
+ repository's stored change set is doing. It runs after the agent has pushed the
1359
+ branch and opened the pull request from the text `renderPullRequest()` returned,
1360
+ and it changes nothing: it asks GitHub three read-only questions (who is
1361
+ asking, which pull requests are open, and where the default branch is) with
1362
+ `gh api --method GET`, reads only commits that are already in the local clone
1363
+ through git, and never fetches a pull request's head, checks anything out or
1364
+ writes a file, an index or any ref but one. It needs a full clone: a partial
1365
+ (promisor) clone, such as a blobless or treeless one, is refused up front as
1366
+ `partial-clone`, before any object is read, because git would fetch what such a
1367
+ clone lacks. All its git calls also run with lazy fetch off, and each has a
1368
+ 30 second limit, including those of the preconditions it shares with `verify`
1369
+ except the base-commit reads made by the hub admission, which have no limit. A
1370
+ read of a base blob inside `verify`'s own checks (a base lockfile, for one) that
1371
+ fails or times out is taken as an absent file, so `status` can answer `diverged`
1372
+ where `indeterminate` is the truer answer. The limit reaches those shared
1373
+ reads through the environment variable `CLOSSYS_LAUNCHER_GIT_TIMEOUT_MS`, which
1374
+ `status` sets for the duration of its own checks, one `status` call at a time in
1375
+ a process; set in a shell, the same variable also limits the git calls of
1376
+ `verify` and `materialize`, which set none. The one
1377
+ ref it writes is the one `verify` writes: the fetch of the default branch into
1378
+ its remote-tracking ref (`refs/remotes/origin/<default branch>`), which also
1379
+ leaves `FETCH_HEAD` and any new objects of that branch in the clone. It runs the
1380
+ hub's readiness executable as `verify` does.
1381
+
1382
+ ```bash
1383
+ launcher-apply-plan status --repo ./site-checkout
1384
+ ```
1385
+
1386
+ It prints one line, `launcher-apply-plan status: <state>`, then a fixed reason
1387
+ in parentheses and `#<n>` for each pull request it is about, and nothing else:
1388
+ never a body, a title, a login, a branch, a path or any tool output.
1389
+
1390
+ | State | Exit | Meaning |
1391
+ | --- | --- | --- |
1392
+ | `proposed` | `0` | An open pull request carries this set's marker, was opened by the person running this, from and into the set's own repository, on the set's branch and title, its body hashes to the `bodySha256` that `body` recorded, and its head commit passes every check `verify` makes, including the ledger's exact bytes. |
1393
+ | `applied` | `0` | The default branch's tip is in the clone and holds every `after` and every key the set writes, however it got there, with no open pull request needed. |
1394
+ | `planned` | `2` | Neither. |
1395
+ | `diverged` | `1` | The pull request that carries this set's marker does not match it: a different base branch, branch or title, a body whose hash is not the recorded `bodySha256` (`body-mismatch`), a head that is not in the clone, or a head that fails a `verify` check. |
1396
+ | `superseded` | `2` | An older change set this hub stored for the repository has an open pull request. It outranks `proposed`, so it is also the state when this set's own pull request is open beside the older one. |
1397
+ | `indeterminate` | `2` | Something could not be read or trusted, with one of the reasons below. |
1398
+
1399
+ When more than one applies, the first of `indeterminate`, `diverged`,
1400
+ `superseded`, `proposed`, `applied` and `planned` wins. A pull request whose
1401
+ body names the marker counts only when its author is the person running this
1402
+ and its head and base are both the set's repository; any other is
1403
+ `foreign-marker`. A body that has a carriage return, a marker that is not the
1404
+ whole of its first line, or a marker `readChangeSetMarker()` cannot read is
1405
+ `marker-malformed`. Any open pull request whose body names the marker word, from
1406
+ anyone, therefore makes `status` `indeterminate` (`foreign-marker`) until it is
1407
+ closed: closing the stray pull request is the remedy.
1408
+
1409
+ Every reason `indeterminate` can carry:
1410
+
1411
+ - The set and the clone: `change-set-absent`, `change-set-invalid`,
1412
+ `repository-invalid`, `missing-clone` and `partial-clone` (a blobless,
1413
+ treeless or other partial clone; use a full clone).
1414
+ - GitHub: `port-failed`, `port-malformed` and `too-many-open`. Only the first
1415
+ page of 100 open pull requests is read, so a listing of 100 or more cannot be
1416
+ shown to be whole and is refused.
1417
+ - The markers: `foreign-marker`, `marker-malformed`, `unknown-digest` (a digest
1418
+ this hub never stored) and `duplicate-digest`.
1419
+ - The body: `body-unbound`, when the set has no `bodySha256` because `body` never
1420
+ ran for it. Such a set is never `proposed` and never `diverged`; run `body`,
1421
+ which records the hash, and open the pull request from its output.
1422
+ - Git, over commits already in the clone: `tip-not-local` (the default branch's
1423
+ tip is not in the clone), `tip-unreadable` (the tip's tree could not be read),
1424
+ `object-unreadable` (a corrupt, missing or unreachable object of the pull
1425
+ request's head, or a git call that timed out on it) and `status-failed` (any
1426
+ other failure, including a git call that timed out, a git that cannot run or
1427
+ an unexpected error; nothing is guessed from it).
1428
+ - The clone, the hub and the admission, which `verify` needs and which are
1429
+ judged before the pull request's own fields: a refusal there, of either exit
1430
+ code of `verify` (for example `remote-tip-mismatch`, when the local default
1431
+ branch is not the remote's), is `indeterminate` here, because it is about the
1432
+ clone and not about the pull request. `verify`'s own reasons for an
1433
+ unreadable tree, such as `status-unreadable`, `symlink-ancestor` and
1434
+ `lockfile-format-unsupported`, appear the same way.
1435
+
1436
+ `proposed` does not check the head's ancestry to the base commit, and nothing in
1437
+ this unit does: it checks the head's tree and the paths that differ from the
1438
+ base, as `verify` does, so a head built on a newer default branch that reverts
1439
+ it can look the same. A reviewer reading the pull request's own diff on GitHub
1440
+ is what would notice.
1441
+
1442
+ `status` does notice a body edited after it was opened. It hashes the body of
1443
+ this set's own pull request, as GitHub returned it, with no trimming and no
1444
+ change to line endings or the final line feed, and `proposed` needs
1445
+ `sha256:` and the hex SHA-256 of its UTF-8 bytes to equal the set's
1446
+ `bodySha256`. An appended line, a missing final line feed, a trailing space or
1447
+ a character that is not well-formed UTF-16 is `diverged` (`body-mismatch`). The
1448
+ body is checked after the base branch, branch and title and before the head, so
1449
+ the first of those that differs is the reason given. An older set's pull request
1450
+ is never hashed, and neither hash nor body is printed.
1451
+
1452
+ `verify` now reads a path the set removes with a `lstat` alone. A file that is
1453
+ still there but that `verify` cannot read used to raise (exit 2, the usage
1454
+ line); it now reports `removal-present` (exit 1), which is the truth about a
1455
+ path that should be gone.
1456
+
1457
+ ## Taking the registry snapshot
1458
+
1459
+ `launcher-apply-plan snapshot --request <file> [--out <file>]` takes the
1460
+ registry snapshot a plan's exact packages are resolved from (#1178). It is
1461
+ the only step of applying a plan that reads the package registry. It records what the
1462
+ registry said; it decides nothing from it. Deciding is
1463
+ `advisor-resolve-packages`'s job, in `@clossys/advisor`.
1464
+
1465
+ - **Request.** `<file>` holds the report `advisor-package-request` prints,
1466
+ `{ "state": "satisfied", "names": [...], "findings": [] }`, or just
1467
+ `{ "names": [...] }`, read as strict JSON (invalid UTF-8, a byte order
1468
+ mark or a repeated key is refused). Every name must be a scoped package
1469
+ name in the publishing scope this package was built with, and appear once.
1470
+ A request with another field, another state or any finding is refused
1471
+ before anything is fetched.
1472
+ - **Fetch.** For each name, in name order and one at a time, a `GET` of
1473
+ the package's full registry document at `{registry}/{name}`, with the slash
1474
+ in the name percent-encoded (`@scope%2Fname`), the same encoding
1475
+ `@clossys/integrator` uses. The registry is the one in this repository's
1476
+ `package-scope.json`, packed into this package at build time.
1477
+ - **Transport.** Node's own `fetch`. The only headers this step sets are
1478
+ `accept: application/json` and `accept-encoding: identity`, and never an
1479
+ `Authorization` header; Node's fetch adds its own default, non-credential
1480
+ headers. The step does not run the npm CLI, and reads no
1481
+ `.npmrc` and no token from the environment. If Node is started with an
1482
+ environment proxy (`NODE_USE_ENV_PROXY`), requests go through that proxy.
1483
+ No registry credential is ever sent; a username and password written in
1484
+ the proxy URL itself are sent only to that proxy, as Node's fetch does. A redirect is refused, never followed.
1485
+ `accept-encoding: identity` asks for the body uncompressed, so when the
1486
+ server honours it the size cap and `responseSha256` apply to the exact
1487
+ bytes received. A response body is
1488
+ read as a stream and abandoned as soon as it passes 10 MiB; a declared
1489
+ length over that is refused before any of the body is read. If a server
1490
+ compresses the body anyway, Node's `fetch` decodes it and the 10 MiB cap
1491
+ counts the decoded bytes, so the read is still bounded. Each request, body
1492
+ included, is abandoned after 30 seconds.
1493
+ - **Answers.** A `200` is projected into the snapshot. A `404` is recorded
1494
+ as `status: "not-found"`. Anything else stops the step at that package:
1495
+ a transport error, a timeout, a redirect, any other status, an oversize
1496
+ body, or a `200` body that is not strict JSON or is not that package's
1497
+ registry document. Nothing further is fetched, no snapshot is written, and
1498
+ the exit code is `2`. An earlier snapshot at the output path is left
1499
+ untouched, and must not be used: exit `2` means this run recorded nothing.
1500
+ - **Projection.** Only what the registry snapshot contract declares is
1501
+ kept: the version the `latest` dist-tag names, or `null`, and, when the
1502
+ document lists that version, that one version's integrity value and tarball
1503
+ URL exactly as served, whether it is deprecated, when it was published, and
1504
+ whether it lists attestations. Every other version, dist-tag and field is
1505
+ ignored. The document must name the requested package, and the version's
1506
+ own entry must carry the version number `latest` names; otherwise nothing
1507
+ is written. `responseSha256` is the SHA-256 of the response body's bytes
1508
+ as received; if a server compressed the body despite
1509
+ `accept-encoding: identity`, it is the SHA-256 of the decoded body.
1510
+ - **Output.** The snapshot is written only after the exact text to be
1511
+ written has been read back strictly and has passed
1512
+ `docs/contracts/registry-snapshot.json`
1513
+ (in the public repository, not shipped in this package; its content is
1514
+ packed at build time), schema and code rules N1-N3 both. It goes to
1515
+ `--out`, by default `clossys/.state/apply/registry-snapshot.json` under the
1516
+ current directory, which should be the hub. It is two-space JSON with a
1517
+ final newline, with packages sorted by name, so the same registry answers
1518
+ give the same bytes apart from `fetchedAt`. `fetchedBy` is this package's
1519
+ own name and version. The write is atomic: a temporary file in the same
1520
+ directory is written, flushed to disk and renamed over the target, so a
1521
+ reader sees the old file or the whole new one.
1522
+
1523
+ A message names a package by its position in the request, `names[<n>]`,
1524
+ never by its name, and never quotes a response or the request: a name is
1525
+ request text, so the caller looks position `<n>` up in the request file it
1526
+ wrote. Exit codes: `0` means the snapshot was written, or that `--help` printed
1527
+ the usage;
1528
+ `2` means nothing was written, whether because of a usage error, an
1529
+ unreadable or refused request, or a registry answer this step cannot record.
1530
+ A snapshot is a record of what the registry answered, not evidence of where
1531
+ a package came from; that is shown by verifying the package's provenance,
1532
+ which this step does not do.
1533
+
1534
+ ## Checking provenance (V9)
1535
+
1536
+ `checkSetProvenance({ tree, hubRoot, items }, ports?)`
1537
+ returns, for one change set, its V9 `ApplyCheck` entries
1538
+ (`Promise<readonly ApplyCheck[]>`). `plan` runs it on its temporary tree,
1539
+ after the lockfile step passes; `materialize` and `verify` do not yet run it.
1540
+
1541
+ - **Engine.** It runs the hub's own `node_modules/.bin/integrator-provenance-check --cwd <tree>`
1542
+ (`PROVENANCE_CHECK_BIN`), never through `npx` and never looked up on `PATH`. If
1543
+ that bin is missing, or its real path is not inside the hub's installed
1544
+ `@clossys/integrator`, the result is indeterminate (`engine-missing-bin`).
1545
+ The child gets an environment built from a fixed list of variables, so no
1546
+ parent credential, proxy or CA-trust variable reaches it, and its time and
1547
+ output are capped (`PROVENANCE_CHECK_TIMEOUT_MS`, `PROVENANCE_CHECK_MAX_BUFFER`).
1548
+ The JSON report is parsed strictly; exit `2`, unreadable output, or output
1549
+ that contradicts the exit code is indeterminate.
1550
+ - **What gates.** Every `install` and `pin-starter` item except one whose
1551
+ `satisfiedInBase` is exactly `true`. Each must be `verified` at exactly its version. One
1552
+ missing from the report is indeterminate; a verified version other than the
1553
+ act's is violated (`version-mismatch`). Other `@clossys/*` packages in the
1554
+ report never gate, so an unrelated violated legacy pin passes.
1555
+ - **No exception.** An unverified package is never satisfied. One the bin
1556
+ reports `violated` stays violated (`provenance-unverified`), and one it
1557
+ cannot decide stays indeterminate. The first-publication exception the
1558
+ design allows for (D20) is deliberately not implemented: a registry
1559
+ snapshot records only the one version `latest` names, so it cannot show
1560
+ that a version is a package's first publication, and Integrator reports a
1561
+ failed attestation the same way as a missing one. Any exception built on
1562
+ that would also pass a package with earlier releases whose latest release
1563
+ fails verification. Until the snapshot contract records evidence of a first
1564
+ publication, an unattested first publication blocks the apply (fail
1565
+ closed).
1566
+ - **Verdict.** Indeterminate outranks violated: when any indeterminate rule
1567
+ applies, only indeterminate entries are returned. A set with nothing to gate
1568
+ returns one satisfied entry with no rule, without running the engine.
1569
+ - **Soundness boundary.** A pass means every version the set installs or
1570
+ updates is verified by the hub's pinned Integrator at check time, with no
1571
+ exception. It does not cover transitive dependencies or a later republish.
1572
+ The bin check accepts any regular file inside the installed
1573
+ `@clossys/integrator` package and does not compare the installed version
1574
+ with the hub's pin; the hub's `node_modules` is trusted.
1575
+
1576
+ `registrySnapshotDigest(snapshot)` returns a registry snapshot's contract digest
1577
+ (`sha256:` and 64 lowercase hexadecimal digits) from the snapshot's contents,
1578
+ independent of fetch time and of the order of packages and versions; it throws a
1579
+ `TypeError` for a snapshot that does not validate. The provenance check does not
1580
+ read a snapshot; the digest is for the plan binding a later change wires in.
1581
+
1582
+ Types: `ProvenanceGateInput`, `ProvenanceGatePorts`.
320
1583
 
321
1584
  ## Why this is not Advisor, Starter, Builder, installer, creator, or a connector
322
1585
 
@@ -351,9 +1614,10 @@ intact.
351
1614
 
352
1615
  ## Requirements
353
1616
 
354
- Node.js 20+, ESM, GitHub `gh`, and no runtime dependencies. Creating a new
355
- hub needs permission to create a private repository under the inferred
356
- owner. Appointing uses the current checkout and does not create a second
1617
+ Node.js 20+, ESM, GitHub `gh`, and no runtime dependencies. The registry
1618
+ snapshot step needs HTTPS access to the public registry, and no credential.
1619
+ Creating a new hub needs permission to create a private repository under the
1620
+ inferred owner. Appointing uses the current checkout and does not create a second
357
1621
  repository.
358
1622
 
359
1623
  ## Licence
@@ -363,3 +1627,52 @@ MIT.
363
1627
  ## Changelog
364
1628
 
365
1629
  Release notes for every version are in the [changelog](https://github.com/clossys/foundry/blob/main/docs/changelogs/launcher.md), kept in the public repository rather than in the installed package.
1630
+
1631
+
1632
+ ### Apply branch provenance
1633
+
1634
+ `launcher-apply-plan plan --agent codex` selects an agent namespace for the
1635
+ computed apply branches. The supported choices are `codex`, `claude`, and
1636
+ `cursor`. The choice is covered by each change-set digest and the bundle
1637
+ approval subject; use the same choice when recomputing an approved bundle.
1638
+ Omitting `--agent` preserves legacy `clossys/apply-<digest12>` branches and
1639
+ stored change sets. Materialize and apply use the stored branch binding.
1640
+ Explicit provenance also renders setup path-scope checks for legacy and
1641
+ supported agent apply branches, refusing malformed or unsupported apply
1642
+ namespaces. Existing legacy template bytes remain unchanged.
1643
+
1644
+ ### Opt in to existing root dependency declarations
1645
+
1646
+ Existing declarations are refused by default. To adopt or update an existing
1647
+ first-party root `package.json` declaration, select the existing bucket and
1648
+ explicitly consent before approving setup. `createExistingDeclarationAdoptions`
1649
+ derives proof rows from a complete `RepositoryObservation`, a validated resolved
1650
+ `AdvisorPlan`, selected package names and the literal
1651
+ `"adopt-existing-declaration"`. Pass these rows under the repository id in
1652
+ `PlanApplyBundleInputs.existingDeclarationAdoptions`, or save that mapping as
1653
+ JSON and pass `launcher-apply-plan plan --adopt-existing <consent.json>`.
1654
+ This option supplies scope, never an approval. The approval sheet displays prior
1655
+ literal declaration, prior resolved version/integrity, desired public identity
1656
+ and observed base. The set and bundle digests cover the consent and desired
1657
+ registry snapshot digest. Omission retains all legacy behavior.
1658
+
1659
+ This bounded path requires Starter **0.3.x**. Starter 0.2.x remains supported for
1660
+ legacy setup, but cannot accept this proof. Only a single declaration in the
1661
+ existing root dependency bucket is eligible; cross-bucket relocation, duplicate
1662
+ placements, aliases and file/git/tarball declarations refuse. Prior literals may
1663
+ be exact, caret or tilde versions with all three numeric components. Align
1664
+ unsupported placements in an ordinary reviewed manifest/native-lock change
1665
+ before taking a fresh setup observation. Nested application manifests are outside
1666
+ this capability.
1667
+
1668
+ Setup renders the approved rows into its canonical ledger with the setup digest.
1669
+ The protected merged setup is the trust anchor; a newly claimed approved head
1670
+ still fails required admission. The next apply retains the proof unchanged and
1671
+ emits a real compare-and-swap, even when prior and desired versions are equal.
1672
+ Launcher independently checks immutable source keys and root lock identities.
1673
+ Starter reads protected base and pull-request head manifest/lock files as data,
1674
+ checks prior and desired identities and forbids collateral manifest changes.
1675
+ Head code is never executed by this admission check. These checks prove declared
1676
+ resolution metadata; installed-byte execution evidence remains a separate proof.
1677
+
1678
+ Existing-declaration consent supports stable exact, caret and tilde root declarations in their existing dependency bucket. The prior resolved version must satisfy that declaration. Cross-bucket moves, aliases, file references and prereleases are refused. Proof rows remain immutable; this capability covers initial setup and its first admitted apply. It grants no installed-byte or later-generation lifecycle evidence.