@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/dist/core.js CHANGED
@@ -4,6 +4,9 @@ import { composeSkills, SKILLS_MANIFEST_REL } from "./skills.js";
4
4
  import { parseSkillManifest, summarizeSkillsManifest } from "./manifest.js";
5
5
  import { detectLinkedHosts, serializeHostRecord, HOSTS_REL } from "./hosts.js";
6
6
  import { reportInventoryDrift } from "./inventory-adoption.js";
7
+ import { belongsToOwner, distinctOwners, sameOwner, sameRepository } from "./identity.js";
8
+ import { isValidInventoryId, readInventoryDocument, renderInventoryDocument, validateInventoryDocument } from "./inventory-contract.js";
9
+ import { describeChosenInventory, resolveChosenInventory } from "./inventory-choice.js";
7
10
  export const DEFAULT_REPOSITORY_NAME = "workspace";
8
11
  /** The one visible, per-repository Clossys folder (#1171). Every role's output lives under it. */
9
12
  export const CLOSSYS_DIR_REL = "clossys";
@@ -17,7 +20,10 @@ export const LEGACY_STATE_DIR_REL = ".clossys";
17
20
  export const LEGACY_WORKSPACE_MARKER_REL = join(LEGACY_STATE_DIR_REL, "workspace.json");
18
21
  export const LEGACY_WORKSPACE_INVENTORY_REL = join(LEGACY_STATE_DIR_REL, "inventory.json");
19
22
  export const ADVISOR_PACKAGE = "@clossys/advisor";
23
+ export const INTEGRATOR_PACKAGE = "@clossys/integrator";
20
24
  export const LAUNCHER_PACKAGE = "@clossys/launcher";
25
+ /** The engines a hub pins: each exactly, once, in `devDependencies`, at its live registry version. */
26
+ export const HUB_ENGINE_PACKAGES = [ADVISOR_PACKAGE, INTEGRATOR_PACKAGE];
21
27
  const DEPENDENCY_BUCKETS = [
22
28
  "dependencies",
23
29
  "devDependencies",
@@ -38,6 +44,29 @@ export const CONSUMER_AGENTS_MD = `# Account workspace
38
44
 
39
45
  This folder is the account hub for Foundry packages.
40
46
 
47
+ After \`npx @clossys/launcher\`, the \`@clossys-*\` team is composed in this
48
+ hub. Talk with \`@clossys-advisor\` and \`@clossys-<package>\` here. A launcher
49
+ run writes nothing into a product repository; once a repository is staffed
50
+ in an approved plan, it gets \`@clossys-advisor\` and the voices of the roles
51
+ staffed there, with that plan's setup pull request. A missing \`@\` mention
52
+ is a bug only here in the hub; in a product repository, a role that is not
53
+ staffed there is expected to be absent. \`@clossys-advisor\` is the hiring
54
+ check.
55
+
56
+ Run \`npx @clossys/launcher\` again for hub health and to refresh the voices
57
+ in this hub, not as how you talk to packages.
58
+
59
+ The person in this folder is a founder, not an engineer. Speak like a
60
+ person. Do not dump machine identifiers, JSON, hashes, or grant fields
61
+ unless they ask.
62
+
63
+ Advisor is read-only until the sponsor approves a next action.
64
+ `;
65
+ /** The hub guidance written while a launcher run still composed the team into checkouts beside the hub; resume refreshes it. */
66
+ export const SIBLING_COMPOSING_CONSUMER_AGENTS_MD = `# Account workspace
67
+
68
+ This folder is the account hub for Foundry packages.
69
+
41
70
  After \`npx @clossys/launcher\`, the same \`@clossys-*\` team is composed in
42
71
  every inventoried checkout beside this hub. Talk with \`@clossys-advisor\` and
43
72
  \`@clossys-<package>\` here or in a product repository. A missing \`@\` mention
@@ -52,14 +81,6 @@ unless they ask.
52
81
 
53
82
  Advisor is read-only until the sponsor approves a next action.
54
83
  `;
55
- /** Canned guidance for inventoried product checkouts (not the hub). */
56
- export const SISTER_CONSUMER_AGENTS_MD = `# Product repository
57
-
58
- This repository is part of the same account engagement. The same
59
- \`@clossys-<package>\` team is here for intro and questions;
60
- \`@clossys-advisor\` decides hiring and compatibility. This folder is not the
61
- hub — engines are hired per repository, not dumped here.
62
- `;
63
84
  /** Previous generate-time guidance; used to refresh stale hub AGENTS.md on resume. */
64
85
  export const LEGACY_CONSUMER_AGENTS_MD = `# Account workspace
65
86
 
@@ -174,7 +195,7 @@ export function isHubDocument(value) {
174
195
  const parsed = value.repository.includes("/")
175
196
  ? { owner: value.repository.split("/")[0], repository: value.repository.split("/")[1] }
176
197
  : null;
177
- if (!parsed?.owner || !parsed.repository || parsed.owner !== value.owner || !REPO.test(parsed.repository))
198
+ if (!parsed?.owner || !parsed.repository || !sameOwner(parsed.owner, value.owner) || !REPO.test(parsed.repository))
178
199
  return false;
179
200
  return true;
180
201
  }
@@ -195,8 +216,9 @@ function readHubAt(host, path) {
195
216
  * migration (#1171). `clean`: only the current path has a marker. `legacy`:
196
217
  * only the old path does; resume migrates it (see `migrateLegacyHubState`).
197
218
  * `indeterminate`: both paths carry a parseable marker; launcher never
198
- * merges them silently, so `planWorkspace` refuses instead. `none`: neither
199
- * path has one.
219
+ * merges them silently, so `planWorkspace` refuses instead (and, for a hub
220
+ * cloned from an empty directory, `classifyClonedHub` at apply time).
221
+ * `none`: neither path has one.
200
222
  */
201
223
  function locateHub(host, directory) {
202
224
  const current = readHubAt(host, join(directory, WORKSPACE_MARKER_REL));
@@ -212,21 +234,26 @@ function locateHub(host, directory) {
212
234
  function readHub(host, directory) {
213
235
  return locateHub(host, directory).document;
214
236
  }
215
- /** Classifies a generated hub inventory (packed template skeleton/clossys/.state/inventory.json; the generated path does not ship) without inventing repositories. */
216
- export function inspectInventory(raw) {
237
+ /**
238
+ * The inventory document's shape lives in docs/contracts/repository-inventory.json
239
+ * (in the public repository; that exact path does not ship in this
240
+ * package, but this package's build packs and ships its own copy of the
241
+ * contract) and is checked, on every read and write, by
242
+ * `validateInventoryDocument()` through the shared contract checker
243
+ * (./inventory-contract.ts, #1334, #1179).
244
+ */
245
+ export { validateInventoryDocument } from "./inventory-contract.js";
246
+ /**
247
+ * Classifies a generated hub inventory (packed template skeleton/clossys/.state/inventory.json; the generated path does not ship) without inventing repositories. Malformed input is "invalid", never silently folded into "empty" (#1334).
248
+ * Pass the file's exact bytes (`WorkspaceHost.readBytes()`), and the hub's owner when it is known, so a bare id and `<owner>/<id>` count as one repository (#1179).
249
+ */
250
+ export function inspectInventory(raw, hubOwner) {
217
251
  if (raw === null)
218
252
  return { status: "missing", count: 0 };
219
- try {
220
- const parsed = JSON.parse(raw);
221
- if (!isRecord(parsed) || parsed.schemaVersion !== 1 || !Array.isArray(parsed.repositories)) {
222
- return { status: "empty", count: 0 };
223
- }
224
- const count = parsed.repositories.length;
225
- return { status: count > 0 ? "populated" : "empty", count };
226
- }
227
- catch {
228
- return { status: "empty", count: 0 };
229
- }
253
+ const validated = validateInventoryDocument(raw, hubOwner === undefined ? {} : { hubOwner });
254
+ if (!validated.valid)
255
+ return { status: "invalid", count: 0, reason: validated.reason };
256
+ return { status: validated.ids.length > 0 ? "populated" : "empty", count: validated.ids.length };
230
257
  }
231
258
  function looksLikeFoundry(host, directory) {
232
259
  const manifestRaw = host.readText(join(directory, "package.json"));
@@ -263,19 +290,33 @@ function commandAvailable(host, command) {
263
290
  export function hasAdvisorPin(manifest) {
264
291
  if (!isRecord(manifest))
265
292
  return false;
266
- return DEPENDENCY_BUCKETS.some((bucket) => clossysNames(manifest[bucket], new Set()) !== undefined);
293
+ return DEPENDENCY_BUCKETS.some((bucket) => enginePinsIn(manifest[bucket], new Set())[ADVISOR_PACKAGE] !== undefined);
294
+ }
295
+ /**
296
+ * The one place Launcher learns a package's live version: the public npm
297
+ * registry (`npm view <name> version`). A failed or unparseable read is
298
+ * `undefined`, never a guess.
299
+ */
300
+ function readRegistryVersion(host, name) {
301
+ const viewed = host.run("npm", ["view", name, "version"]);
302
+ const version = viewed.stdout.trim();
303
+ return viewed.status === 0 && /^\d+\.\d+\.\d+$/.test(version) ? version : undefined;
267
304
  }
268
- /** Collects GitHub owner, cwd shape, default-hub presence, and the public Advisor pin. */
269
305
  /**
270
306
  * Reads the public `@clossys/launcher` registry version, used only to grade
271
307
  * catalogue-sourced skill staleness in the health report (#1183). A missing
272
308
  * or unparseable read leaves staleness ungraded rather than refusing.
273
309
  */
274
310
  export function readLiveLauncherVersion(host) {
275
- const viewed = host.run("npm", ["view", LAUNCHER_PACKAGE, "version"]);
276
- const version = viewed.stdout.trim();
277
- return viewed.status === 0 && /^\d+\.\d+\.\d+$/.test(version) ? version : undefined;
311
+ return readRegistryVersion(host, LAUNCHER_PACKAGE);
278
312
  }
313
+ /**
314
+ * Collects GitHub owner, cwd shape, default-hub presence, and the public
315
+ * Advisor and Integrator versions. Default-hub presence means only that
316
+ * `{owner}/workspace` exists on GitHub; its contents (marker, legacy marker,
317
+ * or none) are not observed here but classified after the clone, at apply
318
+ * time (#1585).
319
+ */
279
320
  export function observeWorkspace(host) {
280
321
  const cwd = host.cwd;
281
322
  const ghAvailable = commandAvailable(host, "gh");
@@ -292,39 +333,39 @@ export function observeWorkspace(host) {
292
333
  githubRepository = parsed.repository;
293
334
  }
294
335
  }
295
- const candidates = new Set();
336
+ // Owners are compared as GitHub compares them (identity.ts): two spellings of one account are one candidate.
337
+ const seenOwners = [];
296
338
  const envOwnerRaw = host.env.CLOSSYS_OWNER?.trim();
297
339
  const envOwner = envOwnerRaw && OWNER.test(envOwnerRaw) ? envOwnerRaw : undefined;
298
340
  if (ghAvailable) {
299
341
  const user = host.run("gh", ["api", "user", "--jq", ".login"]);
300
342
  const login = user.stdout.trim();
301
343
  if (user.status === 0 && OWNER.test(login))
302
- candidates.add(login);
344
+ seenOwners.push(login);
303
345
  for (const org of stdoutLines(host.run("gh", ["org", "list"]))) {
304
346
  if (OWNER.test(org))
305
- candidates.add(org);
347
+ seenOwners.push(org);
306
348
  }
307
349
  }
308
350
  if (githubOwner)
309
- candidates.add(githubOwner);
351
+ seenOwners.push(githubOwner);
352
+ const candidates = new Set(distinctOwners(seenOwners));
310
353
  let remoteDefaultHub;
311
- let advisorVersion;
312
354
  const ownerGuess = envOwner ?? (candidates.size === 1 ? [...candidates][0] : githubOwner);
313
355
  if (ghAvailable && ownerGuess) {
314
356
  const viewed = host.run("gh", ["repo", "view", `${ownerGuess}/${DEFAULT_REPOSITORY_NAME}`, "--json", "name"]);
315
357
  if (viewed.status === 0)
316
358
  remoteDefaultHub = { owner: ownerGuess, repository: DEFAULT_REPOSITORY_NAME };
317
359
  }
318
- const viewedAdvisor = host.run("npm", ["view", ADVISOR_PACKAGE, "version"]);
319
- const version = viewedAdvisor.stdout.trim();
320
- if (viewedAdvisor.status === 0 && /^\d+\.\d+\.\d+$/.test(version))
321
- advisorVersion = version;
360
+ const advisorVersion = readRegistryVersion(host, ADVISOR_PACKAGE);
361
+ const integratorVersion = readRegistryVersion(host, INTEGRATOR_PACKAGE);
322
362
  const hubLocation = locateHub(host, cwd);
323
363
  // While only the legacy `.clossys/` marker exists, its sibling inventory is
324
364
  // the one resume will migrate; read from there so planning sees it too.
325
- const inventoryRaw = hubLocation.migration === "legacy"
326
- ? host.readText(join(cwd, LEGACY_WORKSPACE_INVENTORY_REL))
327
- : host.readText(join(cwd, WORKSPACE_INVENTORY_REL));
365
+ const inventoryBytes = hubLocation.migration === "legacy"
366
+ ? host.readBytes(join(cwd, LEGACY_WORKSPACE_INVENTORY_REL))
367
+ : host.readBytes(join(cwd, WORKSPACE_INVENTORY_REL));
368
+ const inventoryOwner = hubLocation.document?.owner ?? githubOwner;
328
369
  const cwdObservation = {
329
370
  absolutePath: cwd,
330
371
  empty: isEffectivelyEmpty(entries),
@@ -334,7 +375,7 @@ export function observeWorkspace(host) {
334
375
  ...(hubLocation.document === undefined ? {} : { hub: hubLocation.document }),
335
376
  ...(hubLocation.migration === "none" ? {} : { hubMigration: hubLocation.migration }),
336
377
  looksLikeFoundry: looksLikeFoundry(host, cwd),
337
- inventory: inspectInventory(inventoryRaw),
378
+ inventory: inspectInventory(inventoryBytes, inventoryOwner),
338
379
  };
339
380
  return {
340
381
  cwd: cwdObservation,
@@ -342,6 +383,7 @@ export function observeWorkspace(host) {
342
383
  ...(envOwner === undefined ? {} : { envOwner }),
343
384
  ...(remoteDefaultHub === undefined ? {} : { remoteDefaultHub }),
344
385
  ...(advisorVersion === undefined ? {} : { advisorVersion }),
386
+ ...(integratorVersion === undefined ? {} : { integratorVersion }),
345
387
  ghAvailable,
346
388
  gitAvailable,
347
389
  };
@@ -368,23 +410,37 @@ function resolveOwner(observation, host) {
368
410
  * Decides create, resume, or adopt from a cwd observation.
369
411
  * Appointing means: run this from the GitHub repository that should own the hub.
370
412
  */
371
- export function readInventoryRepositories(host, source, label) {
372
- const raw = host.readText(source);
413
+ /**
414
+ * Reads repository ids from a `schemaVersion: 1` inventory document, routed
415
+ * through `validateInventoryDocument` -- the same schema check `--inventory`
416
+ * and `inspectInventory` apply, so a caller here can never end up trusting a
417
+ * document neither of those would have accepted (#1334). Throws, naming the
418
+ * offending field, on anything present but invalid; a missing file is `[]`,
419
+ * not a throw -- an absent inventory is a fact about the hub, not a
420
+ * malformed one. The file is read as bytes; `hubOwner`, when given, makes a
421
+ * bare id and `<hubOwner>/<id>` one repository, so a document listing both
422
+ * is refused.
423
+ */
424
+ export function readInventoryRepositories(host, source, label, hubOwner) {
425
+ const raw = host.readBytes(source);
373
426
  if (raw === null)
374
427
  return [];
375
- try {
376
- const parsed = JSON.parse(raw);
377
- if (!isRecord(parsed) || !Array.isArray(parsed.repositories))
378
- return [];
379
- return parsed.repositories.map((entry) => {
380
- if (isRecord(entry) && isText(entry.id))
381
- return entry.id.trim();
382
- return "";
383
- });
384
- }
385
- catch {
386
- throw new Error(`${label} is not readable inventory JSON (schemaVersion 1, repositories array)`);
387
- }
428
+ const validated = validateInventoryDocument(raw, hubOwner === undefined ? {} : { hubOwner });
429
+ if (!validated.valid)
430
+ throw new Error(`${label} ${validated.reason}`);
431
+ return validated.ids;
432
+ }
433
+ /**
434
+ * How a founder gives Launcher the repositories a hub covers (#1179): they
435
+ * choose them on Advisor's repository-choice card, and `--repositories`
436
+ * writes the inventory. Named by every refusal that needs an inventory,
437
+ * instead of `--inventory <path>`, which still works but asks for a
438
+ * document a founder will not write.
439
+ */
440
+ function chooseRepositoriesHint(extraFlag = "") {
441
+ return ("choose the repositories this hub covers on Advisor's repository card " +
442
+ "(`npx -p @clossys/advisor advisor-repository-card`), built from the repositories GitHub lists for your sign-in, then run " +
443
+ `\`launcher --repositories <owner/name>[,<owner/name>...]${extraFlag}\`, and Launcher writes the inventory for you`);
388
444
  }
389
445
  /**
390
446
  * Resolves which repositories the adopt plan inventories. When the on-disk hub
@@ -398,35 +454,92 @@ function resolveAdoptInventory(host, cwd, inventoryPath) {
398
454
  const trimmed = inventoryPath?.trim();
399
455
  const onDiskPopulated = cwd.inventory?.status === "populated";
400
456
  if (!onDiskPopulated && !trimmed) {
401
- return refuse("violated", "appointing requires a populated generated hub inventory (packed template skeleton/clossys/.state/inventory.json; the generated path does not ship), or --inventory <path> to a populated inventory document");
457
+ if (cwd.inventory?.status === "invalid") {
458
+ return refuse("violated", `the on-disk hub inventory ${cwd.inventory.reason ?? "does not conform to the inventory contract"} -- to replace it, ${chooseRepositoriesHint(" --replace-inventory")}`);
459
+ }
460
+ return refuse("violated", `appointing needs the repositories this hub covers, and it has no inventory yet: ${chooseRepositoriesHint()}`);
402
461
  }
403
462
  if (!trimmed)
404
463
  return {};
405
464
  const resolved = resolve(cwd.absolutePath, trimmed);
406
- const imported = inspectInventory(host.readText(resolved));
407
- if (imported.status !== "populated") {
408
- return refuse("violated", "--inventory must point at a populated inventory document (schemaVersion 1, nonempty repositories)");
465
+ const importedRaw = host.readBytes(resolved);
466
+ if (importedRaw === null) {
467
+ return refuse("violated", `--inventory does not point at a readable file: ${resolved}`);
468
+ }
469
+ const owner = cwd.githubOwner === undefined ? {} : { hubOwner: cwd.githubOwner };
470
+ const imported = readInventoryDocument(importedRaw, owner);
471
+ if (!imported.valid) {
472
+ return refuse("violated", `--inventory at ${resolved} ${imported.reason}`);
473
+ }
474
+ if (imported.ids.length === 0) {
475
+ return refuse("violated", `--inventory at ${resolved} must be a populated inventory document (nonempty repositories)`);
409
476
  }
410
- if (!onDiskPopulated)
411
- return { inventorySource: resolved };
477
+ if (!onDiskPopulated) {
478
+ return {
479
+ inventorySource: resolved,
480
+ ...(cwd.inventory?.status === "invalid" ? { replacesInvalidInventory: true } : {}),
481
+ };
482
+ }
483
+ const onDiskRaw = host.readBytes(join(cwd.absolutePath, WORKSPACE_INVENTORY_REL));
484
+ const onDisk = onDiskRaw === null ? undefined : readInventoryDocument(onDiskRaw, owner);
485
+ if (onDisk !== undefined && !onDisk.valid) {
486
+ return refuse("violated", `the on-disk hub inventory ${onDisk.reason}`);
487
+ }
488
+ const onDiskEntries = onDisk !== undefined && onDisk.valid ? onDisk.entries : [];
489
+ // Merge whole entries, not ids: an entry's `packages` travels with it.
490
+ // Entries are keyed by Launcher's one repository identity (identity.ts),
491
+ // the same rule validateInventoryDocument() applies to ids, so the merge
492
+ // can never write a document the validator then refuses; the first
493
+ // occurrence -- the on-disk spelling and entry -- wins (#1334, #1179).
412
494
  const merged = [];
413
- const seen = new Set();
414
- for (const id of readInventoryRepositories(host, join(cwd.absolutePath, WORKSPACE_INVENTORY_REL), "the on-disk hub inventory")) {
415
- if (id !== "" && !seen.has(id)) {
416
- seen.add(id);
417
- merged.push(id);
418
- }
495
+ for (const entry of [...onDiskEntries, ...imported.entries]) {
496
+ if (!merged.some((kept) => sameRepository(kept.id, entry.id, cwd.githubOwner)))
497
+ merged.push(entry);
419
498
  }
420
- for (const id of readInventoryRepositories(host, resolved, "--inventory")) {
421
- if (id !== "" && !seen.has(id)) {
422
- seen.add(id);
423
- merged.push(id);
424
- }
499
+ return {
500
+ mergedInventoryIds: merged.map((entry) => entry.id),
501
+ mergedInventoryRepositories: merged,
502
+ mergedInventoryDocument: renderInventoryDocument(merged),
503
+ };
504
+ }
505
+ /**
506
+ * Resolves `--repositories` against the inventory stored in `directory`, or
507
+ * returns a refusal. See `resolveChosenInventory()`.
508
+ */
509
+ function resolveChosenRepositories(host, directory, owner, repositories, replaceInventory) {
510
+ const resolution = resolveChosenInventory(host.readBytes(join(directory, WORKSPACE_INVENTORY_REL)), repositories, owner, replaceInventory);
511
+ if (resolution.kind === "refuse")
512
+ return refuse("violated", resolution.message);
513
+ return { chosenInventory: resolution.chosen };
514
+ }
515
+ /** The hub engine versions a plan carries, only those the observation read. */
516
+ function observedEngineVersions(observation) {
517
+ return {
518
+ ...(observation.advisorVersion === undefined ? {} : { advisorVersion: observation.advisorVersion }),
519
+ ...(observation.integratorVersion === undefined ? {} : { integratorVersion: observation.integratorVersion }),
520
+ };
521
+ }
522
+ /**
523
+ * Create and appoint pin both hub engines, so each needs its live version:
524
+ * the refusal names the first one the registry did not answer for.
525
+ */
526
+ function requireEngineVersions(observation) {
527
+ if (!observation.advisorVersion) {
528
+ return refuse("indeterminate", `cannot read a public ${ADVISOR_PACKAGE} version from the npm registry`);
529
+ }
530
+ if (!observation.integratorVersion) {
531
+ return refuse("indeterminate", `cannot read a public ${INTEGRATOR_PACKAGE} version from the npm registry`);
425
532
  }
426
- return { mergedInventoryIds: merged };
533
+ return { advisorVersion: observation.advisorVersion, integratorVersion: observation.integratorVersion };
427
534
  }
428
535
  export function planWorkspace(observation, host, options = {}) {
429
536
  const { cwd } = observation;
537
+ if (options.repositories !== undefined && options.inventoryPath !== undefined) {
538
+ return refuse("violated", "--repositories and --inventory each supply the whole inventory; use one, not both");
539
+ }
540
+ if (options.replaceInventory === true && options.repositories === undefined) {
541
+ return refuse("violated", "--replace-inventory approves replacing the inventory with --repositories, and means nothing without it");
542
+ }
430
543
  if (cwd.hubMigration === "indeterminate") {
431
544
  return refuse("indeterminate", `both ${WORKSPACE_MARKER_REL} and the legacy ${LEGACY_WORKSPACE_MARKER_REL} are present; launcher never merges them silently -- remove one before resuming`);
432
545
  }
@@ -436,26 +549,50 @@ export function planWorkspace(observation, host, options = {}) {
436
549
  cwd.hub === undefined &&
437
550
  cwd.git &&
438
551
  cwd.githubOwner !== undefined &&
439
- cwd.githubOwner !== envOwner) {
552
+ !sameOwner(cwd.githubOwner, envOwner)) {
440
553
  return refuse("violated", `CLOSSYS_OWNER is "${envOwner}" but the github.com origin here is owned by "${cwd.githubOwner}"; refusing to appoint a marker for the wrong account`);
441
554
  }
442
555
  if (cwd.looksLikeFoundry) {
443
556
  return refuse("violated", "refusing to scaffold a hub inside the Foundry supplier tree; run from the GitHub repository you want to appoint, or from an empty directory");
444
557
  }
445
558
  if (cwd.hub) {
559
+ let chosen;
560
+ if (options.repositories !== undefined) {
561
+ if (cwd.hubMigration === "legacy") {
562
+ return refuse("violated", "this hub's state is still in the legacy .clossys/ folder; run launcher once without --repositories to move it to clossys/.state/, then choose the repositories again");
563
+ }
564
+ const resolved = resolveChosenRepositories(host, cwd.absolutePath, cwd.hub.owner, options.repositories, options.replaceInventory === true);
565
+ if ("action" in resolved)
566
+ return resolved;
567
+ chosen = resolved;
568
+ }
446
569
  return {
447
570
  action: "resume",
448
571
  owner: cwd.hub.owner,
449
572
  repository: repoNameFromSlug(cwd.hub.repository, DEFAULT_REPOSITORY_NAME),
450
573
  directory: cwd.absolutePath,
451
574
  clone: false,
452
- ...(observation.advisorVersion === undefined ? {} : { advisorVersion: observation.advisorVersion }),
575
+ ...observedEngineVersions(observation),
453
576
  ...(cwd.hubMigration === "legacy" ? { migrateFrom: "legacy" } : {}),
577
+ ...(chosen ?? {}),
454
578
  };
455
579
  }
456
580
  if (cwd.git && cwd.githubOwner && cwd.githubRepository) {
457
- if (!observation.advisorVersion) {
458
- return refuse("indeterminate", `cannot read a public ${ADVISOR_PACKAGE} version from the npm registry`);
581
+ const engines = requireEngineVersions(observation);
582
+ if ("action" in engines)
583
+ return engines;
584
+ if (options.repositories !== undefined) {
585
+ const resolved = resolveChosenRepositories(host, cwd.absolutePath, cwd.githubOwner, options.repositories, options.replaceInventory === true);
586
+ if ("action" in resolved)
587
+ return resolved;
588
+ return {
589
+ action: "adopt",
590
+ owner: cwd.githubOwner,
591
+ repository: cwd.githubRepository,
592
+ directory: cwd.absolutePath,
593
+ ...engines,
594
+ chosenInventory: resolved.chosenInventory,
595
+ };
459
596
  }
460
597
  const imported = resolveAdoptInventory(host, cwd, options.inventoryPath);
461
598
  if ("action" in imported)
@@ -465,9 +602,12 @@ export function planWorkspace(observation, host, options = {}) {
465
602
  owner: cwd.githubOwner,
466
603
  repository: cwd.githubRepository,
467
604
  directory: cwd.absolutePath,
468
- advisorVersion: observation.advisorVersion,
605
+ ...engines,
469
606
  ...(imported.inventorySource === undefined ? {} : { inventorySource: imported.inventorySource }),
470
607
  ...(imported.mergedInventoryIds === undefined ? {} : { mergedInventoryIds: imported.mergedInventoryIds }),
608
+ ...(imported.mergedInventoryRepositories === undefined ? {} : { mergedInventoryRepositories: imported.mergedInventoryRepositories }),
609
+ ...(imported.mergedInventoryDocument === undefined ? {} : { mergedInventoryDocument: imported.mergedInventoryDocument }),
610
+ ...(imported.replacesInvalidInventory === undefined ? {} : { replacesInvalidInventory: imported.replacesInvalidInventory }),
471
611
  };
472
612
  }
473
613
  if (cwd.git) {
@@ -476,31 +616,37 @@ export function planWorkspace(observation, host, options = {}) {
476
616
  if (!cwd.empty) {
477
617
  return refuse("violated", "the current directory is not empty and is not a GitHub repository; run from the repo you want to appoint, or from an empty directory");
478
618
  }
619
+ if (options.repositories !== undefined) {
620
+ return refuse("violated", "--repositories writes the inventory of a hub checkout, and this directory is empty; run launcher here first to create or clone the hub, then choose its repositories from inside it");
621
+ }
479
622
  const ownerResult = resolveOwner(observation, host);
480
623
  if ("action" in ownerResult)
481
624
  return ownerResult;
482
- if (observation.remoteDefaultHub && observation.remoteDefaultHub.owner === ownerResult.owner) {
625
+ if (observation.remoteDefaultHub && sameOwner(observation.remoteDefaultHub.owner, ownerResult.owner)) {
626
+ // A clone plan: whether `{owner}/workspace` is already a hub (marked, legacy-marked) or an existing
627
+ // repository to appoint (unmarked) is not known until it is cloned, so applyWorkspacePlan classifies
628
+ // it then (classifyClonedHub, #1585) and may take the adopt path instead of resuming.
483
629
  return {
484
630
  action: "resume",
485
631
  owner: observation.remoteDefaultHub.owner,
486
632
  repository: observation.remoteDefaultHub.repository,
487
633
  directory: cwd.absolutePath,
488
634
  clone: true,
489
- ...(observation.advisorVersion === undefined ? {} : { advisorVersion: observation.advisorVersion }),
635
+ ...observedEngineVersions(observation),
490
636
  };
491
637
  }
492
638
  if (!observation.ghAvailable) {
493
639
  return refuse("indeterminate", "`gh` is required to create a GitHub repository for a new hub");
494
640
  }
495
- if (!observation.advisorVersion) {
496
- return refuse("indeterminate", `cannot read a public ${ADVISOR_PACKAGE} version from the npm registry`);
497
- }
641
+ const engines = requireEngineVersions(observation);
642
+ if ("action" in engines)
643
+ return engines;
498
644
  return {
499
645
  action: "create",
500
646
  owner: ownerResult.owner,
501
647
  repository: DEFAULT_REPOSITORY_NAME,
502
648
  directory: cwd.absolutePath,
503
- advisorVersion: observation.advisorVersion,
649
+ ...engines,
504
650
  };
505
651
  }
506
652
  function containedPath(root, relativePath) {
@@ -515,76 +661,243 @@ function substitute(contents, plan) {
515
661
  return contents
516
662
  .replaceAll("__OWNER__", plan.owner)
517
663
  .replaceAll("__REPOSITORY_NAME__", plan.repository)
518
- .replaceAll("__ADVISOR_VERSION__", plan.advisorVersion ?? "0.0.0");
664
+ .replaceAll("__ADVISOR_VERSION__", plan.advisorVersion ?? "0.0.0")
665
+ .replaceAll("__INTEGRATOR_VERSION__", plan.integratorVersion ?? "0.0.0");
519
666
  }
520
667
  function writeSkeletonFile(host, directory, relativePath, contents) {
521
668
  const target = containedPath(directory, relativePath);
522
669
  host.mkdirp(dirname(target));
523
670
  host.writeText(target, contents);
524
671
  }
672
+ /** Writes exact bytes, for a document copied rather than composed, so nothing is decoded and re-encoded on the way. */
673
+ function writeSkeletonBytes(host, directory, relativePath, contents) {
674
+ const target = containedPath(directory, relativePath);
675
+ host.mkdirp(dirname(target));
676
+ host.writeBytes(target, contents);
677
+ }
678
+ function withFinalNewline(bytes) {
679
+ if (bytes.length > 0 && bytes[bytes.length - 1] === 0x0a)
680
+ return bytes;
681
+ const out = new Uint8Array(bytes.length + 1);
682
+ out.set(bytes);
683
+ out[bytes.length] = 0x0a;
684
+ return out;
685
+ }
525
686
  function pinString(value) {
526
687
  return typeof value === "string" && value.trim() !== "" ? value.trim() : undefined;
527
688
  }
528
- function clossysNames(bucket, extra) {
689
+ /**
690
+ * The hub engine pins in one dependency bucket, by engine. Every other
691
+ * `@clossys/*` name found there is added to `extra`.
692
+ */
693
+ function enginePinsIn(bucket, extra) {
694
+ const pins = {};
529
695
  if (!isRecord(bucket))
530
- return undefined;
531
- let advisor;
696
+ return pins;
532
697
  for (const [name, version] of Object.entries(bucket)) {
533
- if (name === ADVISOR_PACKAGE) {
534
- advisor = pinString(version);
698
+ const engine = HUB_ENGINE_PACKAGES.find((candidate) => candidate === name);
699
+ if (engine !== undefined) {
700
+ const pinned = pinString(version);
701
+ if (pinned !== undefined)
702
+ pins[engine] = pinned;
535
703
  continue;
536
704
  }
537
705
  if (name.startsWith("@clossys/"))
538
706
  extra.add(name);
539
707
  }
540
- return advisor;
708
+ return pins;
709
+ }
710
+ function engineVersionEntries(versions) {
711
+ const entries = [];
712
+ if (versions.advisorVersion !== undefined)
713
+ entries.push([ADVISOR_PACKAGE, versions.advisorVersion]);
714
+ if (versions.integratorVersion !== undefined)
715
+ entries.push([INTEGRATOR_PACKAGE, versions.integratorVersion]);
716
+ return entries;
717
+ }
718
+ /** A `package.json` body parsed to a JSON object, or `undefined` when it is unparseable, an array, or a primitive. */
719
+ function parseManifestObject(raw) {
720
+ try {
721
+ const parsed = JSON.parse(raw);
722
+ return isRecord(parsed) ? parsed : undefined;
723
+ }
724
+ catch {
725
+ return undefined;
726
+ }
727
+ }
728
+ /**
729
+ * The manifest refusals of `mergeHubEnginePins` in "appoint" mode, raised by
730
+ * `adoptHubFiles` before its first write so a refusal leaves the checkout
731
+ * untouched: a `package.json` that is a symbolic link (live or dangling, never
732
+ * resolved, so the write cannot land outside the checkout), a present one that
733
+ * is unreadable or not a JSON object, or a missing one with no skeleton
734
+ * manifest to write in its place.
735
+ */
736
+ function assertManifestAppointable(host, directory, skeletonRoot) {
737
+ const manifestPath = join(directory, "package.json");
738
+ if (host.isSymlink(manifestPath)) {
739
+ throw new Error("existing package.json is a symbolic link; replace it with a regular file before appointing");
740
+ }
741
+ const raw = host.readText(manifestPath);
742
+ if (raw === null) {
743
+ // readText collapses every read error to null, so tell "absent" from
744
+ // "present but unreadable" (a directory, no read permission).
745
+ if (host.exists(manifestPath)) {
746
+ throw new Error("existing package.json is present but cannot be read (is it a directory, or unreadable?)");
747
+ }
748
+ if (host.readText(join(skeletonRoot, "package.json")) === null)
749
+ throw new Error("missing skeleton package.json");
750
+ return;
751
+ }
752
+ if (parseManifestObject(raw) === undefined)
753
+ throw new Error("existing package.json is unreadable JSON");
541
754
  }
542
755
  /**
543
- * Pins live Advisor in `devDependencies` only. Relocates a pin left in any
544
- * other bucket and overwrites a frozen version. Does not touch other
545
- * `@clossys/*` names. A dedicated `{owner}/workspace` hub is named
546
- * `@owner/workspace`.
756
+ * Pins each hub engine (Advisor and Integrator) exactly, in `devDependencies`
757
+ * only, and returns what it changed. It only raises a pin: one older than
758
+ * live, or not a plain version (a range, a tag), becomes the live version;
759
+ * one newer than live is kept as it is. A pin left in any other bucket is
760
+ * moved to `devDependencies`. Other `@clossys/*` names are not touched. The
761
+ * manifest is written as 2-space-indented JSON with a final LF.
762
+ *
763
+ * `appoint` writes the packed skeleton manifest when the hub has none,
764
+ * refuses a manifest that is not a JSON object, names a dedicated
765
+ * `{owner}/workspace` hub `@owner/workspace` only when its manifest has no
766
+ * own `name` (an existing name is never overwritten, #1585), and rewrites
767
+ * the manifest.
768
+ * `resume` changes only the engine pins (never the `name`), writes the
769
+ * manifest only when a pin changed, and leaves a missing or unreadable
770
+ * manifest as it is (the health report then shows the engine pins as
771
+ * missing).
547
772
  */
548
- function mergeAdvisorPin(host, directory, skeletonRoot, advisorVersion, owner, repository) {
773
+ function mergeHubEnginePins(host, directory, skeletonRoot, versions, owner, repository, mode) {
549
774
  const path = join(directory, "package.json");
550
775
  const raw = host.readText(path);
551
776
  if (raw === null) {
777
+ if (mode === "resume")
778
+ return [];
552
779
  const skeleton = host.readText(join(skeletonRoot, "package.json"));
553
780
  if (skeleton === null)
554
781
  throw new Error("missing skeleton package.json");
555
- writeSkeletonFile(host, directory, "package.json", substitute(skeleton, { owner, repository, advisorVersion }));
556
- return;
557
- }
558
- let manifest;
559
- try {
560
- const parsed = JSON.parse(raw);
561
- if (!isRecord(parsed))
562
- throw new Error("package.json is not an object");
563
- manifest = parsed;
782
+ writeSkeletonFile(host, directory, "package.json", substitute(skeleton, { owner, repository, ...versions }));
783
+ return engineVersionEntries(versions).map(([engine, version]) => ({ package: engine, to: version }));
564
784
  }
565
- catch {
785
+ const manifest = parseManifestObject(raw);
786
+ if (manifest === undefined) {
787
+ if (mode === "resume")
788
+ return [];
566
789
  throw new Error("existing package.json is unreadable JSON");
567
790
  }
568
- for (const bucket of DEPENDENCY_BUCKETS) {
569
- if (bucket === "devDependencies")
570
- continue;
571
- const current = manifest[bucket];
572
- if (!isRecord(current) || !(ADVISOR_PACKAGE in current))
573
- continue;
574
- const next = { ...current };
575
- delete next[ADVISOR_PACKAGE];
576
- if (Object.keys(next).length === 0)
577
- delete manifest[bucket];
578
- else
579
- manifest[bucket] = next;
580
- }
581
- const devDependencies = isRecord(manifest.devDependencies) ? { ...manifest.devDependencies } : {};
582
- devDependencies[ADVISOR_PACKAGE] = advisorVersion;
583
- manifest.devDependencies = devDependencies;
584
- if (repository === DEFAULT_REPOSITORY_NAME) {
791
+ const changes = [];
792
+ for (const [engine, live] of engineVersionEntries(versions)) {
793
+ const devPin = isRecord(manifest.devDependencies) ? pinString(manifest.devDependencies[engine]) : undefined;
794
+ let movedFrom;
795
+ let movedPin;
796
+ for (const bucket of DEPENDENCY_BUCKETS) {
797
+ if (bucket === "devDependencies")
798
+ continue;
799
+ const current = manifest[bucket];
800
+ if (!isRecord(current) || !(engine in current))
801
+ continue;
802
+ if (movedFrom === undefined) {
803
+ movedFrom = bucket;
804
+ movedPin = pinString(current[engine]);
805
+ }
806
+ const next = { ...current };
807
+ delete next[engine];
808
+ if (Object.keys(next).length === 0)
809
+ delete manifest[bucket];
810
+ else
811
+ manifest[bucket] = next;
812
+ }
813
+ const existing = devPin ?? movedPin;
814
+ // Only raise: a pin newer than live stays, as the health grader treats it as current.
815
+ const to = existing !== undefined && compareVersions(existing, live) === 1 ? existing : live;
816
+ const devDependencies = isRecord(manifest.devDependencies) ? { ...manifest.devDependencies } : {};
817
+ devDependencies[engine] = to;
818
+ manifest.devDependencies = devDependencies;
819
+ if (to !== devPin || movedFrom !== undefined) {
820
+ changes.push({
821
+ package: engine,
822
+ ...(existing === undefined ? {} : { from: existing }),
823
+ to,
824
+ ...(devPin === undefined && movedFrom !== undefined ? { movedFrom } : {}),
825
+ });
826
+ }
827
+ }
828
+ // Only a nameless manifest is named: an existing `name` is the project's own and is never overwritten (#1585).
829
+ if (mode === "appoint" && repository === DEFAULT_REPOSITORY_NAME && !Object.hasOwn(manifest, "name")) {
585
830
  manifest.name = `@${owner}/${repository}`;
586
831
  }
832
+ if (mode === "resume" && changes.length === 0)
833
+ return changes;
587
834
  host.writeText(path, `${JSON.stringify(manifest, null, 2)}\n`);
835
+ return changes;
836
+ }
837
+ /** Lockfiles Launcher recognises in a hub, with the install command that updates each. */
838
+ const HUB_LOCKFILES = [
839
+ // npm reads npm-shrinkwrap.json instead of package-lock.json when both exist.
840
+ ["npm-shrinkwrap.json", "npm install"],
841
+ ["package-lock.json", "npm install"],
842
+ ["pnpm-lock.yaml", "pnpm install"],
843
+ ["yarn.lock", "yarn install"],
844
+ ["bun.lock", "bun install"],
845
+ ["bun.lockb", "bun install"],
846
+ ];
847
+ function describeEnginePinChange(change) {
848
+ const moved = change.movedFrom === undefined ? "" : ` (moved from ${change.movedFrom} to devDependencies)`;
849
+ if (change.from === undefined)
850
+ return `${change.package} added at ${change.to}${moved}`;
851
+ if (change.from === change.to)
852
+ return `${change.package} ${change.to}${moved}`;
853
+ return `${change.package} ${change.from} -> ${change.to}${moved}`;
854
+ }
855
+ /** The one next step after a run changed engine pins: install with the hub's package manager, then commit the manifest with its lockfile. */
856
+ function enginePinNextStep(host, directory) {
857
+ const lockfile = HUB_LOCKFILES.find(([name]) => host.exists(join(directory, name)));
858
+ return lockfile === undefined
859
+ ? "run your package manager's install in the hub, then commit package.json together with the lockfile it writes"
860
+ : `run \`${lockfile[1]}\` in the hub, then commit package.json together with ${lockfile[0]}`;
861
+ }
862
+ /**
863
+ * Whether the hub's lockfile resolves its engine pins. An npm lockfile is
864
+ * read: each engine pinned to a plain version in `devDependencies` must
865
+ * resolve to that version. Any other lockfile is not read, so it counts as
866
+ * not resolving the engines this run changed. No lockfile, no finding.
867
+ */
868
+ function engineInstallFinding(host, directory, changes) {
869
+ const found = HUB_LOCKFILES.find(([name]) => host.exists(join(directory, name)));
870
+ if (found === undefined)
871
+ return undefined;
872
+ const [lockfile, command] = found;
873
+ const changed = [...new Set(changes.map((change) => change.package))];
874
+ let packages = changed;
875
+ if (lockfile === "package-lock.json" || lockfile === "npm-shrinkwrap.json") {
876
+ const lock = readJson(host, join(directory, lockfile));
877
+ const manifest = readJson(host, join(directory, "package.json"));
878
+ const devDependencies = isRecord(manifest) && isRecord(manifest.devDependencies) ? manifest.devDependencies : {};
879
+ if (isRecord(lock)) {
880
+ packages = HUB_ENGINE_PACKAGES.filter((engine) => {
881
+ const pinned = pinString(devDependencies[engine]);
882
+ if (pinned === undefined || compareVersions(pinned, pinned) === null)
883
+ return false;
884
+ const lockPackages = isRecord(lock.packages) ? lock.packages : {};
885
+ const lockDependencies = isRecord(lock.dependencies) ? lock.dependencies : {};
886
+ const entry = lockPackages[`node_modules/${engine}`] ?? lockDependencies[engine];
887
+ const locked = isRecord(entry) ? pinString(entry.version) : undefined;
888
+ return locked === undefined || compareVersions(locked, pinned) !== 0;
889
+ });
890
+ }
891
+ }
892
+ if (packages.length === 0)
893
+ return undefined;
894
+ return {
895
+ kind: "engine-pins-changed-install-needed",
896
+ lockfile,
897
+ command,
898
+ packages,
899
+ note: `${lockfile} does not resolve the pinned ${packages.join(" and ")} yet; run \`${command}\` in the hub, then commit package.json together with ${lockfile}`,
900
+ };
588
901
  }
589
902
  function copySkeleton(host, skeletonRoot, plan) {
590
903
  for (const relativePath of SKELETON_FILES) {
@@ -616,8 +929,59 @@ function assertCleanTree(host, directory) {
616
929
  throw new Error(`${where} has uncommitted changes (git status --porcelain is nonempty); commit or stash them before appointing it as the account hub`);
617
930
  }
618
931
  }
932
+ /**
933
+ * An inventory document Launcher composed (from a choice, or a merge),
934
+ * checked again at write time by the same function every later read of it
935
+ * uses, so a document Launcher writes is always one Launcher reads back.
936
+ */
937
+ function revalidatedDocument(document, hubOwner, label = "the chosen inventory") {
938
+ const validated = validateInventoryDocument(document, { hubOwner });
939
+ if (!validated.valid)
940
+ throw new Error(`${label} ${validated.reason}`);
941
+ return document;
942
+ }
619
943
  function adoptHubFiles(host, skeletonRoot, plan) {
620
944
  assertCleanTree(host, plan.directory);
945
+ // Resolve and strictly re-validate the inventory document BEFORE writing
946
+ // anything, including the hub marker -- planWorkspace already validated it
947
+ // once, but re-checking here (rather than trusting the earlier result)
948
+ // means a document that changed on disk between plan and apply still
949
+ // cannot land a mismatched shape, and it means this function alone
950
+ // guarantees "fail before any file is touched" (#1334).
951
+ let inventoryDocument;
952
+ if (plan.action === "adopt" && plan.chosenInventory !== undefined) {
953
+ if (plan.chosenInventory.kind === "write")
954
+ inventoryDocument = revalidatedDocument(plan.chosenInventory.document, plan.owner);
955
+ }
956
+ else if (plan.action === "adopt" && plan.mergedInventoryDocument !== undefined) {
957
+ inventoryDocument = revalidatedDocument(plan.mergedInventoryDocument, plan.owner, "the merged inventory");
958
+ }
959
+ else if (plan.action === "adopt" && plan.mergedInventoryRepositories !== undefined) {
960
+ // A plan carrying the merged entries without the rendered document: each entry is written whole, `packages`
961
+ // included, in the same layout renderInventoryDocument() writes; the contract check decides whether it is valid.
962
+ const repositories = plan.mergedInventoryRepositories;
963
+ inventoryDocument = revalidatedDocument(`${JSON.stringify({ schemaVersion: 1, repositories }, null, 2)}\n`, plan.owner, "the merged inventory");
964
+ }
965
+ else if ("mergedInventoryIds" in plan && Array.isArray(plan.mergedInventoryIds)) {
966
+ // A plan built by hand with ids only: there are no entries to keep, so each id is written alone.
967
+ inventoryDocument = revalidatedDocument(renderInventoryDocument(plan.mergedInventoryIds.map((id) => ({ id }))), plan.owner, "the merged inventory");
968
+ }
969
+ else if ("inventorySource" in plan && typeof plan.inventorySource === "string") {
970
+ const raw = host.readBytes(plan.inventorySource);
971
+ if (raw === null)
972
+ throw new Error(`inventory source is not readable: ${plan.inventorySource}`);
973
+ const validated = validateInventoryDocument(raw, { hubOwner: plan.owner });
974
+ if (!validated.valid)
975
+ throw new Error(`inventory source at ${plan.inventorySource} ${validated.reason}`);
976
+ if (validated.ids.length === 0) {
977
+ throw new Error(`inventory source at ${plan.inventorySource} must be a populated inventory document`);
978
+ }
979
+ // Copied byte for byte: the bytes just validated are the bytes written.
980
+ inventoryDocument = withFinalNewline(raw);
981
+ }
982
+ // The manifest is read and checked here too, for the same reason: mergeHubEnginePins refuses it only after
983
+ // the marker and the composed files are written, which would leave a half-appointed checkout.
984
+ assertManifestAppointable(host, plan.directory, skeletonRoot);
621
985
  const marker = {
622
986
  schemaVersion: 1,
623
987
  kind: "account-hub",
@@ -625,17 +989,10 @@ function adoptHubFiles(host, skeletonRoot, plan) {
625
989
  repository: `${plan.owner}/${plan.repository}`,
626
990
  };
627
991
  writeSkeletonFile(host, plan.directory, WORKSPACE_MARKER_REL, `${JSON.stringify(marker, null, 2)}\n`);
628
- if ("mergedInventoryIds" in plan && Array.isArray(plan.mergedInventoryIds)) {
629
- const document = { schemaVersion: 1, repositories: plan.mergedInventoryIds.map((id) => ({ id })) };
630
- writeSkeletonFile(host, plan.directory, WORKSPACE_INVENTORY_REL, `${JSON.stringify(document, null, 2)}\n`);
631
- }
632
- else if ("inventorySource" in plan && typeof plan.inventorySource === "string") {
633
- const raw = host.readText(plan.inventorySource);
634
- if (raw === null || inspectInventory(raw).status !== "populated") {
635
- throw new Error("inventory source is not a populated inventory document");
636
- }
637
- writeSkeletonFile(host, plan.directory, WORKSPACE_INVENTORY_REL, raw.endsWith("\n") ? raw : `${raw}\n`);
638
- }
992
+ if (typeof inventoryDocument === "string")
993
+ writeSkeletonFile(host, plan.directory, WORKSPACE_INVENTORY_REL, inventoryDocument);
994
+ else if (inventoryDocument !== undefined)
995
+ writeSkeletonBytes(host, plan.directory, WORKSPACE_INVENTORY_REL, inventoryDocument);
639
996
  if (host.readText(join(plan.directory, "AGENTS.md")) === null) {
640
997
  writeSkeletonFile(host, plan.directory, "AGENTS.md", CONSUMER_AGENTS_MD);
641
998
  }
@@ -655,7 +1012,7 @@ function adoptHubFiles(host, skeletonRoot, plan) {
655
1012
  if (ignore !== null)
656
1013
  writeSkeletonFile(host, plan.directory, ".gitignore", ignore);
657
1014
  }
658
- mergeAdvisorPin(host, plan.directory, skeletonRoot, plan.advisorVersion, plan.owner, plan.repository);
1015
+ return mergeHubEnginePins(host, plan.directory, skeletonRoot, { advisorVersion: plan.advisorVersion, integratorVersion: plan.integratorVersion }, plan.owner, plan.repository, "appoint");
659
1016
  }
660
1017
  function requireZero(result, label) {
661
1018
  if (result.status !== 0) {
@@ -664,22 +1021,30 @@ function requireZero(result, label) {
664
1021
  }
665
1022
  /**
666
1023
  * Read-only pin and inventory report. Does not install or uninstall. Scans all
667
- * four dependency buckets; grades each pinned advisor version against the live
668
- * registry version, marking a pin older than live as a stale-pin finding and a
669
- * degraded report.
1024
+ * four dependency buckets for both hub engines (Advisor and Integrator);
1025
+ * grades each pinned version against that engine's live registry version,
1026
+ * when known, marking a pin older than live as a stale-pin finding and a
1027
+ * degraded report. `liveIntegratorVersion` is the last parameter so earlier
1028
+ * callers keep their argument positions.
670
1029
  */
671
- export function reportHubHealth(host, directory, liveAdvisorVersion, liveLauncherVersion, retiredThisRun = [], migration) {
1030
+ export function reportHubHealth(host, directory, liveAdvisorVersion, liveLauncherVersion, retiredThisRun = [], migration, liveIntegratorVersion) {
672
1031
  const extra = new Set();
673
- const pins = {};
1032
+ const pins = {
1033
+ [ADVISOR_PACKAGE]: {},
1034
+ [INTEGRATOR_PACKAGE]: {},
1035
+ };
674
1036
  const manifestRaw = host.readText(join(directory, "package.json"));
675
1037
  if (manifestRaw !== null) {
676
1038
  try {
677
1039
  const parsed = JSON.parse(manifestRaw);
678
1040
  if (isRecord(parsed)) {
679
1041
  for (const bucket of DEPENDENCY_BUCKETS) {
680
- const pin = clossysNames(parsed[bucket], extra);
681
- if (pin !== undefined)
682
- pins[bucket] = pin;
1042
+ const found = enginePinsIn(parsed[bucket], extra);
1043
+ for (const engine of HUB_ENGINE_PACKAGES) {
1044
+ const pinned = found[engine];
1045
+ if (pinned !== undefined)
1046
+ pins[engine][bucket] = pinned;
1047
+ }
683
1048
  }
684
1049
  }
685
1050
  }
@@ -687,71 +1052,114 @@ export function reportHubHealth(host, directory, liveAdvisorVersion, liveLaunche
687
1052
  /* unreadable manifest is reported as missing pins */
688
1053
  }
689
1054
  }
690
- const pinFindings = Object.entries(pins).flatMap(([bucket, pinned]) => {
691
- if (liveAdvisorVersion === undefined)
1055
+ const live = {
1056
+ [ADVISOR_PACKAGE]: liveAdvisorVersion,
1057
+ [INTEGRATOR_PACKAGE]: liveIntegratorVersion,
1058
+ };
1059
+ const pinFindings = HUB_ENGINE_PACKAGES.flatMap((engine) => DEPENDENCY_BUCKETS.flatMap((bucket) => {
1060
+ const pinned = pins[engine][bucket];
1061
+ const liveVersion = live[engine];
1062
+ if (pinned === undefined || liveVersion === undefined)
692
1063
  return [];
693
- const comparison = compareVersions(pinned, liveAdvisorVersion);
1064
+ const comparison = compareVersions(pinned, liveVersion);
694
1065
  if (comparison === null) {
695
- return [{ bucket: bucket, pinned, grade: "indeterminate", note: `cannot compare ${pinned} with live ${liveAdvisorVersion}` }];
1066
+ return [{ package: engine, bucket, pinned, grade: "indeterminate", note: `cannot compare ${pinned} with live ${liveVersion}` }];
696
1067
  }
697
1068
  return comparison < 0
698
- ? [{ bucket: bucket, pinned, grade: "stale", note: `pinned ${pinned} is older than live ${liveAdvisorVersion}` }]
1069
+ ? [{ package: engine, bucket, pinned, grade: "stale", note: `pinned ${pinned} is older than live ${liveVersion}` }]
699
1070
  : [];
700
- });
1071
+ }));
1072
+ const pinCount = (engine) => Object.values(pins[engine]).filter((value) => value !== undefined).length;
1073
+ const misplaced = (engine) => pins[engine].devDependencies === undefined ||
1074
+ pins[engine].dependencies !== undefined ||
1075
+ pins[engine].optionalDependencies !== undefined ||
1076
+ pins[engine].peerDependencies !== undefined;
1077
+ const dualPin = HUB_ENGINE_PACKAGES.some((engine) => pinCount(engine) > 1);
701
1078
  const skillsManifest = summarizeSkillsManifest(parseSkillManifest(host.readText(join(directory, SKILLS_MANIFEST_REL))), liveLauncherVersion, retiredThisRun);
1079
+ const enginePin = (engine) => {
1080
+ const found = pins[engine];
1081
+ const liveVersion = live[engine];
1082
+ return {
1083
+ ...(found.dependencies === undefined ? {} : { dependencies: found.dependencies }),
1084
+ ...(found.devDependencies === undefined ? {} : { devDependencies: found.devDependencies }),
1085
+ ...(found.optionalDependencies === undefined ? {} : { optionalDependencies: found.optionalDependencies }),
1086
+ ...(found.peerDependencies === undefined ? {} : { peerDependencies: found.peerDependencies }),
1087
+ ...(liveVersion === undefined ? {} : { live: liveVersion }),
1088
+ };
1089
+ };
1090
+ const inventory = inspectInventory(host.readBytes(join(directory, WORKSPACE_INVENTORY_REL)), readHub(host, directory)?.owner);
702
1091
  return {
703
1092
  marker: readHub(host, directory) === undefined ? "missing" : "present",
704
- inventory: inspectInventory(host.readText(join(directory, WORKSPACE_INVENTORY_REL))),
705
- advisorPin: {
706
- ...(pins.dependencies === undefined ? {} : { dependencies: pins.dependencies }),
707
- ...(pins.devDependencies === undefined ? {} : { devDependencies: pins.devDependencies }),
708
- ...(pins.optionalDependencies === undefined ? {} : { optionalDependencies: pins.optionalDependencies }),
709
- ...(pins.peerDependencies === undefined ? {} : { peerDependencies: pins.peerDependencies }),
710
- ...(liveAdvisorVersion === undefined ? {} : { live: liveAdvisorVersion }),
711
- },
712
- dualPin: Object.values(pins).filter((value) => value !== undefined).length > 1,
1093
+ inventory,
1094
+ advisorPin: enginePin(ADVISOR_PACKAGE),
1095
+ integratorPin: enginePin(INTEGRATOR_PACKAGE),
1096
+ dualPin,
713
1097
  extraClossys: [...extra].sort(),
714
1098
  pinFindings,
715
1099
  degraded: pinFindings.some((finding) => finding.grade === "stale") ||
716
- Object.values(pins).filter((value) => value !== undefined).length > 1 ||
717
- pins.devDependencies === undefined ||
718
- pins.dependencies !== undefined ||
719
- pins.optionalDependencies !== undefined ||
720
- pins.peerDependencies !== undefined,
1100
+ dualPin ||
1101
+ HUB_ENGINE_PACKAGES.some(misplaced) ||
1102
+ inventory.status === "invalid",
721
1103
  ...(migration === undefined ? {} : { migration }),
722
1104
  skillsManifest,
723
1105
  };
724
1106
  }
725
- export function formatHubHealth(report) {
1107
+ function formatEnginePin(pin) {
726
1108
  const pinParts = [];
727
1109
  for (const bucket of DEPENDENCY_BUCKETS) {
728
- const pinned = report.advisorPin[bucket];
1110
+ const pinned = pin[bucket];
729
1111
  if (pinned !== undefined)
730
1112
  pinParts.push(`${bucket} ${pinned}`);
731
1113
  }
732
- const pin = pinParts.length === 0 ? "missing" : pinParts.join(" and ");
733
- const live = report.advisorPin.live === undefined ? "" : `; live ${report.advisorPin.live}`;
1114
+ const pinned = pinParts.length === 0 ? "missing" : pinParts.join(" and ");
1115
+ return `${pinned}${pin.live === undefined ? "" : `; live ${pin.live}`}`;
1116
+ }
1117
+ export function formatHubHealth(report) {
734
1118
  const extra = report.extraClossys.length === 0 ? "none" : report.extraClossys.join(", ");
735
- const inventory = report.inventory.status === "populated" ? `populated (${report.inventory.count})` : report.inventory.status;
736
- const findings = report.pinFindings.map((finding) => finding.note !== undefined ? `${finding.bucket} ${finding.note}` : `${finding.bucket} ${finding.grade}`);
1119
+ const inventory = report.inventory.status === "populated"
1120
+ ? `populated (${report.inventory.count})`
1121
+ : report.inventory.status === "invalid"
1122
+ ? `invalid${report.inventory.reason === undefined ? "" : ` -- ${report.inventory.reason}`}`
1123
+ : report.inventory.status;
1124
+ const findings = report.pinFindings.map((finding) => finding.note !== undefined
1125
+ ? `${finding.package} ${finding.bucket} ${finding.note}`
1126
+ : `${finding.package} ${finding.bucket} ${finding.grade}`);
737
1127
  const findingLine = findings.length === 0 ? "none" : findings.join("; ");
1128
+ const enginePinLines = report.enginePins === undefined
1129
+ ? []
1130
+ : [
1131
+ `engine pins changed in package.json: ${report.enginePins.changed.map(describeEnginePinChange).join("; ")}`,
1132
+ `next: ${report.enginePins.nextStep}`,
1133
+ ];
1134
+ const installLine = report.installNeeded === undefined ? [] : [`install needed (${report.installNeeded.kind}): ${report.installNeeded.note}`];
738
1135
  const skillParts = [];
739
1136
  if (report.skillComposition !== undefined) {
740
1137
  skillParts.push(report.skillComposition.composed.length === 0
741
1138
  ? "skills composed: none"
742
1139
  : `skills composed: ${report.skillComposition.composed.map((name) => `clossys-${name}`).join(", ")}`);
743
1140
  for (const skip of report.skillComposition.skipped) {
1141
+ // skip.packageDir names one of this package's own known package directories, never document text (see skills.ts).
744
1142
  skillParts.push(`skill skipped (${skip.packageDir}): ${skip.note}`);
745
1143
  }
1144
+ // rosterTargets and siblings' inventoryId are already position-safe by
1145
+ // the time they reach here: composeSkillRoster redacts every
1146
+ // stored-inventory id to `inventoryPositionLabel()`'s position label
1147
+ // before putting it in this report, because this whole report is also
1148
+ // JSON-dumped into the `health:` line below -- an id left raw in the
1149
+ // structured report would still reach the message that way even if this
1150
+ // prose line named it safely.
746
1151
  if (report.skillComposition.rosterTargets !== undefined && report.skillComposition.rosterTargets.length > 0) {
747
1152
  skillParts.push(`skill roster written: ${report.skillComposition.rosterTargets.join(", ")}`);
748
1153
  }
749
- for (const skip of report.skillComposition.rosterSkipped ?? []) {
750
- skillParts.push(`skill roster skipped (${skip.inventoryId}): ${skip.note}`);
1154
+ for (const sibling of report.skillComposition.siblings ?? []) {
1155
+ skillParts.push(`sibling (${sibling.inventoryId}): ${sibling.note}`);
751
1156
  }
752
1157
  if (report.skillComposition.retired !== undefined && report.skillComposition.retired.length > 0) {
753
1158
  skillParts.push(`skills retired: ${report.skillComposition.retired.map((name) => `clossys-${name}`).join(", ")}`);
754
1159
  }
1160
+ for (const kept of report.skillComposition.preserved ?? []) {
1161
+ skillParts.push(`skill preserved (clossys-${kept.packageDir}, not ${kept.action === "rewrite" ? "rewritten" : "retired"}): ${kept.note}`);
1162
+ }
755
1163
  }
756
1164
  const skillsManifestLine = report.skillsManifest === undefined
757
1165
  ? undefined
@@ -766,14 +1174,17 @@ export function formatHubHealth(report) {
766
1174
  ? undefined
767
1175
  : report.inventoryDrift.status === "indeterminate"
768
1176
  ? `inventory drift: indeterminate${report.inventoryDrift.note === undefined ? "" : ` -- ${report.inventoryDrift.note}`}`
769
- : `inventory drift: external-only ${report.inventoryDrift.externalOnly.length}, launcher-only ${report.inventoryDrift.launcherOnly.length}, agreeing ${report.inventoryDrift.agreeing.length}`;
1177
+ : `inventory drift: external-only ${report.inventoryDrift.externalOnly.count}, launcher-only ${report.inventoryDrift.launcherOnly.count}, agreeing ${report.inventoryDrift.agreeing.count}`;
770
1178
  return [
771
1179
  `hub marker: ${report.marker}`,
772
1180
  `inventory: ${inventory}`,
773
- `advisor pin: ${pin}${live}`,
1181
+ `advisor pin: ${formatEnginePin(report.advisorPin)}`,
1182
+ `integrator pin: ${formatEnginePin(report.integratorPin)}`,
774
1183
  `dual pin: ${report.dualPin ? "yes" : "no"}`,
775
1184
  `extra @clossys/*: ${extra}`,
776
1185
  `pin findings: ${findingLine}`,
1186
+ ...enginePinLines,
1187
+ ...installLine,
777
1188
  `degraded: ${report.degraded ? "yes" : "no"}`,
778
1189
  ...(migrationLine === undefined ? [] : [migrationLine]),
779
1190
  ...(linkedHostsLine === undefined ? [] : [linkedHostsLine]),
@@ -783,15 +1194,20 @@ export function formatHubHealth(report) {
783
1194
  `health: ${JSON.stringify(report)}`,
784
1195
  ].join("\n");
785
1196
  }
786
- function withHealth(host, directory, headline, liveAdvisorVersion, skillComposition, liveLauncherVersion, migration, inventoryDrift) {
787
- const base = reportHubHealth(host, directory, liveAdvisorVersion, liveLauncherVersion, skillComposition?.retired ?? [], migration);
788
- const rosterSkipped = skillComposition?.rosterSkipped ?? [];
1197
+ function withHealth(host, directory, headline, liveEngines, skillComposition, liveLauncherVersion, migration, inventoryDrift, enginePinChanges = []) {
1198
+ const base = reportHubHealth(host, directory, liveEngines.advisorVersion, liveLauncherVersion, skillComposition?.retired ?? [], migration, liveEngines.integratorVersion);
1199
+ const preserved = skillComposition?.preserved ?? [];
1200
+ const installNeeded = engineInstallFinding(host, directory, enginePinChanges);
789
1201
  const health = {
790
1202
  ...base,
1203
+ ...(enginePinChanges.length === 0
1204
+ ? {}
1205
+ : { enginePins: { changed: enginePinChanges, nextStep: enginePinNextStep(host, directory) } }),
1206
+ ...(installNeeded === undefined ? {} : { installNeeded }),
791
1207
  ...(skillComposition === undefined ? {} : { skillComposition }),
792
1208
  ...(skillComposition?.linkedHosts === undefined ? {} : { linkedHosts: skillComposition.linkedHosts }),
793
1209
  ...(inventoryDrift === undefined || inventoryDrift.status === "no-external-source" ? {} : { inventoryDrift }),
794
- degraded: base.degraded || rosterSkipped.length > 0,
1210
+ degraded: base.degraded || preserved.length > 0 || installNeeded !== undefined,
795
1211
  };
796
1212
  return {
797
1213
  state: "satisfied",
@@ -805,8 +1221,8 @@ function withHealth(host, directory, headline, liveAdvisorVersion, skillComposit
805
1221
  * a note; marks ids whose check fails as unknown. Never mutates the inventory.
806
1222
  */
807
1223
  export function checkInventoryEntries(host, directory) {
808
- const raw = host.readText(join(directory, WORKSPACE_INVENTORY_REL));
809
- const observation = inspectInventory(raw);
1224
+ const hubOwner = readHub(host, directory)?.owner;
1225
+ const observation = inspectInventory(host.readBytes(join(directory, WORKSPACE_INVENTORY_REL)), hubOwner);
810
1226
  if (observation.status !== "populated") {
811
1227
  return { entries: [], skipped: true, note: `inventory is ${observation.status}; nothing to validate` };
812
1228
  }
@@ -814,7 +1230,7 @@ export function checkInventoryEntries(host, directory) {
814
1230
  if (!available) {
815
1231
  return { entries: [], skipped: true, note: "`gh` is unavailable; inventory ids were not validated" };
816
1232
  }
817
- const ids = readInventoryRepositories(host, join(directory, WORKSPACE_INVENTORY_REL), "the hub inventory").filter((id) => id !== "");
1233
+ const ids = readInventoryRepositories(host, join(directory, WORKSPACE_INVENTORY_REL), "the hub inventory", hubOwner).filter((id) => id !== "");
818
1234
  const batch = 20;
819
1235
  const entries = [];
820
1236
  for (let index = 0; index < ids.length; index += batch) {
@@ -840,6 +1256,8 @@ function shouldRefreshConsumerAgents(existing) {
840
1256
  return false;
841
1257
  if (existing === LEGACY_CONSUMER_AGENTS_MD)
842
1258
  return true;
1259
+ if (existing === SIBLING_COMPOSING_CONSUMER_AGENTS_MD)
1260
+ return true;
843
1261
  if (existing.includes("Run `npx @clossys/launcher` again to resume"))
844
1262
  return true;
845
1263
  return false;
@@ -850,111 +1268,163 @@ function writeConsumerAgentsIfNeeded(host, directory) {
850
1268
  return;
851
1269
  writeSkeletonFile(host, directory, "AGENTS.md", CONSUMER_AGENTS_MD);
852
1270
  }
853
- function writeSisterConsumerAgentsIfNeeded(host, directory) {
854
- const existing = host.readText(join(directory, "AGENTS.md"));
855
- if (existing !== null && existing.trim() !== "" && existing !== SISTER_CONSUMER_AGENTS_MD)
856
- return;
857
- writeSkeletonFile(host, directory, "AGENTS.md", SISTER_CONSUMER_AGENTS_MD);
858
- }
859
- const CLONE_NOT_BESIDE_HUB_NOTE = "clone not next to the hub; voices appear here after this repository is cloned beside the hub and launcher resumes";
1271
+ /** What a hub run reports for each inventoried repository other than the hub. It never writes into one. */
1272
+ const SETUP_PULL_REQUEST_NOTE = "a hub run writes nothing here; once this repository is staffed in an approved plan, @clossys-advisor and the voices of the roles staffed there arrive with that plan's setup pull request";
1273
+ const BESIDE_HUB_NOTE = `checkout beside the hub; ${SETUP_PULL_REQUEST_NOTE}`;
1274
+ const CLONE_NOT_BESIDE_HUB_NOTE = `not cloned beside the hub; ${SETUP_PULL_REQUEST_NOTE}`;
1275
+ /**
1276
+ * Splits an inventory id into owner/repository, trusting a caller that
1277
+ * already validated it with `isValidInventoryId` (every id reaching here
1278
+ * comes from a document `validateInventoryDocument` already accepted)
1279
+ * rather than re-deriving that rule -- but still runs it, so a caller that
1280
+ * somehow supplies an unvalidated id gets `null`, never a wrong split.
1281
+ */
860
1282
  function parseInventoryRepositoryId(id, hubOwner) {
861
- const trimmed = id.trim();
862
- if (trimmed === "")
1283
+ if (!isValidInventoryId(id))
863
1284
  return null;
864
- if (trimmed.includes("/")) {
865
- const slash = trimmed.indexOf("/");
866
- const owner = trimmed.slice(0, slash);
867
- const repository = trimmed.slice(slash + 1);
868
- if (!OWNER.test(owner) || !REPO.test(repository))
869
- return null;
870
- return { owner, repository };
871
- }
872
- if (!REPO.test(trimmed))
873
- return null;
874
- return { owner: hubOwner, repository: trimmed };
1285
+ const slash = id.indexOf("/");
1286
+ if (slash === -1)
1287
+ return { owner: hubOwner, repository: id };
1288
+ return { owner: id.slice(0, slash), repository: id.slice(slash + 1) };
1289
+ }
1290
+ /** A checkout's origin as `owner/name`, when it is a github.com repository. */
1291
+ function originRepository(host, directory) {
1292
+ const result = host.run("git", ["remote", "get-url", "origin"], { cwd: directory });
1293
+ const remote = result.status === 0 ? parseGitHubRemote(result.stdout.trim()) : null;
1294
+ return remote === null ? undefined : `${remote.owner}/${remote.repository}`;
875
1295
  }
876
- function resolveSisterCloneTargets(host, hubDirectory, hubOwner) {
1296
+ /**
1297
+ * The hub's own repository identity: its origin's `owner/name`, or, when
1298
+ * the checkout has no github.com origin, the repository its hub marker
1299
+ * records. Never its folder path (see identity.ts).
1300
+ */
1301
+ function hubRepositoryIdentity(host, hubDirectory) {
1302
+ return originRepository(host, hubDirectory) ?? readHub(host, hubDirectory)?.repository;
1303
+ }
1304
+ /**
1305
+ * Classifies every inventoried repository other than the hub. Read-only:
1306
+ * it looks for a checkout beside the hub and reads that checkout's git
1307
+ * origin, and nothing else, so a sibling's working tree, legacy output or
1308
+ * old pins can neither change the result nor be changed by it. The hub run
1309
+ * reports the result; only `--clone-missing` acts on it, and only on
1310
+ * `not-cloned` entries.
1311
+ */
1312
+ function classifyInventoriedSiblings(host, hubDirectory, hubOwner) {
877
1313
  const parent = dirname(resolve(hubDirectory));
878
- const hubResolved = resolve(hubDirectory);
879
- const skipped = [];
880
- const targets = [];
1314
+ const hubIdentity = hubRepositoryIdentity(host, hubDirectory);
1315
+ const siblings = [];
881
1316
  const inventoryPath = join(hubDirectory, WORKSPACE_INVENTORY_REL);
882
1317
  let inventoryIds;
883
1318
  try {
884
- inventoryIds = readInventoryRepositories(host, inventoryPath, "the hub inventory");
885
- }
886
- catch {
887
- return { targets: [], skipped: [] };
888
- }
889
- for (const id of inventoryIds) {
890
- if (id === "")
891
- continue;
1319
+ inventoryIds = readInventoryRepositories(host, inventoryPath, "the stored inventory", hubOwner);
1320
+ }
1321
+ catch (error) {
1322
+ // An invalid stored inventory is reported, never silently read for what
1323
+ // it happens to look like -- no clone is attempted from it (#1334).
1324
+ // WORKSPACE_INVENTORY_REL is Launcher's own constant path, not document
1325
+ // content, so it is safe to name directly; there is no per-id position
1326
+ // for a failure at the file level, so `position` stays undefined.
1327
+ const reason = error instanceof Error ? error.message : String(error);
1328
+ return [{ inventoryId: WORKSPACE_INVENTORY_REL, status: "inventory-invalid", note: reason }];
1329
+ }
1330
+ for (const [position, id] of inventoryIds.entries()) {
892
1331
  const parsed = parseInventoryRepositoryId(id, hubOwner);
893
1332
  if (parsed === null) {
894
- skipped.push({ inventoryId: id, note: "inventory id is not a valid repository slug" });
1333
+ siblings.push({ inventoryId: id, position, status: "invalid-id", note: "inventory id is not a valid repository slug" });
895
1334
  continue;
896
1335
  }
897
- if (parsed.owner !== hubOwner) {
898
- skipped.push({ inventoryId: id, note: "other account; not this roster" });
1336
+ if (!belongsToOwner(id, hubOwner)) {
1337
+ siblings.push({ inventoryId: id, position, status: "other-account", note: "other account; not this roster" });
899
1338
  continue;
900
1339
  }
901
- const candidate = join(parent, parsed.repository);
902
- const candidateResolved = resolve(candidate);
903
- if (candidateResolved === hubResolved)
1340
+ // The hub itself is recognised by repository identity, not by folder path.
1341
+ if (hubIdentity !== undefined && sameRepository(id, hubIdentity, hubOwner))
904
1342
  continue;
1343
+ const candidate = join(parent, parsed.repository);
905
1344
  if (!host.exists(candidate) || !host.isDirectory(candidate)) {
906
- skipped.push({ inventoryId: id, note: CLONE_NOT_BESIDE_HUB_NOTE });
1345
+ siblings.push({ inventoryId: id, position, status: "not-cloned", note: CLONE_NOT_BESIDE_HUB_NOTE });
907
1346
  continue;
908
1347
  }
909
1348
  if (looksLikeFoundry(host, candidate)) {
910
- skipped.push({ inventoryId: id, note: "foundry supplier tree; skills are not written here" });
1349
+ siblings.push({ inventoryId: id, position, status: "foundry-supplier-tree", note: "foundry supplier tree; skills are not written here" });
911
1350
  continue;
912
1351
  }
913
- const originResult = host.run("git", ["remote", "get-url", "origin"], { cwd: candidate });
914
- const originUrl = originResult.status === 0 ? originResult.stdout.trim() : "";
915
- const remote = originUrl === "" ? null : parseGitHubRemote(originUrl);
916
- if (remote === null || remote.owner !== parsed.owner || remote.repository !== parsed.repository) {
917
- skipped.push({ inventoryId: id, note: "git origin does not match inventory id" });
1352
+ if (!host.exists(join(candidate, ".git"))) {
1353
+ siblings.push({
1354
+ inventoryId: id,
1355
+ position,
1356
+ status: "not-a-git-checkout",
1357
+ note: "the folder beside the hub with this name is not a git checkout, so it cannot be matched to this inventory id",
1358
+ });
918
1359
  continue;
919
1360
  }
920
- targets.push({ inventoryId: id, directory: candidate });
1361
+ const remote = host.run("git", ["remote", "get-url", "origin"], { cwd: candidate });
1362
+ if (remote.status !== 0 && /dubious ownership/i.test(remote.stderr)) {
1363
+ siblings.push({
1364
+ inventoryId: id,
1365
+ position,
1366
+ status: "git-refused",
1367
+ note: "git refuses to read this checkout (it reports dubious ownership), so its origin could not be matched to this inventory id",
1368
+ });
1369
+ continue;
1370
+ }
1371
+ const parsedOrigin = remote.status === 0 ? parseGitHubRemote(remote.stdout.trim()) : null;
1372
+ const origin = parsedOrigin === null ? undefined : `${parsedOrigin.owner}/${parsedOrigin.repository}`;
1373
+ if (origin === undefined || !sameRepository(origin, id, hubOwner)) {
1374
+ siblings.push({ inventoryId: id, position, status: "origin-mismatch", note: "git origin does not match inventory id" });
1375
+ continue;
1376
+ }
1377
+ siblings.push({ inventoryId: id, position, status: "beside-the-hub", note: BESIDE_HUB_NOTE });
921
1378
  }
922
- return { targets, skipped };
1379
+ return siblings;
1380
+ }
1381
+ /** The position a message names instead of a document-sourced inventory id, matching `inventory-choice.ts`'s `listRemovedPositions`. `undefined` only for the stored-inventory-file-itself failure, which names the file by its own constant path instead. */
1382
+ export function inventoryPositionLabel(inventoryId, position) {
1383
+ return position === undefined ? inventoryId : `repositories[${position}] in the stored inventory`;
923
1384
  }
924
1385
  /**
925
1386
  * Explicit, approved action (#1179, the #1045 pattern): clones every
926
- * inventoried repository that resolveSisterCloneTargets's own skip pass
927
- * identified as "just needs a clone" (CLONE_NOT_BESIDE_HUB_NOTE), and only
928
- * those -- every other skip reason (wrong account, foundry supplier tree,
929
- * origin mismatch, invalid slug) is left exactly as skipped, never
930
- * attempted. Never called from resume's default path; only from the
931
- * --clone-missing flag. Reverses the launcher README's own no-clone
932
- * default for exactly this one approved action.
1387
+ * inventoried repository that classifyInventoriedSiblings found not cloned
1388
+ * beside the hub, and only those -- every other reason (wrong account,
1389
+ * foundry supplier tree, origin mismatch, invalid slug, invalid inventory)
1390
+ * is reported as skipped, never attempted, and a checkout already beside
1391
+ * the hub is left out. Never called from resume's default path; only from
1392
+ * the --clone-missing flag. Reverses the launcher README's own no-clone
1393
+ * default for exactly this one approved action. Cloning is not composing:
1394
+ * a cloned repository receives its team only once it is staffed in an
1395
+ * approved plan, with that plan's setup pull request.
933
1396
  */
934
1397
  export function cloneMissingInventoryRepositories(host, hubDirectory, hubOwner) {
935
- const { skipped } = resolveSisterCloneTargets(host, hubDirectory, hubOwner);
936
1398
  const parent = dirname(resolve(hubDirectory));
937
1399
  const outcomes = [];
938
- for (const skip of skipped) {
939
- if (skip.note !== CLONE_NOT_BESIDE_HUB_NOTE) {
940
- outcomes.push({ inventoryId: skip.inventoryId, result: "skipped-other-reason", note: skip.note });
1400
+ for (const skip of classifyInventoriedSiblings(host, hubDirectory, hubOwner)) {
1401
+ if (skip.status === "beside-the-hub")
1402
+ continue;
1403
+ if (skip.status !== "not-cloned") {
1404
+ outcomes.push({ inventoryId: skip.inventoryId, position: skip.position, result: "skipped-other-reason", note: skip.note });
941
1405
  continue;
942
1406
  }
943
1407
  const parsed = parseInventoryRepositoryId(skip.inventoryId, hubOwner);
944
1408
  if (parsed === null) {
945
- outcomes.push({ inventoryId: skip.inventoryId, result: "failed", note: "inventory id is not a valid repository slug" });
1409
+ outcomes.push({ inventoryId: skip.inventoryId, position: skip.position, result: "failed", note: "inventory id is not a valid repository slug" });
946
1410
  continue;
947
1411
  }
1412
+ // Cloning itself still uses the raw id and the real path -- that is the whole
1413
+ // point of this action -- but the *reported* note never repeats either: the
1414
+ // folder name comes straight from the inventory (a document), and `gh`'s own
1415
+ // stderr on failure names the repository it could not find or clone. Both are
1416
+ // fixed text instead (#1179); `position` is still how this outcome is named.
948
1417
  const siblingPath = join(parent, parsed.repository);
949
1418
  const result = host.run("gh", ["repo", "clone", `${hubOwner}/${parsed.repository}`, siblingPath]);
950
1419
  if (result.status === 0) {
951
- outcomes.push({ inventoryId: skip.inventoryId, result: "cloned", note: `cloned to ${siblingPath}` });
1420
+ outcomes.push({ inventoryId: skip.inventoryId, position: skip.position, result: "cloned", note: "cloned beside the hub" });
952
1421
  }
953
1422
  else {
954
1423
  outcomes.push({
955
1424
  inventoryId: skip.inventoryId,
1425
+ position: skip.position,
956
1426
  result: "failed",
957
- note: `gh repo clone exited ${result.status ?? "null"}: ${result.stderr.trim() || "no stderr"}`,
1427
+ note: `gh repo clone exited ${result.status ?? "null"}`,
958
1428
  });
959
1429
  }
960
1430
  }
@@ -1021,6 +1491,16 @@ function recordLinkedHosts(host, directory) {
1021
1491
  writeSkeletonFile(host, directory, HOSTS_REL, serializeHostRecord({ schemaVersion: 1, linkedHosts, recordedAt: host.now() }));
1022
1492
  return linkedHosts;
1023
1493
  }
1494
+ /**
1495
+ * Composes the team in the hub, and only the hub: this writes nothing into
1496
+ * any checkout beside the hub. An inventoried product repository receives
1497
+ * its skills, skills manifest, host discovery links and guidance only once
1498
+ * it is staffed in an approved plan, with that plan's setup pull request. Each inventoried
1499
+ * repository other than the hub is reported, read-only, as a sibling, named
1500
+ * by its `inventoryPositionLabel()` position -- never its raw stored
1501
+ * inventory id -- because the whole report is JSON-dumped into the apply
1502
+ * message's `health:` line (see `formatHubHealth`).
1503
+ */
1024
1504
  function composeSkillRoster(host, hubDirectory, hubOwner, hubRepository, options) {
1025
1505
  const composeOptions = {
1026
1506
  launcherPackageRoot: options.launcherPackageRoot,
@@ -1032,17 +1512,13 @@ function composeSkillRoster(host, hubDirectory, hubOwner, hubRepository, options
1032
1512
  writeConsumerAgentsIfNeeded(host, hubDirectory);
1033
1513
  writeClossysReadme(host, hubDirectory);
1034
1514
  const hubId = hubRosterId(host, hubDirectory, hubOwner, hubRepository);
1035
- const rosterTargets = [hubId];
1036
- const { targets, skipped } = resolveSisterCloneTargets(host, hubDirectory, hubOwner);
1037
- for (const target of targets) {
1038
- recordLinkedHosts(host, target.directory);
1039
- composeSkills(host, target.directory, composeOptions);
1040
- writeSisterConsumerAgentsIfNeeded(host, target.directory);
1041
- rosterTargets.push(target.inventoryId);
1042
- }
1043
- return { ...hubSkill, rosterTargets, rosterSkipped: skipped, linkedHosts };
1515
+ const siblings = classifyInventoriedSiblings(host, hubDirectory, hubOwner)
1516
+ // An invalid stored inventory is a hub finding: the health report's inventory line names it.
1517
+ .filter((sibling) => sibling.status !== "inventory-invalid")
1518
+ .map(({ inventoryId, position, note }) => ({ inventoryId: inventoryPositionLabel(inventoryId, position), note }));
1519
+ return { ...hubSkill, rosterTargets: [hubId], siblings, linkedHosts };
1044
1520
  }
1045
- function finishHubApply(host, directory, headline, launcherPackageRoot, hubOwner, hubRepository, liveAdvisorVersion, skillCatalogueRoot, contractPath, liveLauncherVersion, migration) {
1521
+ function finishHubApply(host, directory, headline, launcherPackageRoot, hubOwner, hubRepository, liveEngines, skillCatalogueRoot, contractPath, liveLauncherVersion, migration, enginePinChanges = []) {
1046
1522
  const skillComposition = composeSkillRoster(host, directory, hubOwner, hubRepository, {
1047
1523
  launcherPackageRoot,
1048
1524
  ...(skillCatalogueRoot === undefined ? {} : { skillCatalogueRoot }),
@@ -1051,49 +1527,148 @@ function finishHubApply(host, directory, headline, launcherPackageRoot, hubOwner
1051
1527
  // #1216: when the hub marker declares an external inventory, report drift against
1052
1528
  // it on every apply (create's fresh marker never declares one, so this is a no-op there).
1053
1529
  const hubDocument = readHub(host, directory);
1054
- const inventoryDrift = reportInventoryDrift(host, directory, hubDocument?.externalInventory, WORKSPACE_INVENTORY_REL);
1055
- return withHealth(host, directory, headline, liveAdvisorVersion, skillComposition, liveLauncherVersion, migration, inventoryDrift);
1530
+ const inventoryDrift = reportInventoryDrift(host, directory, hubDocument?.externalInventory, WORKSPACE_INVENTORY_REL, hubOwner);
1531
+ return withHealth(host, directory, headline, liveEngines, skillComposition, liveLauncherVersion, migration, inventoryDrift, enginePinChanges);
1056
1532
  }
1057
1533
  /**
1058
1534
  * Migrates a legacy `.clossys/` hub marker (and its sibling inventory, when
1059
1535
  * present) to `clossys/.state/`, then removes the old directory. Called only
1060
1536
  * when `locateHub` found the marker at the legacy path and nowhere else
1061
- * (`plan.migrateFrom === "legacy"`); a hub with markers at both paths is
1062
- * refused by `planWorkspace` before apply ever runs, so this never merges
1063
- * two hub states.
1537
+ * (`plan.migrateFrom === "legacy"`, or `classifyClonedHub` for a fresh
1538
+ * clone); a hub with markers at both paths is refused by `planWorkspace`
1539
+ * (or, for a clone, by `classifyClonedHub`) before anything is written, so
1540
+ * this never merges two hub states.
1064
1541
  */
1065
1542
  function migrateLegacyHubState(host, directory) {
1066
1543
  const markerRaw = host.readText(join(directory, LEGACY_WORKSPACE_MARKER_REL));
1067
1544
  if (markerRaw === null)
1068
1545
  return undefined;
1069
1546
  writeSkeletonFile(host, directory, WORKSPACE_MARKER_REL, markerRaw.endsWith("\n") ? markerRaw : `${markerRaw}\n`);
1070
- const inventoryRaw = host.readText(join(directory, LEGACY_WORKSPACE_INVENTORY_REL));
1071
- if (inventoryRaw !== null) {
1072
- writeSkeletonFile(host, directory, WORKSPACE_INVENTORY_REL, inventoryRaw.endsWith("\n") ? inventoryRaw : `${inventoryRaw}\n`);
1073
- }
1547
+ // Moved byte for byte, so bytes that are not valid UTF-8 are still refused when read from the new place.
1548
+ const inventoryBytes = host.readBytes(join(directory, LEGACY_WORKSPACE_INVENTORY_REL));
1549
+ if (inventoryBytes !== null)
1550
+ writeSkeletonBytes(host, directory, WORKSPACE_INVENTORY_REL, withFinalNewline(inventoryBytes));
1074
1551
  host.remove(join(directory, LEGACY_STATE_DIR_REL));
1075
1552
  return { status: "migrated", from: LEGACY_STATE_DIR_REL, to: STATE_DIR_REL };
1076
1553
  }
1077
- /** Applies a create, resume, or adopt plan through the host. Resume refreshes composed skills and stale AGENTS.md guidance. */
1554
+ /** The apply message's line saying what `--repositories` did to the inventory, or nothing without it. */
1555
+ function chosenInventoryNote(chosen) {
1556
+ return chosen === undefined ? "" : `\n${describeChosenInventory(chosen)}`;
1557
+ }
1558
+ function engineVersionsOf(plan) {
1559
+ return {
1560
+ ...(plan.advisorVersion === undefined ? {} : { advisorVersion: plan.advisorVersion }),
1561
+ ...(plan.integratorVersion === undefined ? {} : { integratorVersion: plan.integratorVersion }),
1562
+ };
1563
+ }
1564
+ /**
1565
+ * Classifies a freshly cloned `{owner}/workspace` (a resume plan with
1566
+ * `clone: true`) by its marker, the way `planWorkspace` classifies a local
1567
+ * checkout, and returns how apply must continue (#1585). It only reads: every
1568
+ * refusal it decides is thrown here, before apply writes anything into the
1569
+ * clone, and the appoint path's own refusals (a `package.json` that is not a
1570
+ * JSON object among them) are thrown by `adoptHubFiles` before its first write.
1571
+ *
1572
+ * - both markers: refused, as `planWorkspace` refuses them locally;
1573
+ * - a marker (current or legacy) for another owner: refused;
1574
+ * - only the legacy marker: resume, migrating it;
1575
+ * - only the current marker: resume;
1576
+ * - no marker: an existing repository with unrelated content is being
1577
+ * appointed, so apply takes the adopt path, which keeps its files. That
1578
+ * path needs both engine versions, refuses the Foundry supplier tree, and
1579
+ * refuses an on-disk inventory that fails its contract (a missing or valid
1580
+ * one is kept as it is; this path writes no inventory), and refuses a
1581
+ * `package.json` that is not a JSON object (checked by `adoptHubFiles`).
1582
+ */
1583
+ function classifyClonedHub(host, plan) {
1584
+ const located = locateHub(host, plan.directory);
1585
+ if (located.migration === "indeterminate") {
1586
+ throw new Error(`in the cloned ${plan.owner}/${plan.repository}, both ${WORKSPACE_MARKER_REL} and the legacy ${LEGACY_WORKSPACE_MARKER_REL} are present; launcher never merges them silently -- remove one before resuming`);
1587
+ }
1588
+ if (located.document !== undefined) {
1589
+ if (!sameOwner(located.document.owner, plan.owner)) {
1590
+ throw new Error(`the cloned ${plan.owner}/${plan.repository} carries a hub marker owned by "${located.document.owner}", not "${plan.owner}"; refusing to resume a hub for the wrong account`);
1591
+ }
1592
+ return located.migration === "legacy" ? { kind: "resume", migrateFrom: "legacy" } : { kind: "resume" };
1593
+ }
1594
+ if (!plan.advisorVersion) {
1595
+ throw new Error(`cannot read a public ${ADVISOR_PACKAGE} version from the npm registry; appointing the cloned ${plan.owner}/${plan.repository} pins it`);
1596
+ }
1597
+ if (!plan.integratorVersion) {
1598
+ throw new Error(`cannot read a public ${INTEGRATOR_PACKAGE} version from the npm registry; appointing the cloned ${plan.owner}/${plan.repository} pins it`);
1599
+ }
1600
+ if (looksLikeFoundry(host, plan.directory)) {
1601
+ throw new Error(`refusing to appoint the cloned ${plan.owner}/${plan.repository}: it looks like the Foundry supplier tree`);
1602
+ }
1603
+ const inventory = inspectInventory(host.readBytes(join(plan.directory, WORKSPACE_INVENTORY_REL)), plan.owner);
1604
+ if (inventory.status === "invalid") {
1605
+ throw new Error(`the cloned ${plan.owner}/${plan.repository} has an on-disk hub inventory that ${inventory.reason ?? "does not conform to the inventory contract"}; fix or remove ${WORKSPACE_INVENTORY_REL} in that repository, then run launcher from inside the checkout`);
1606
+ }
1607
+ return {
1608
+ kind: "appoint",
1609
+ plan: {
1610
+ action: "adopt",
1611
+ owner: plan.owner,
1612
+ repository: plan.repository,
1613
+ directory: plan.directory,
1614
+ advisorVersion: plan.advisorVersion,
1615
+ integratorVersion: plan.integratorVersion,
1616
+ },
1617
+ };
1618
+ }
1619
+ /**
1620
+ * Applies a create, resume, or adopt plan through the host. Resume refreshes
1621
+ * composed skills and stale AGENTS.md guidance. A resume plan that clones
1622
+ * (`clone: true`, planned from an empty directory) is classified only after
1623
+ * the clone, by `classifyClonedHub`: a marked clone resumes (migrating a
1624
+ * legacy marker), an unmarked one is appointed through the adopt path, and
1625
+ * every refusal Launcher decides happens before anything is written into the clone (#1585).
1626
+ * Every path writes only into the hub checkout, never into an inventoried
1627
+ * repository beside it.
1628
+ */
1078
1629
  export function applyWorkspacePlan(host, plan, skeletonRoot, options = {}) {
1079
1630
  const launcherPackageRoot = options.launcherPackageRoot ?? resolve(skeletonRoot, "..");
1080
1631
  const skillCatalogueRoot = options.skillCatalogueRoot;
1081
1632
  const contractPath = options.contractPath;
1082
1633
  const liveLauncherVersion = options.liveLauncherVersion;
1083
1634
  if (plan.action === "resume") {
1635
+ let migrateFrom = plan.migrateFrom;
1084
1636
  if (plan.clone) {
1085
1637
  requireZero(host.run("gh", ["repo", "clone", `${plan.owner}/${plan.repository}`, plan.directory]), "gh repo clone");
1638
+ const cloned = classifyClonedHub(host, plan);
1639
+ if (cloned.kind === "appoint")
1640
+ return applyAdoptPlan(host, cloned.plan, skeletonRoot, options);
1641
+ migrateFrom = cloned.migrateFrom;
1086
1642
  }
1087
- const migration = plan.migrateFrom === "legacy" ? migrateLegacyHubState(host, plan.directory) : undefined;
1088
- return finishHubApply(host, plan.directory, `resumed ${plan.owner}/${plan.repository} as the account hub\nOpen this folder in your coding agent. Advisor stays read-only until you approve a next action.`, launcherPackageRoot, plan.owner, plan.repository, plan.advisorVersion, skillCatalogueRoot, contractPath, liveLauncherVersion, migration);
1643
+ const migration = migrateFrom === "legacy" ? migrateLegacyHubState(host, plan.directory) : undefined;
1644
+ // --repositories (#1179): write the chosen inventory before composing, so
1645
+ // this same run's health report lists the repositories just chosen.
1646
+ if (plan.chosenInventory?.kind === "write") {
1647
+ writeSkeletonFile(host, plan.directory, WORKSPACE_INVENTORY_REL, revalidatedDocument(plan.chosenInventory.document, plan.owner));
1648
+ }
1649
+ // Resume raises the hub's engine pins to live: a frozen Advisor is
1650
+ // bumped and Integrator is added, for each engine whose live version
1651
+ // observeWorkspace could read. The health report says what changed and
1652
+ // that the install, and a commit with the lockfile, come next.
1653
+ const enginePinChanges = mergeHubEnginePins(host, plan.directory, skeletonRoot, engineVersionsOf(plan), plan.owner, plan.repository, "resume");
1654
+ return finishHubApply(host, plan.directory, `resumed ${plan.owner}/${plan.repository} as the account hub\nOpen this folder in your coding agent. Advisor stays read-only until you approve a next action.${chosenInventoryNote(plan.chosenInventory)}`, launcherPackageRoot, plan.owner, plan.repository, engineVersionsOf(plan), skillCatalogueRoot, contractPath, liveLauncherVersion, migration, enginePinChanges);
1089
1655
  }
1090
1656
  if (plan.action === "create") {
1091
1657
  copySkeleton(host, skeletonRoot, plan);
1092
1658
  requireZero(host.run("gh", ["repo", "create", `${plan.owner}/${plan.repository}`, "--private", "--source", plan.directory, "--remote", "origin", "--push"], { cwd: plan.directory }), "gh repo create");
1093
- return finishHubApply(host, plan.directory, `created ${plan.owner}/${plan.repository} as the account hub\nOpen this folder in your coding agent. Advisor stays read-only until you approve a next action.`, launcherPackageRoot, plan.owner, plan.repository, plan.advisorVersion, skillCatalogueRoot, contractPath, liveLauncherVersion);
1659
+ return finishHubApply(host, plan.directory, `created ${plan.owner}/${plan.repository} as the account hub\nOpen this folder in your coding agent. Advisor stays read-only until you approve a next action.`, launcherPackageRoot, plan.owner, plan.repository, engineVersionsOf(plan), skillCatalogueRoot, contractPath, liveLauncherVersion);
1094
1660
  }
1095
- adoptHubFiles(host, skeletonRoot, plan);
1096
- return finishHubApply(host, plan.directory, `appointed ${plan.owner}/${plan.repository} as the account hub\nExisting project files were kept. This hub inventories engagement; it does not install the catalogue into the repo.`, launcherPackageRoot, plan.owner, plan.repository, plan.advisorVersion, skillCatalogueRoot, contractPath, liveLauncherVersion);
1661
+ return applyAdoptPlan(host, plan, skeletonRoot, options);
1662
+ }
1663
+ /** Appoints an existing checkout as the hub, keeping its files: the adopt plan's apply, also reached from an unmarked clone (#1585). */
1664
+ function applyAdoptPlan(host, plan, skeletonRoot, options) {
1665
+ const launcherPackageRoot = options.launcherPackageRoot ?? resolve(skeletonRoot, "..");
1666
+ const { skillCatalogueRoot, contractPath, liveLauncherVersion } = options;
1667
+ const enginePinChanges = adoptHubFiles(host, skeletonRoot, plan);
1668
+ const inventoryReplacedNote = plan.replacesInvalidInventory === true
1669
+ ? " The on-disk inventory failed schema validation; --inventory replaced it."
1670
+ : "";
1671
+ return finishHubApply(host, plan.directory, `appointed ${plan.owner}/${plan.repository} as the account hub\nExisting project files were kept. This hub inventories engagement; it does not install the catalogue into the repo.${inventoryReplacedNote}${chosenInventoryNote(plan.chosenInventory)}`, launcherPackageRoot, plan.owner, plan.repository, engineVersionsOf(plan), skillCatalogueRoot, contractPath, liveLauncherVersion, undefined, enginePinChanges);
1097
1672
  }
1098
1673
  export function launcherPackageRootFromModule(moduleUrl) {
1099
1674
  return resolve(dirname(fileURLToPath(moduleUrl)), "..");