@clossys/launcher 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (285) hide show
  1. package/README.md +1324 -60
  2. package/contracts/conversation-contract.md +2 -2
  3. package/contracts/product-ci-workflow.yml +74 -0
  4. package/contracts/repository-inventory.json +53 -0
  5. package/dist/admission-fixture.d.ts +168 -0
  6. package/dist/admission-fixture.d.ts.map +1 -0
  7. package/dist/admission-fixture.js +453 -0
  8. package/dist/admission-fixture.js.map +1 -0
  9. package/dist/admission.d.ts +124 -0
  10. package/dist/admission.d.ts.map +1 -0
  11. package/dist/admission.js +799 -0
  12. package/dist/admission.js.map +1 -0
  13. package/dist/agents-guide.d.ts +9 -0
  14. package/dist/agents-guide.d.ts.map +1 -0
  15. package/dist/agents-guide.js +26 -0
  16. package/dist/agents-guide.js.map +1 -0
  17. package/dist/apply-command-options.check.d.ts +12 -0
  18. package/dist/apply-command-options.check.d.ts.map +1 -0
  19. package/dist/apply-command-options.check.js +20 -0
  20. package/dist/apply-command-options.check.js.map +1 -0
  21. package/dist/apply-plan-cli.d.ts +39 -1
  22. package/dist/apply-plan-cli.d.ts.map +1 -1
  23. package/dist/apply-plan-cli.js +432 -15
  24. package/dist/apply-plan-cli.js.map +1 -1
  25. package/dist/apply-plan.d.ts +46 -59
  26. package/dist/apply-plan.d.ts.map +1 -1
  27. package/dist/apply-plan.js +112 -97
  28. package/dist/apply-plan.js.map +1 -1
  29. package/dist/apply-step-fixture.d.ts +87 -0
  30. package/dist/apply-step-fixture.d.ts.map +1 -0
  31. package/dist/apply-step-fixture.js +199 -0
  32. package/dist/apply-step-fixture.js.map +1 -0
  33. package/dist/apply-store.d.ts +93 -0
  34. package/dist/apply-store.d.ts.map +1 -0
  35. package/dist/apply-store.js +625 -0
  36. package/dist/apply-store.js.map +1 -0
  37. package/dist/approval-sheet.d.ts +21 -0
  38. package/dist/approval-sheet.d.ts.map +1 -0
  39. package/dist/approval-sheet.js +157 -0
  40. package/dist/approval-sheet.js.map +1 -0
  41. package/dist/body-command.d.ts +42 -0
  42. package/dist/body-command.d.ts.map +1 -0
  43. package/dist/body-command.js +143 -0
  44. package/dist/body-command.js.map +1 -0
  45. package/dist/change-set-contract.d.ts +381 -0
  46. package/dist/change-set-contract.d.ts.map +1 -0
  47. package/dist/change-set-contract.js +738 -0
  48. package/dist/change-set-contract.js.map +1 -0
  49. package/dist/change-set-digest.d.ts +28 -0
  50. package/dist/change-set-digest.d.ts.map +1 -0
  51. package/dist/change-set-digest.js +65 -0
  52. package/dist/change-set-digest.js.map +1 -0
  53. package/dist/check-cli.d.ts.map +1 -1
  54. package/dist/check-cli.js +14 -3
  55. package/dist/check-cli.js.map +1 -1
  56. package/dist/cli.d.ts +17 -6
  57. package/dist/cli.d.ts.map +1 -1
  58. package/dist/cli.js +84 -23
  59. package/dist/cli.js.map +1 -1
  60. package/dist/core.d.ts +79 -22
  61. package/dist/core.d.ts.map +1 -1
  62. package/dist/core.js +843 -268
  63. package/dist/core.js.map +1 -1
  64. package/dist/dry-materialize.d.ts +63 -0
  65. package/dist/dry-materialize.d.ts.map +1 -0
  66. package/dist/dry-materialize.js +330 -0
  67. package/dist/dry-materialize.js.map +1 -0
  68. package/dist/generated/contract-schema.generated.d.ts +97 -0
  69. package/dist/generated/contract-schema.generated.d.ts.map +1 -0
  70. package/dist/generated/contract-schema.generated.js +496 -0
  71. package/dist/generated/contract-schema.generated.js.map +1 -0
  72. package/dist/generated/package-scope.generated.d.ts +6 -0
  73. package/dist/generated/package-scope.generated.d.ts.map +1 -0
  74. package/dist/generated/package-scope.generated.js +10 -0
  75. package/dist/generated/package-scope.generated.js.map +1 -0
  76. package/dist/generated/plan-contracts.generated.d.ts +3 -0
  77. package/dist/generated/plan-contracts.generated.d.ts.map +1 -0
  78. package/dist/generated/plan-contracts.generated.js +2840 -0
  79. package/dist/generated/plan-contracts.generated.js.map +1 -0
  80. package/dist/host.d.ts.map +1 -1
  81. package/dist/host.js +11 -0
  82. package/dist/host.js.map +1 -1
  83. package/dist/identity.d.ts +15 -0
  84. package/dist/identity.d.ts.map +1 -0
  85. package/dist/identity.js +48 -0
  86. package/dist/identity.js.map +1 -0
  87. package/dist/index.d.ts +34 -5
  88. package/dist/index.d.ts.map +1 -1
  89. package/dist/index.js +19 -2
  90. package/dist/index.js.map +1 -1
  91. package/dist/inventory-adoption.d.ts +24 -5
  92. package/dist/inventory-adoption.d.ts.map +1 -1
  93. package/dist/inventory-adoption.js +70 -25
  94. package/dist/inventory-adoption.js.map +1 -1
  95. package/dist/inventory-choice.d.ts +40 -0
  96. package/dist/inventory-choice.d.ts.map +1 -0
  97. package/dist/inventory-choice.js +156 -0
  98. package/dist/inventory-choice.js.map +1 -0
  99. package/dist/inventory-contract.d.ts +89 -0
  100. package/dist/inventory-contract.d.ts.map +1 -0
  101. package/dist/inventory-contract.js +121 -0
  102. package/dist/inventory-contract.js.map +1 -0
  103. package/dist/key-editor.d.ts +30 -0
  104. package/dist/key-editor.d.ts.map +1 -0
  105. package/dist/key-editor.js +445 -0
  106. package/dist/key-editor.js.map +1 -0
  107. package/dist/ledger-contract.d.ts +187 -0
  108. package/dist/ledger-contract.d.ts.map +1 -0
  109. package/dist/ledger-contract.js +532 -0
  110. package/dist/ledger-contract.js.map +1 -0
  111. package/dist/ledger-trust.d.ts +90 -0
  112. package/dist/ledger-trust.d.ts.map +1 -0
  113. package/dist/ledger-trust.js +198 -0
  114. package/dist/ledger-trust.js.map +1 -0
  115. package/dist/lockfile-invariants.d.ts +48 -0
  116. package/dist/lockfile-invariants.d.ts.map +1 -0
  117. package/dist/lockfile-invariants.js +375 -0
  118. package/dist/lockfile-invariants.js.map +1 -0
  119. package/dist/lockfile-readers.d.ts +72 -0
  120. package/dist/lockfile-readers.d.ts.map +1 -0
  121. package/dist/lockfile-readers.js +713 -0
  122. package/dist/lockfile-readers.js.map +1 -0
  123. package/dist/lockfile-regen.d.ts +106 -0
  124. package/dist/lockfile-regen.d.ts.map +1 -0
  125. package/dist/lockfile-regen.js +760 -0
  126. package/dist/lockfile-regen.js.map +1 -0
  127. package/dist/lockfile-tool-env.d.ts +29 -0
  128. package/dist/lockfile-tool-env.d.ts.map +1 -0
  129. package/dist/lockfile-tool-env.js +111 -0
  130. package/dist/lockfile-tool-env.js.map +1 -0
  131. package/dist/materialize.d.ts +113 -0
  132. package/dist/materialize.d.ts.map +1 -0
  133. package/dist/materialize.js +840 -0
  134. package/dist/materialize.js.map +1 -0
  135. package/dist/observe-repository.d.ts +90 -0
  136. package/dist/observe-repository.d.ts.map +1 -0
  137. package/dist/observe-repository.js +1367 -0
  138. package/dist/observe-repository.js.map +1 -0
  139. package/dist/plan-bundle-setup-fixture.d.ts +68 -0
  140. package/dist/plan-bundle-setup-fixture.d.ts.map +1 -0
  141. package/dist/plan-bundle-setup-fixture.js +167 -0
  142. package/dist/plan-bundle-setup-fixture.js.map +1 -0
  143. package/dist/plan-bundle.d.ts +250 -0
  144. package/dist/plan-bundle.d.ts.map +1 -0
  145. package/dist/plan-bundle.js +827 -0
  146. package/dist/plan-bundle.js.map +1 -0
  147. package/dist/plan-command.d.ts +29 -0
  148. package/dist/plan-command.d.ts.map +1 -0
  149. package/dist/plan-command.js +493 -0
  150. package/dist/plan-command.js.map +1 -0
  151. package/dist/plan-contract.d.ts +153 -0
  152. package/dist/plan-contract.d.ts.map +1 -0
  153. package/dist/plan-contract.js +61 -0
  154. package/dist/plan-contract.js.map +1 -0
  155. package/dist/plan-digest.d.ts +25 -0
  156. package/dist/plan-digest.d.ts.map +1 -0
  157. package/dist/plan-digest.js +106 -0
  158. package/dist/plan-digest.js.map +1 -0
  159. package/dist/plan-rules.d.ts +23 -0
  160. package/dist/plan-rules.d.ts.map +1 -0
  161. package/dist/plan-rules.js +177 -0
  162. package/dist/plan-rules.js.map +1 -0
  163. package/dist/planned-bundle.d.ts +20 -0
  164. package/dist/planned-bundle.d.ts.map +1 -0
  165. package/dist/planned-bundle.js +191 -0
  166. package/dist/planned-bundle.js.map +1 -0
  167. package/dist/product-repository.d.ts +4 -0
  168. package/dist/product-repository.d.ts.map +1 -1
  169. package/dist/product-repository.js +9 -1
  170. package/dist/product-repository.js.map +1 -1
  171. package/dist/provenance-gate.d.ts +48 -0
  172. package/dist/provenance-gate.d.ts.map +1 -0
  173. package/dist/provenance-gate.js +324 -0
  174. package/dist/provenance-gate.js.map +1 -0
  175. package/dist/pull-request-body.d.ts +45 -0
  176. package/dist/pull-request-body.d.ts.map +1 -0
  177. package/dist/pull-request-body.js +232 -0
  178. package/dist/pull-request-body.js.map +1 -0
  179. package/dist/registry-snapshot.d.ts +141 -0
  180. package/dist/registry-snapshot.d.ts.map +1 -0
  181. package/dist/registry-snapshot.js +483 -0
  182. package/dist/registry-snapshot.js.map +1 -0
  183. package/dist/release-age-edit.d.ts +52 -0
  184. package/dist/release-age-edit.d.ts.map +1 -0
  185. package/dist/release-age-edit.js +413 -0
  186. package/dist/release-age-edit.js.map +1 -0
  187. package/dist/root-entries.d.ts +36 -0
  188. package/dist/root-entries.d.ts.map +1 -0
  189. package/dist/root-entries.js +80 -0
  190. package/dist/root-entries.js.map +1 -0
  191. package/dist/setup-template-scripts.d.ts +34 -0
  192. package/dist/setup-template-scripts.d.ts.map +1 -0
  193. package/dist/setup-template-scripts.js +557 -0
  194. package/dist/setup-template-scripts.js.map +1 -0
  195. package/dist/setup-templates.d.ts +54 -0
  196. package/dist/setup-templates.d.ts.map +1 -0
  197. package/dist/setup-templates.js +427 -0
  198. package/dist/setup-templates.js.map +1 -0
  199. package/dist/skills.d.ts +34 -1
  200. package/dist/skills.d.ts.map +1 -1
  201. package/dist/skills.js +129 -17
  202. package/dist/skills.js.map +1 -1
  203. package/dist/status.d.ts +63 -0
  204. package/dist/status.d.ts.map +1 -0
  205. package/dist/status.js +539 -0
  206. package/dist/status.js.map +1 -0
  207. package/dist/types.d.ts +151 -13
  208. package/dist/types.d.ts.map +1 -1
  209. package/package.json +4 -4
  210. package/skeleton/README.md +14 -9
  211. package/skeleton/package.json +2 -1
  212. package/skill/SKILL.md +23 -7
  213. package/skill-catalogue/advisor/SKILL.md +59 -6
  214. package/skill-catalogue/architect/SKILL.md +2 -2
  215. package/skill-catalogue/bouncer/SKILL.md +2 -2
  216. package/skill-catalogue/builder/SKILL.md +2 -2
  217. package/skill-catalogue/butler/SKILL.md +2 -2
  218. package/skill-catalogue/controller/SKILL.md +2 -2
  219. package/skill-catalogue/customer/SKILL.md +2 -2
  220. package/skill-catalogue/designer/SKILL.md +4 -2
  221. package/skill-catalogue/giver/SKILL.md +2 -2
  222. package/skill-catalogue/influencer/SKILL.md +2 -2
  223. package/skill-catalogue/inspector/SKILL.md +2 -2
  224. package/skill-catalogue/integrator/SKILL.md +2 -2
  225. package/skill-catalogue/keeper/SKILL.md +2 -2
  226. package/skill-catalogue/launcher/SKILL.md +23 -7
  227. package/skill-catalogue/locksmith/SKILL.md +2 -2
  228. package/skill-catalogue/messenger/SKILL.md +2 -2
  229. package/skill-catalogue/observer/SKILL.md +2 -2
  230. package/skill-catalogue/publisher/SKILL.md +2 -2
  231. package/skill-catalogue/starter/SKILL.md +3 -2
  232. package/skill-catalogue/strategist/SKILL.md +12 -4
  233. package/skill-catalogue/writer/SKILL.md +2 -2
  234. package/src/admission-fixture.ts +572 -0
  235. package/src/admission.ts +816 -0
  236. package/src/agents-guide.ts +29 -0
  237. package/src/apply-command-options.check.ts +27 -0
  238. package/src/apply-plan-cli.ts +454 -14
  239. package/src/apply-plan.ts +112 -124
  240. package/src/apply-step-fixture.ts +236 -0
  241. package/src/apply-store.ts +584 -0
  242. package/src/approval-sheet.ts +164 -0
  243. package/src/body-command.ts +162 -0
  244. package/src/change-set-contract.ts +937 -0
  245. package/src/change-set-digest.ts +70 -0
  246. package/src/check-cli.ts +14 -3
  247. package/src/cli.ts +90 -22
  248. package/src/core.ts +973 -275
  249. package/src/dry-materialize.ts +353 -0
  250. package/src/generated/contract-schema.generated.ts +520 -0
  251. package/src/generated/package-scope.generated.ts +10 -0
  252. package/src/generated/plan-contracts.generated.ts +2840 -0
  253. package/src/host.ts +10 -0
  254. package/src/identity.ts +51 -0
  255. package/src/index.ts +72 -3
  256. package/src/inventory-adoption.ts +107 -29
  257. package/src/inventory-choice.ts +172 -0
  258. package/src/inventory-contract.ts +166 -0
  259. package/src/key-editor.ts +446 -0
  260. package/src/ledger-contract.ts +637 -0
  261. package/src/ledger-trust.ts +267 -0
  262. package/src/lockfile-invariants.ts +421 -0
  263. package/src/lockfile-readers.ts +749 -0
  264. package/src/lockfile-regen.ts +851 -0
  265. package/src/lockfile-tool-env.ts +131 -0
  266. package/src/materialize.ts +886 -0
  267. package/src/observe-repository.ts +1365 -0
  268. package/src/plan-bundle-setup-fixture.ts +200 -0
  269. package/src/plan-bundle.ts +964 -0
  270. package/src/plan-command.ts +509 -0
  271. package/src/plan-contract.ts +179 -0
  272. package/src/plan-digest.ts +102 -0
  273. package/src/plan-rules.ts +188 -0
  274. package/src/planned-bundle.ts +211 -0
  275. package/src/product-repository.ts +10 -1
  276. package/src/provenance-gate.ts +352 -0
  277. package/src/pull-request-body.ts +261 -0
  278. package/src/registry-snapshot.ts +534 -0
  279. package/src/release-age-edit.ts +430 -0
  280. package/src/root-entries.ts +81 -0
  281. package/src/setup-template-scripts.ts +571 -0
  282. package/src/setup-templates.ts +471 -0
  283. package/src/skills.ts +161 -18
  284. package/src/status.ts +557 -0
  285. package/src/types.ts +148 -13
package/src/core.ts CHANGED
@@ -2,10 +2,14 @@ import { dirname, join, resolve, sep } from "node:path";
2
2
  import { fileURLToPath } from "node:url";
3
3
  import type {
4
4
  ApplyWorkspaceOptions,
5
+ ChosenInventory,
5
6
  CommandResult,
6
7
  CwdObservation,
7
8
  DependencyBucket,
9
+ EngineInstallFinding,
10
+ EnginePinChange,
8
11
  HubDocument,
12
+ HubEnginePin,
9
13
  HubHealthReport,
10
14
  InventoryObservation,
11
15
  InventoryValidationEntry,
@@ -16,13 +20,18 @@ import type {
16
20
  WorkspaceHost,
17
21
  WorkspaceObservation,
18
22
  WorkspacePlan,
23
+ WorkspacePlanAdopt,
19
24
  WorkspacePlanCreate,
25
+ WorkspacePlanResume,
20
26
  WorkspaceRefusal,
21
27
  } from "./types.js";
22
28
  import { composeSkills, SKILLS_MANIFEST_REL, type SkillCompositionResult } from "./skills.js";
23
29
  import { parseSkillManifest, summarizeSkillsManifest } from "./manifest.js";
24
30
  import { detectLinkedHosts, serializeHostRecord, HOSTS_REL, type DiscoveredHost } from "./hosts.js";
25
31
  import { reportInventoryDrift } from "./inventory-adoption.js";
32
+ import { belongsToOwner, distinctOwners, sameOwner, sameRepository } from "./identity.js";
33
+ import { isValidInventoryId, readInventoryDocument, renderInventoryDocument, validateInventoryDocument, type InventoryEntry } from "./inventory-contract.js";
34
+ import { describeChosenInventory, resolveChosenInventory } from "./inventory-choice.js";
26
35
 
27
36
  export const DEFAULT_REPOSITORY_NAME = "workspace";
28
37
  /** The one visible, per-repository Clossys folder (#1171). Every role's output lives under it. */
@@ -37,7 +46,11 @@ export const LEGACY_STATE_DIR_REL = ".clossys";
37
46
  export const LEGACY_WORKSPACE_MARKER_REL = join(LEGACY_STATE_DIR_REL, "workspace.json");
38
47
  export const LEGACY_WORKSPACE_INVENTORY_REL = join(LEGACY_STATE_DIR_REL, "inventory.json");
39
48
  export const ADVISOR_PACKAGE = "@clossys/advisor";
49
+ export const INTEGRATOR_PACKAGE = "@clossys/integrator";
40
50
  export const LAUNCHER_PACKAGE = "@clossys/launcher";
51
+ /** The engines a hub pins: each exactly, once, in `devDependencies`, at its live registry version. */
52
+ export const HUB_ENGINE_PACKAGES = [ADVISOR_PACKAGE, INTEGRATOR_PACKAGE] as const;
53
+ type HubEnginePackage = (typeof HUB_ENGINE_PACKAGES)[number];
41
54
 
42
55
  const DEPENDENCY_BUCKETS: readonly DependencyBucket[] = [
43
56
  "dependencies",
@@ -61,6 +74,30 @@ export const CONSUMER_AGENTS_MD = `# Account workspace
61
74
 
62
75
  This folder is the account hub for Foundry packages.
63
76
 
77
+ After \`npx @clossys/launcher\`, the \`@clossys-*\` team is composed in this
78
+ hub. Talk with \`@clossys-advisor\` and \`@clossys-<package>\` here. A launcher
79
+ run writes nothing into a product repository; once a repository is staffed
80
+ in an approved plan, it gets \`@clossys-advisor\` and the voices of the roles
81
+ staffed there, with that plan's setup pull request. A missing \`@\` mention
82
+ is a bug only here in the hub; in a product repository, a role that is not
83
+ staffed there is expected to be absent. \`@clossys-advisor\` is the hiring
84
+ check.
85
+
86
+ Run \`npx @clossys/launcher\` again for hub health and to refresh the voices
87
+ in this hub, not as how you talk to packages.
88
+
89
+ The person in this folder is a founder, not an engineer. Speak like a
90
+ person. Do not dump machine identifiers, JSON, hashes, or grant fields
91
+ unless they ask.
92
+
93
+ Advisor is read-only until the sponsor approves a next action.
94
+ `;
95
+
96
+ /** The hub guidance written while a launcher run still composed the team into checkouts beside the hub; resume refreshes it. */
97
+ export const SIBLING_COMPOSING_CONSUMER_AGENTS_MD = `# Account workspace
98
+
99
+ This folder is the account hub for Foundry packages.
100
+
64
101
  After \`npx @clossys/launcher\`, the same \`@clossys-*\` team is composed in
65
102
  every inventoried checkout beside this hub. Talk with \`@clossys-advisor\` and
66
103
  \`@clossys-<package>\` here or in a product repository. A missing \`@\` mention
@@ -76,15 +113,6 @@ unless they ask.
76
113
  Advisor is read-only until the sponsor approves a next action.
77
114
  `;
78
115
 
79
- /** Canned guidance for inventoried product checkouts (not the hub). */
80
- export const SISTER_CONSUMER_AGENTS_MD = `# Product repository
81
-
82
- This repository is part of the same account engagement. The same
83
- \`@clossys-<package>\` team is here for intro and questions;
84
- \`@clossys-advisor\` decides hiring and compatibility. This folder is not the
85
- hub — engines are hired per repository, not dumped here.
86
- `;
87
-
88
116
  /** Previous generate-time guidance; used to refresh stale hub AGENTS.md on resume. */
89
117
  export const LEGACY_CONSUMER_AGENTS_MD = `# Account workspace
90
118
 
@@ -195,7 +223,7 @@ export function isHubDocument(value: unknown): value is HubDocument {
195
223
  const parsed = value.repository.includes("/")
196
224
  ? { owner: value.repository.split("/")[0], repository: value.repository.split("/")[1] }
197
225
  : null;
198
- if (!parsed?.owner || !parsed.repository || parsed.owner !== value.owner || !REPO.test(parsed.repository)) return false;
226
+ if (!parsed?.owner || !parsed.repository || !sameOwner(parsed.owner, value.owner) || !REPO.test(parsed.repository)) return false;
199
227
  return true;
200
228
  }
201
229
 
@@ -215,8 +243,9 @@ function readHubAt(host: WorkspaceHost, path: string): HubDocument | undefined {
215
243
  * migration (#1171). `clean`: only the current path has a marker. `legacy`:
216
244
  * only the old path does; resume migrates it (see `migrateLegacyHubState`).
217
245
  * `indeterminate`: both paths carry a parseable marker; launcher never
218
- * merges them silently, so `planWorkspace` refuses instead. `none`: neither
219
- * path has one.
246
+ * merges them silently, so `planWorkspace` refuses instead (and, for a hub
247
+ * cloned from an empty directory, `classifyClonedHub` at apply time).
248
+ * `none`: neither path has one.
220
249
  */
221
250
  function locateHub(
222
251
  host: WorkspaceHost,
@@ -234,19 +263,26 @@ function readHub(host: WorkspaceHost, directory: string): HubDocument | undefine
234
263
  return locateHub(host, directory).document;
235
264
  }
236
265
 
237
- /** Classifies a generated hub inventory (packed template skeleton/clossys/.state/inventory.json; the generated path does not ship) without inventing repositories. */
238
- export function inspectInventory(raw: string | null): InventoryObservation {
266
+ /**
267
+ * The inventory document's shape lives in docs/contracts/repository-inventory.json
268
+ * (in the public repository; that exact path does not ship in this
269
+ * package, but this package's build packs and ships its own copy of the
270
+ * contract) and is checked, on every read and write, by
271
+ * `validateInventoryDocument()` through the shared contract checker
272
+ * (./inventory-contract.ts, #1334, #1179).
273
+ */
274
+ export { validateInventoryDocument } from "./inventory-contract.js";
275
+ export type { InventoryValidation } from "./inventory-contract.js";
276
+
277
+ /**
278
+ * 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).
279
+ * 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).
280
+ */
281
+ export function inspectInventory(raw: string | Uint8Array | null, hubOwner?: string): InventoryObservation {
239
282
  if (raw === null) return { status: "missing", count: 0 };
240
- try {
241
- const parsed: unknown = JSON.parse(raw);
242
- if (!isRecord(parsed) || parsed.schemaVersion !== 1 || !Array.isArray(parsed.repositories)) {
243
- return { status: "empty", count: 0 };
244
- }
245
- const count = parsed.repositories.length;
246
- return { status: count > 0 ? "populated" : "empty", count };
247
- } catch {
248
- return { status: "empty", count: 0 };
249
- }
283
+ const validated = validateInventoryDocument(raw, hubOwner === undefined ? {} : { hubOwner });
284
+ if (!validated.valid) return { status: "invalid", count: 0, reason: validated.reason };
285
+ return { status: validated.ids.length > 0 ? "populated" : "empty", count: validated.ids.length };
250
286
  }
251
287
 
252
288
  function looksLikeFoundry(host: WorkspaceHost, directory: string): boolean {
@@ -284,21 +320,37 @@ function commandAvailable(host: WorkspaceHost, command: string): boolean {
284
320
  /** True when the tree already pins Advisor in some bucket, so adopt would not need a new version. */
285
321
  export function hasAdvisorPin(manifest: unknown): boolean {
286
322
  if (!isRecord(manifest)) return false;
287
- return DEPENDENCY_BUCKETS.some((bucket) => clossysNames(manifest[bucket], new Set()) !== undefined);
323
+ return DEPENDENCY_BUCKETS.some((bucket) => enginePinsIn(manifest[bucket], new Set())[ADVISOR_PACKAGE] !== undefined);
324
+ }
325
+
326
+ /**
327
+ * The one place Launcher learns a package's live version: the public npm
328
+ * registry (`npm view <name> version`). A failed or unparseable read is
329
+ * `undefined`, never a guess.
330
+ */
331
+ function readRegistryVersion(host: WorkspaceHost, name: string): string | undefined {
332
+ const viewed = host.run("npm", ["view", name, "version"]);
333
+ const version = viewed.stdout.trim();
334
+ return viewed.status === 0 && /^\d+\.\d+\.\d+$/.test(version) ? version : undefined;
288
335
  }
289
336
 
290
- /** Collects GitHub owner, cwd shape, default-hub presence, and the public Advisor pin. */
291
337
  /**
292
338
  * Reads the public `@clossys/launcher` registry version, used only to grade
293
339
  * catalogue-sourced skill staleness in the health report (#1183). A missing
294
340
  * or unparseable read leaves staleness ungraded rather than refusing.
295
341
  */
296
342
  export function readLiveLauncherVersion(host: WorkspaceHost): string | undefined {
297
- const viewed = host.run("npm", ["view", LAUNCHER_PACKAGE, "version"]);
298
- const version = viewed.stdout.trim();
299
- return viewed.status === 0 && /^\d+\.\d+\.\d+$/.test(version) ? version : undefined;
343
+ return readRegistryVersion(host, LAUNCHER_PACKAGE);
300
344
  }
301
345
 
346
+ /**
347
+ * Collects GitHub owner, cwd shape, default-hub presence, and the public
348
+ * Advisor and Integrator versions. Default-hub presence means only that
349
+ * `{owner}/workspace` exists on GitHub; its contents (marker, legacy marker,
350
+ * or none) are not observed here but classified after the clone, at apply
351
+ * time (#1585).
352
+ */
353
+
302
354
  export function observeWorkspace(host: WorkspaceHost): WorkspaceObservation {
303
355
  const cwd = host.cwd;
304
356
  const ghAvailable = commandAvailable(host, "gh");
@@ -315,37 +367,38 @@ export function observeWorkspace(host: WorkspaceHost): WorkspaceObservation {
315
367
  githubRepository = parsed.repository;
316
368
  }
317
369
  }
318
- const candidates = new Set<string>();
370
+ // Owners are compared as GitHub compares them (identity.ts): two spellings of one account are one candidate.
371
+ const seenOwners: string[] = [];
319
372
  const envOwnerRaw = host.env.CLOSSYS_OWNER?.trim();
320
373
  const envOwner = envOwnerRaw && OWNER.test(envOwnerRaw) ? envOwnerRaw : undefined;
321
374
  if (ghAvailable) {
322
375
  const user = host.run("gh", ["api", "user", "--jq", ".login"]);
323
376
  const login = user.stdout.trim();
324
- if (user.status === 0 && OWNER.test(login)) candidates.add(login);
377
+ if (user.status === 0 && OWNER.test(login)) seenOwners.push(login);
325
378
  for (const org of stdoutLines(host.run("gh", ["org", "list"]))) {
326
- if (OWNER.test(org)) candidates.add(org);
379
+ if (OWNER.test(org)) seenOwners.push(org);
327
380
  }
328
381
  }
329
- if (githubOwner) candidates.add(githubOwner);
382
+ if (githubOwner) seenOwners.push(githubOwner);
383
+ const candidates = new Set(distinctOwners(seenOwners));
330
384
 
331
385
  let remoteDefaultHub: WorkspaceObservation["remoteDefaultHub"];
332
- let advisorVersion: string | undefined;
333
386
  const ownerGuess = envOwner ?? (candidates.size === 1 ? [...candidates][0] : githubOwner);
334
387
  if (ghAvailable && ownerGuess) {
335
388
  const viewed = host.run("gh", ["repo", "view", `${ownerGuess}/${DEFAULT_REPOSITORY_NAME}`, "--json", "name"]);
336
389
  if (viewed.status === 0) remoteDefaultHub = { owner: ownerGuess, repository: DEFAULT_REPOSITORY_NAME };
337
390
  }
338
- const viewedAdvisor = host.run("npm", ["view", ADVISOR_PACKAGE, "version"]);
339
- const version = viewedAdvisor.stdout.trim();
340
- if (viewedAdvisor.status === 0 && /^\d+\.\d+\.\d+$/.test(version)) advisorVersion = version;
391
+ const advisorVersion = readRegistryVersion(host, ADVISOR_PACKAGE);
392
+ const integratorVersion = readRegistryVersion(host, INTEGRATOR_PACKAGE);
341
393
 
342
394
  const hubLocation = locateHub(host, cwd);
343
395
  // While only the legacy `.clossys/` marker exists, its sibling inventory is
344
396
  // the one resume will migrate; read from there so planning sees it too.
345
- const inventoryRaw =
397
+ const inventoryBytes =
346
398
  hubLocation.migration === "legacy"
347
- ? host.readText(join(cwd, LEGACY_WORKSPACE_INVENTORY_REL))
348
- : host.readText(join(cwd, WORKSPACE_INVENTORY_REL));
399
+ ? host.readBytes(join(cwd, LEGACY_WORKSPACE_INVENTORY_REL))
400
+ : host.readBytes(join(cwd, WORKSPACE_INVENTORY_REL));
401
+ const inventoryOwner = hubLocation.document?.owner ?? githubOwner;
349
402
 
350
403
  const cwdObservation: CwdObservation = {
351
404
  absolutePath: cwd,
@@ -356,7 +409,7 @@ export function observeWorkspace(host: WorkspaceHost): WorkspaceObservation {
356
409
  ...(hubLocation.document === undefined ? {} : { hub: hubLocation.document }),
357
410
  ...(hubLocation.migration === "none" ? {} : { hubMigration: hubLocation.migration }),
358
411
  looksLikeFoundry: looksLikeFoundry(host, cwd),
359
- inventory: inspectInventory(inventoryRaw),
412
+ inventory: inspectInventory(inventoryBytes, inventoryOwner),
360
413
  };
361
414
 
362
415
  return {
@@ -365,6 +418,7 @@ export function observeWorkspace(host: WorkspaceHost): WorkspaceObservation {
365
418
  ...(envOwner === undefined ? {} : { envOwner }),
366
419
  ...(remoteDefaultHub === undefined ? {} : { remoteDefaultHub }),
367
420
  ...(advisorVersion === undefined ? {} : { advisorVersion }),
421
+ ...(integratorVersion === undefined ? {} : { integratorVersion }),
368
422
  ghAvailable,
369
423
  gitAvailable,
370
424
  };
@@ -393,19 +447,38 @@ function resolveOwner(observation: WorkspaceObservation, host: WorkspaceHost): {
393
447
  * Decides create, resume, or adopt from a cwd observation.
394
448
  * Appointing means: run this from the GitHub repository that should own the hub.
395
449
  */
396
- export function readInventoryRepositories(host: WorkspaceHost, source: string, label: string): readonly string[] {
397
- const raw = host.readText(source);
450
+ /**
451
+ * Reads repository ids from a `schemaVersion: 1` inventory document, routed
452
+ * through `validateInventoryDocument` -- the same schema check `--inventory`
453
+ * and `inspectInventory` apply, so a caller here can never end up trusting a
454
+ * document neither of those would have accepted (#1334). Throws, naming the
455
+ * offending field, on anything present but invalid; a missing file is `[]`,
456
+ * not a throw -- an absent inventory is a fact about the hub, not a
457
+ * malformed one. The file is read as bytes; `hubOwner`, when given, makes a
458
+ * bare id and `<hubOwner>/<id>` one repository, so a document listing both
459
+ * is refused.
460
+ */
461
+ export function readInventoryRepositories(host: WorkspaceHost, source: string, label: string, hubOwner?: string): readonly string[] {
462
+ const raw = host.readBytes(source);
398
463
  if (raw === null) return [];
399
- try {
400
- const parsed: unknown = JSON.parse(raw);
401
- if (!isRecord(parsed) || !Array.isArray(parsed.repositories)) return [];
402
- return parsed.repositories.map((entry) => {
403
- if (isRecord(entry) && isText(entry.id)) return entry.id.trim();
404
- return "";
405
- });
406
- } catch {
407
- throw new Error(`${label} is not readable inventory JSON (schemaVersion 1, repositories array)`);
408
- }
464
+ const validated = validateInventoryDocument(raw, hubOwner === undefined ? {} : { hubOwner });
465
+ if (!validated.valid) throw new Error(`${label} ${validated.reason}`);
466
+ return validated.ids;
467
+ }
468
+
469
+ /**
470
+ * How a founder gives Launcher the repositories a hub covers (#1179): they
471
+ * choose them on Advisor's repository-choice card, and `--repositories`
472
+ * writes the inventory. Named by every refusal that needs an inventory,
473
+ * instead of `--inventory <path>`, which still works but asks for a
474
+ * document a founder will not write.
475
+ */
476
+ function chooseRepositoriesHint(extraFlag = ""): string {
477
+ return (
478
+ "choose the repositories this hub covers on Advisor's repository card " +
479
+ "(`npx -p @clossys/advisor advisor-repository-card`), built from the repositories GitHub lists for your sign-in, then run " +
480
+ `\`launcher --repositories <owner/name>[,<owner/name>...]${extraFlag}\`, and Launcher writes the inventory for you`
481
+ );
409
482
  }
410
483
 
411
484
  /**
@@ -420,48 +493,135 @@ function resolveAdoptInventory(
420
493
  host: WorkspaceHost,
421
494
  cwd: CwdObservation,
422
495
  inventoryPath: string | undefined,
423
- ): { inventorySource?: string; mergedInventoryIds?: readonly string[] } | WorkspaceRefusal {
496
+ ):
497
+ | {
498
+ inventorySource?: string;
499
+ mergedInventoryIds?: readonly string[];
500
+ mergedInventoryRepositories?: readonly InventoryEntry[];
501
+ mergedInventoryDocument?: string;
502
+ replacesInvalidInventory?: boolean;
503
+ }
504
+ | WorkspaceRefusal {
424
505
  const trimmed = inventoryPath?.trim();
425
506
  const onDiskPopulated = cwd.inventory?.status === "populated";
426
507
  if (!onDiskPopulated && !trimmed) {
508
+ if (cwd.inventory?.status === "invalid") {
509
+ return refuse(
510
+ "violated",
511
+ `the on-disk hub inventory ${cwd.inventory.reason ?? "does not conform to the inventory contract"} -- to replace it, ${chooseRepositoriesHint(" --replace-inventory")}`,
512
+ );
513
+ }
427
514
  return refuse(
428
515
  "violated",
429
- "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",
516
+ `appointing needs the repositories this hub covers, and it has no inventory yet: ${chooseRepositoriesHint()}`,
430
517
  );
431
518
  }
432
519
  if (!trimmed) return {};
433
520
  const resolved = resolve(cwd.absolutePath, trimmed);
434
- const imported = inspectInventory(host.readText(resolved));
435
- if (imported.status !== "populated") {
521
+ const importedRaw = host.readBytes(resolved);
522
+ if (importedRaw === null) {
523
+ return refuse("violated", `--inventory does not point at a readable file: ${resolved}`);
524
+ }
525
+ const owner = cwd.githubOwner === undefined ? {} : { hubOwner: cwd.githubOwner };
526
+ const imported = readInventoryDocument(importedRaw, owner);
527
+ if (!imported.valid) {
528
+ return refuse("violated", `--inventory at ${resolved} ${imported.reason}`);
529
+ }
530
+ if (imported.ids.length === 0) {
436
531
  return refuse(
437
532
  "violated",
438
- "--inventory must point at a populated inventory document (schemaVersion 1, nonempty repositories)",
533
+ `--inventory at ${resolved} must be a populated inventory document (nonempty repositories)`,
439
534
  );
440
535
  }
441
- if (!onDiskPopulated) return { inventorySource: resolved };
442
- const merged: string[] = [];
443
- const seen = new Set<string>();
444
- for (const id of readInventoryRepositories(host, join(cwd.absolutePath, WORKSPACE_INVENTORY_REL), "the on-disk hub inventory")) {
445
- if (id !== "" && !seen.has(id)) {
446
- seen.add(id);
447
- merged.push(id);
448
- }
536
+ if (!onDiskPopulated) {
537
+ return {
538
+ inventorySource: resolved,
539
+ ...(cwd.inventory?.status === "invalid" ? { replacesInvalidInventory: true } : {}),
540
+ };
449
541
  }
450
- for (const id of readInventoryRepositories(host, resolved, "--inventory")) {
451
- if (id !== "" && !seen.has(id)) {
452
- seen.add(id);
453
- merged.push(id);
454
- }
542
+ const onDiskRaw = host.readBytes(join(cwd.absolutePath, WORKSPACE_INVENTORY_REL));
543
+ const onDisk = onDiskRaw === null ? undefined : readInventoryDocument(onDiskRaw, owner);
544
+ if (onDisk !== undefined && !onDisk.valid) {
545
+ return refuse("violated", `the on-disk hub inventory ${onDisk.reason}`);
455
546
  }
456
- return { mergedInventoryIds: merged };
547
+ const onDiskEntries: readonly InventoryEntry[] = onDisk !== undefined && onDisk.valid ? onDisk.entries : [];
548
+ // Merge whole entries, not ids: an entry's `packages` travels with it.
549
+ // Entries are keyed by Launcher's one repository identity (identity.ts),
550
+ // the same rule validateInventoryDocument() applies to ids, so the merge
551
+ // can never write a document the validator then refuses; the first
552
+ // occurrence -- the on-disk spelling and entry -- wins (#1334, #1179).
553
+ const merged: InventoryEntry[] = [];
554
+ for (const entry of [...onDiskEntries, ...imported.entries]) {
555
+ if (!merged.some((kept) => sameRepository(kept.id, entry.id, cwd.githubOwner))) merged.push(entry);
556
+ }
557
+ return {
558
+ mergedInventoryIds: merged.map((entry) => entry.id),
559
+ mergedInventoryRepositories: merged,
560
+ mergedInventoryDocument: renderInventoryDocument(merged),
561
+ };
562
+ }
563
+
564
+ /**
565
+ * Resolves `--repositories` against the inventory stored in `directory`, or
566
+ * returns a refusal. See `resolveChosenInventory()`.
567
+ */
568
+ function resolveChosenRepositories(
569
+ host: WorkspaceHost,
570
+ directory: string,
571
+ owner: string,
572
+ repositories: readonly string[],
573
+ replaceInventory: boolean,
574
+ ): { chosenInventory: ChosenInventory } | WorkspaceRefusal {
575
+ const resolution = resolveChosenInventory(host.readBytes(join(directory, WORKSPACE_INVENTORY_REL)), repositories, owner, replaceInventory);
576
+ if (resolution.kind === "refuse") return refuse("violated", resolution.message);
577
+ return { chosenInventory: resolution.chosen };
578
+ }
579
+
580
+ export interface PlanWorkspaceOptions {
581
+ /** `--inventory <path>`: a prepared inventory document, appoint only. */
582
+ readonly inventoryPath?: string;
583
+ /** `--repositories`: the repository ids a founder chose on Advisor's repository card (#1179). Appoint or resume of a hub checkout. */
584
+ readonly repositories?: readonly string[];
585
+ /** `--replace-inventory`: explicit approval for `repositories` to replace a stored inventory that lists a different set, or one that fails its contract. */
586
+ readonly replaceInventory?: boolean;
587
+ }
588
+
589
+ /** The hub engine versions a plan carries, only those the observation read. */
590
+ function observedEngineVersions(observation: WorkspaceObservation): { advisorVersion?: string; integratorVersion?: string } {
591
+ return {
592
+ ...(observation.advisorVersion === undefined ? {} : { advisorVersion: observation.advisorVersion }),
593
+ ...(observation.integratorVersion === undefined ? {} : { integratorVersion: observation.integratorVersion }),
594
+ };
595
+ }
596
+
597
+ /**
598
+ * Create and appoint pin both hub engines, so each needs its live version:
599
+ * the refusal names the first one the registry did not answer for.
600
+ */
601
+ function requireEngineVersions(
602
+ observation: WorkspaceObservation,
603
+ ): { advisorVersion: string; integratorVersion: string } | WorkspaceRefusal {
604
+ if (!observation.advisorVersion) {
605
+ return refuse("indeterminate", `cannot read a public ${ADVISOR_PACKAGE} version from the npm registry`);
606
+ }
607
+ if (!observation.integratorVersion) {
608
+ return refuse("indeterminate", `cannot read a public ${INTEGRATOR_PACKAGE} version from the npm registry`);
609
+ }
610
+ return { advisorVersion: observation.advisorVersion, integratorVersion: observation.integratorVersion };
457
611
  }
458
612
 
459
613
  export function planWorkspace(
460
614
  observation: WorkspaceObservation,
461
615
  host: WorkspaceHost,
462
- options: { inventoryPath?: string } = {},
616
+ options: PlanWorkspaceOptions = {},
463
617
  ): WorkspaceDecision {
464
618
  const { cwd } = observation;
619
+ if (options.repositories !== undefined && options.inventoryPath !== undefined) {
620
+ return refuse("violated", "--repositories and --inventory each supply the whole inventory; use one, not both");
621
+ }
622
+ if (options.replaceInventory === true && options.repositories === undefined) {
623
+ return refuse("violated", "--replace-inventory approves replacing the inventory with --repositories, and means nothing without it");
624
+ }
465
625
  if (cwd.hubMigration === "indeterminate") {
466
626
  return refuse(
467
627
  "indeterminate",
@@ -475,7 +635,7 @@ export function planWorkspace(
475
635
  cwd.hub === undefined &&
476
636
  cwd.git &&
477
637
  cwd.githubOwner !== undefined &&
478
- cwd.githubOwner !== envOwner
638
+ !sameOwner(cwd.githubOwner, envOwner)
479
639
  ) {
480
640
  return refuse(
481
641
  "violated",
@@ -489,19 +649,43 @@ export function planWorkspace(
489
649
  );
490
650
  }
491
651
  if (cwd.hub) {
652
+ let chosen: { chosenInventory: ChosenInventory } | undefined;
653
+ if (options.repositories !== undefined) {
654
+ if (cwd.hubMigration === "legacy") {
655
+ return refuse(
656
+ "violated",
657
+ "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",
658
+ );
659
+ }
660
+ const resolved = resolveChosenRepositories(host, cwd.absolutePath, cwd.hub.owner, options.repositories, options.replaceInventory === true);
661
+ if ("action" in resolved) return resolved;
662
+ chosen = resolved;
663
+ }
492
664
  return {
493
665
  action: "resume",
494
666
  owner: cwd.hub.owner,
495
667
  repository: repoNameFromSlug(cwd.hub.repository, DEFAULT_REPOSITORY_NAME),
496
668
  directory: cwd.absolutePath,
497
669
  clone: false,
498
- ...(observation.advisorVersion === undefined ? {} : { advisorVersion: observation.advisorVersion }),
670
+ ...observedEngineVersions(observation),
499
671
  ...(cwd.hubMigration === "legacy" ? { migrateFrom: "legacy" as const } : {}),
672
+ ...(chosen ?? {}),
500
673
  };
501
674
  }
502
675
  if (cwd.git && cwd.githubOwner && cwd.githubRepository) {
503
- if (!observation.advisorVersion) {
504
- return refuse("indeterminate", `cannot read a public ${ADVISOR_PACKAGE} version from the npm registry`);
676
+ const engines = requireEngineVersions(observation);
677
+ if ("action" in engines) return engines;
678
+ if (options.repositories !== undefined) {
679
+ const resolved = resolveChosenRepositories(host, cwd.absolutePath, cwd.githubOwner, options.repositories, options.replaceInventory === true);
680
+ if ("action" in resolved) return resolved;
681
+ return {
682
+ action: "adopt",
683
+ owner: cwd.githubOwner,
684
+ repository: cwd.githubRepository,
685
+ directory: cwd.absolutePath,
686
+ ...engines,
687
+ chosenInventory: resolved.chosenInventory,
688
+ };
505
689
  }
506
690
  const imported = resolveAdoptInventory(host, cwd, options.inventoryPath);
507
691
  if ("action" in imported) return imported;
@@ -510,9 +694,12 @@ export function planWorkspace(
510
694
  owner: cwd.githubOwner,
511
695
  repository: cwd.githubRepository,
512
696
  directory: cwd.absolutePath,
513
- advisorVersion: observation.advisorVersion,
697
+ ...engines,
514
698
  ...(imported.inventorySource === undefined ? {} : { inventorySource: imported.inventorySource }),
515
699
  ...(imported.mergedInventoryIds === undefined ? {} : { mergedInventoryIds: imported.mergedInventoryIds }),
700
+ ...(imported.mergedInventoryRepositories === undefined ? {} : { mergedInventoryRepositories: imported.mergedInventoryRepositories }),
701
+ ...(imported.mergedInventoryDocument === undefined ? {} : { mergedInventoryDocument: imported.mergedInventoryDocument }),
702
+ ...(imported.replacesInvalidInventory === undefined ? {} : { replacesInvalidInventory: imported.replacesInvalidInventory }),
516
703
  };
517
704
  }
518
705
  if (cwd.git) {
@@ -524,30 +711,38 @@ export function planWorkspace(
524
711
  "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",
525
712
  );
526
713
  }
714
+ if (options.repositories !== undefined) {
715
+ return refuse(
716
+ "violated",
717
+ "--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",
718
+ );
719
+ }
527
720
  const ownerResult = resolveOwner(observation, host);
528
721
  if ("action" in ownerResult) return ownerResult;
529
- if (observation.remoteDefaultHub && observation.remoteDefaultHub.owner === ownerResult.owner) {
722
+ if (observation.remoteDefaultHub && sameOwner(observation.remoteDefaultHub.owner, ownerResult.owner)) {
723
+ // A clone plan: whether `{owner}/workspace` is already a hub (marked, legacy-marked) or an existing
724
+ // repository to appoint (unmarked) is not known until it is cloned, so applyWorkspacePlan classifies
725
+ // it then (classifyClonedHub, #1585) and may take the adopt path instead of resuming.
530
726
  return {
531
727
  action: "resume",
532
728
  owner: observation.remoteDefaultHub.owner,
533
729
  repository: observation.remoteDefaultHub.repository,
534
730
  directory: cwd.absolutePath,
535
731
  clone: true,
536
- ...(observation.advisorVersion === undefined ? {} : { advisorVersion: observation.advisorVersion }),
732
+ ...observedEngineVersions(observation),
537
733
  };
538
734
  }
539
735
  if (!observation.ghAvailable) {
540
736
  return refuse("indeterminate", "`gh` is required to create a GitHub repository for a new hub");
541
737
  }
542
- if (!observation.advisorVersion) {
543
- return refuse("indeterminate", `cannot read a public ${ADVISOR_PACKAGE} version from the npm registry`);
544
- }
738
+ const engines = requireEngineVersions(observation);
739
+ if ("action" in engines) return engines;
545
740
  return {
546
741
  action: "create",
547
742
  owner: ownerResult.owner,
548
743
  repository: DEFAULT_REPOSITORY_NAME,
549
744
  directory: cwd.absolutePath,
550
- advisorVersion: observation.advisorVersion,
745
+ ...engines,
551
746
  };
552
747
  }
553
748
 
@@ -560,11 +755,15 @@ function containedPath(root: string, relativePath: string): string {
560
755
  return resolved;
561
756
  }
562
757
 
563
- function substitute(contents: string, plan: { owner: string; repository: string; advisorVersion?: string }): string {
758
+ function substitute(
759
+ contents: string,
760
+ plan: { owner: string; repository: string; advisorVersion?: string; integratorVersion?: string },
761
+ ): string {
564
762
  return contents
565
763
  .replaceAll("__OWNER__", plan.owner)
566
764
  .replaceAll("__REPOSITORY_NAME__", plan.repository)
567
- .replaceAll("__ADVISOR_VERSION__", plan.advisorVersion ?? "0.0.0");
765
+ .replaceAll("__ADVISOR_VERSION__", plan.advisorVersion ?? "0.0.0")
766
+ .replaceAll("__INTEGRATOR_VERSION__", plan.integratorVersion ?? "0.0.0");
568
767
  }
569
768
 
570
769
  function writeSkeletonFile(host: WorkspaceHost, directory: string, relativePath: string, contents: string): void {
@@ -573,69 +772,242 @@ function writeSkeletonFile(host: WorkspaceHost, directory: string, relativePath:
573
772
  host.writeText(target, contents);
574
773
  }
575
774
 
775
+ /** Writes exact bytes, for a document copied rather than composed, so nothing is decoded and re-encoded on the way. */
776
+ function writeSkeletonBytes(host: WorkspaceHost, directory: string, relativePath: string, contents: Uint8Array): void {
777
+ const target = containedPath(directory, relativePath);
778
+ host.mkdirp(dirname(target));
779
+ host.writeBytes(target, contents);
780
+ }
781
+
782
+ function withFinalNewline(bytes: Uint8Array): Uint8Array {
783
+ if (bytes.length > 0 && bytes[bytes.length - 1] === 0x0a) return bytes;
784
+ const out = new Uint8Array(bytes.length + 1);
785
+ out.set(bytes);
786
+ out[bytes.length] = 0x0a;
787
+ return out;
788
+ }
789
+
576
790
  function pinString(value: unknown): string | undefined {
577
791
  return typeof value === "string" && value.trim() !== "" ? value.trim() : undefined;
578
792
  }
579
793
 
580
- function clossysNames(bucket: unknown, extra: Set<string>): string | undefined {
581
- if (!isRecord(bucket)) return undefined;
582
- let advisor: string | undefined;
794
+ /**
795
+ * The hub engine pins in one dependency bucket, by engine. Every other
796
+ * `@clossys/*` name found there is added to `extra`.
797
+ */
798
+ function enginePinsIn(bucket: unknown, extra: Set<string>): Partial<Record<HubEnginePackage, string>> {
799
+ const pins: Partial<Record<HubEnginePackage, string>> = {};
800
+ if (!isRecord(bucket)) return pins;
583
801
  for (const [name, version] of Object.entries(bucket)) {
584
- if (name === ADVISOR_PACKAGE) {
585
- advisor = pinString(version);
802
+ const engine = HUB_ENGINE_PACKAGES.find((candidate) => candidate === name);
803
+ if (engine !== undefined) {
804
+ const pinned = pinString(version);
805
+ if (pinned !== undefined) pins[engine] = pinned;
586
806
  continue;
587
807
  }
588
808
  if (name.startsWith("@clossys/")) extra.add(name);
589
809
  }
590
- return advisor;
810
+ return pins;
811
+ }
812
+
813
+ /** The live engine versions to pin; an engine whose version is unknown is left as the manifest has it. */
814
+ interface HubEngineVersions {
815
+ readonly advisorVersion?: string;
816
+ readonly integratorVersion?: string;
817
+ }
818
+
819
+ function engineVersionEntries(versions: HubEngineVersions): readonly (readonly [HubEnginePackage, string])[] {
820
+ const entries: (readonly [HubEnginePackage, string])[] = [];
821
+ if (versions.advisorVersion !== undefined) entries.push([ADVISOR_PACKAGE, versions.advisorVersion]);
822
+ if (versions.integratorVersion !== undefined) entries.push([INTEGRATOR_PACKAGE, versions.integratorVersion]);
823
+ return entries;
824
+ }
825
+
826
+ /** A `package.json` body parsed to a JSON object, or `undefined` when it is unparseable, an array, or a primitive. */
827
+ function parseManifestObject(raw: string): Record<string, unknown> | undefined {
828
+ try {
829
+ const parsed: unknown = JSON.parse(raw);
830
+ return isRecord(parsed) ? parsed : undefined;
831
+ } catch {
832
+ return undefined;
833
+ }
834
+ }
835
+
836
+ /**
837
+ * The manifest refusals of `mergeHubEnginePins` in "appoint" mode, raised by
838
+ * `adoptHubFiles` before its first write so a refusal leaves the checkout
839
+ * untouched: a `package.json` that is a symbolic link (live or dangling, never
840
+ * resolved, so the write cannot land outside the checkout), a present one that
841
+ * is unreadable or not a JSON object, or a missing one with no skeleton
842
+ * manifest to write in its place.
843
+ */
844
+ function assertManifestAppointable(host: WorkspaceHost, directory: string, skeletonRoot: string): void {
845
+ const manifestPath = join(directory, "package.json");
846
+ if (host.isSymlink(manifestPath)) {
847
+ throw new Error("existing package.json is a symbolic link; replace it with a regular file before appointing");
848
+ }
849
+ const raw = host.readText(manifestPath);
850
+ if (raw === null) {
851
+ // readText collapses every read error to null, so tell "absent" from
852
+ // "present but unreadable" (a directory, no read permission).
853
+ if (host.exists(manifestPath)) {
854
+ throw new Error("existing package.json is present but cannot be read (is it a directory, or unreadable?)");
855
+ }
856
+ if (host.readText(join(skeletonRoot, "package.json")) === null) throw new Error("missing skeleton package.json");
857
+ return;
858
+ }
859
+ if (parseManifestObject(raw) === undefined) throw new Error("existing package.json is unreadable JSON");
591
860
  }
592
861
 
593
862
  /**
594
- * Pins live Advisor in `devDependencies` only. Relocates a pin left in any
595
- * other bucket and overwrites a frozen version. Does not touch other
596
- * `@clossys/*` names. A dedicated `{owner}/workspace` hub is named
597
- * `@owner/workspace`.
863
+ * Pins each hub engine (Advisor and Integrator) exactly, in `devDependencies`
864
+ * only, and returns what it changed. It only raises a pin: one older than
865
+ * live, or not a plain version (a range, a tag), becomes the live version;
866
+ * one newer than live is kept as it is. A pin left in any other bucket is
867
+ * moved to `devDependencies`. Other `@clossys/*` names are not touched. The
868
+ * manifest is written as 2-space-indented JSON with a final LF.
869
+ *
870
+ * `appoint` writes the packed skeleton manifest when the hub has none,
871
+ * refuses a manifest that is not a JSON object, names a dedicated
872
+ * `{owner}/workspace` hub `@owner/workspace` only when its manifest has no
873
+ * own `name` (an existing name is never overwritten, #1585), and rewrites
874
+ * the manifest.
875
+ * `resume` changes only the engine pins (never the `name`), writes the
876
+ * manifest only when a pin changed, and leaves a missing or unreadable
877
+ * manifest as it is (the health report then shows the engine pins as
878
+ * missing).
598
879
  */
599
- function mergeAdvisorPin(
880
+ function mergeHubEnginePins(
600
881
  host: WorkspaceHost,
601
882
  directory: string,
602
883
  skeletonRoot: string,
603
- advisorVersion: string,
884
+ versions: HubEngineVersions,
604
885
  owner: string,
605
886
  repository: string,
606
- ): void {
887
+ mode: "appoint" | "resume",
888
+ ): readonly EnginePinChange[] {
607
889
  const path = join(directory, "package.json");
608
890
  const raw = host.readText(path);
609
891
  if (raw === null) {
892
+ if (mode === "resume") return [];
610
893
  const skeleton = host.readText(join(skeletonRoot, "package.json"));
611
894
  if (skeleton === null) throw new Error("missing skeleton package.json");
612
- writeSkeletonFile(host, directory, "package.json", substitute(skeleton, { owner, repository, advisorVersion }));
613
- return;
895
+ writeSkeletonFile(host, directory, "package.json", substitute(skeleton, { owner, repository, ...versions }));
896
+ return engineVersionEntries(versions).map(([engine, version]) => ({ package: engine, to: version }));
614
897
  }
615
- let manifest: Record<string, unknown>;
616
- try {
617
- const parsed: unknown = JSON.parse(raw);
618
- if (!isRecord(parsed)) throw new Error("package.json is not an object");
619
- manifest = parsed;
620
- } catch {
898
+ const manifest = parseManifestObject(raw);
899
+ if (manifest === undefined) {
900
+ if (mode === "resume") return [];
621
901
  throw new Error("existing package.json is unreadable JSON");
622
902
  }
623
- for (const bucket of DEPENDENCY_BUCKETS) {
624
- if (bucket === "devDependencies") continue;
625
- const current = manifest[bucket];
626
- if (!isRecord(current) || !(ADVISOR_PACKAGE in current)) continue;
627
- const next = { ...current };
628
- delete next[ADVISOR_PACKAGE];
629
- if (Object.keys(next).length === 0) delete manifest[bucket];
630
- else manifest[bucket] = next;
631
- }
632
- const devDependencies = isRecord(manifest.devDependencies) ? { ...manifest.devDependencies } : {};
633
- devDependencies[ADVISOR_PACKAGE] = advisorVersion;
634
- manifest.devDependencies = devDependencies;
635
- if (repository === DEFAULT_REPOSITORY_NAME) {
903
+ const changes: EnginePinChange[] = [];
904
+ for (const [engine, live] of engineVersionEntries(versions)) {
905
+ const devPin = isRecord(manifest.devDependencies) ? pinString(manifest.devDependencies[engine]) : undefined;
906
+ let movedFrom: DependencyBucket | undefined;
907
+ let movedPin: string | undefined;
908
+ for (const bucket of DEPENDENCY_BUCKETS) {
909
+ if (bucket === "devDependencies") continue;
910
+ const current = manifest[bucket];
911
+ if (!isRecord(current) || !(engine in current)) continue;
912
+ if (movedFrom === undefined) {
913
+ movedFrom = bucket;
914
+ movedPin = pinString(current[engine]);
915
+ }
916
+ const next = { ...current };
917
+ delete next[engine];
918
+ if (Object.keys(next).length === 0) delete manifest[bucket];
919
+ else manifest[bucket] = next;
920
+ }
921
+ const existing = devPin ?? movedPin;
922
+ // Only raise: a pin newer than live stays, as the health grader treats it as current.
923
+ const to = existing !== undefined && compareVersions(existing, live) === 1 ? existing : live;
924
+ const devDependencies = isRecord(manifest.devDependencies) ? { ...manifest.devDependencies } : {};
925
+ devDependencies[engine] = to;
926
+ manifest.devDependencies = devDependencies;
927
+ if (to !== devPin || movedFrom !== undefined) {
928
+ changes.push({
929
+ package: engine,
930
+ ...(existing === undefined ? {} : { from: existing }),
931
+ to,
932
+ ...(devPin === undefined && movedFrom !== undefined ? { movedFrom } : {}),
933
+ });
934
+ }
935
+ }
936
+ // Only a nameless manifest is named: an existing `name` is the project's own and is never overwritten (#1585).
937
+ if (mode === "appoint" && repository === DEFAULT_REPOSITORY_NAME && !Object.hasOwn(manifest, "name")) {
636
938
  manifest.name = `@${owner}/${repository}`;
637
939
  }
940
+ if (mode === "resume" && changes.length === 0) return changes;
638
941
  host.writeText(path, `${JSON.stringify(manifest, null, 2)}\n`);
942
+ return changes;
943
+ }
944
+
945
+ /** Lockfiles Launcher recognises in a hub, with the install command that updates each. */
946
+ const HUB_LOCKFILES: readonly (readonly [string, string])[] = [
947
+ // npm reads npm-shrinkwrap.json instead of package-lock.json when both exist.
948
+ ["npm-shrinkwrap.json", "npm install"],
949
+ ["package-lock.json", "npm install"],
950
+ ["pnpm-lock.yaml", "pnpm install"],
951
+ ["yarn.lock", "yarn install"],
952
+ ["bun.lock", "bun install"],
953
+ ["bun.lockb", "bun install"],
954
+ ];
955
+
956
+ function describeEnginePinChange(change: EnginePinChange): string {
957
+ const moved = change.movedFrom === undefined ? "" : ` (moved from ${change.movedFrom} to devDependencies)`;
958
+ if (change.from === undefined) return `${change.package} added at ${change.to}${moved}`;
959
+ if (change.from === change.to) return `${change.package} ${change.to}${moved}`;
960
+ return `${change.package} ${change.from} -> ${change.to}${moved}`;
961
+ }
962
+
963
+ /** The one next step after a run changed engine pins: install with the hub's package manager, then commit the manifest with its lockfile. */
964
+ function enginePinNextStep(host: WorkspaceHost, directory: string): string {
965
+ const lockfile = HUB_LOCKFILES.find(([name]) => host.exists(join(directory, name)));
966
+ return lockfile === undefined
967
+ ? "run your package manager's install in the hub, then commit package.json together with the lockfile it writes"
968
+ : `run \`${lockfile[1]}\` in the hub, then commit package.json together with ${lockfile[0]}`;
969
+ }
970
+
971
+ /**
972
+ * Whether the hub's lockfile resolves its engine pins. An npm lockfile is
973
+ * read: each engine pinned to a plain version in `devDependencies` must
974
+ * resolve to that version. Any other lockfile is not read, so it counts as
975
+ * not resolving the engines this run changed. No lockfile, no finding.
976
+ */
977
+ function engineInstallFinding(
978
+ host: WorkspaceHost,
979
+ directory: string,
980
+ changes: readonly EnginePinChange[],
981
+ ): EngineInstallFinding | undefined {
982
+ const found = HUB_LOCKFILES.find(([name]) => host.exists(join(directory, name)));
983
+ if (found === undefined) return undefined;
984
+ const [lockfile, command] = found;
985
+ const changed = [...new Set(changes.map((change) => change.package))];
986
+ let packages: readonly string[] = changed;
987
+ if (lockfile === "package-lock.json" || lockfile === "npm-shrinkwrap.json") {
988
+ const lock = readJson(host, join(directory, lockfile));
989
+ const manifest = readJson(host, join(directory, "package.json"));
990
+ const devDependencies = isRecord(manifest) && isRecord(manifest.devDependencies) ? manifest.devDependencies : {};
991
+ if (isRecord(lock)) {
992
+ packages = HUB_ENGINE_PACKAGES.filter((engine) => {
993
+ const pinned = pinString(devDependencies[engine]);
994
+ if (pinned === undefined || compareVersions(pinned, pinned) === null) return false;
995
+ const lockPackages = isRecord(lock.packages) ? lock.packages : {};
996
+ const lockDependencies = isRecord(lock.dependencies) ? lock.dependencies : {};
997
+ const entry = lockPackages[`node_modules/${engine}`] ?? lockDependencies[engine];
998
+ const locked = isRecord(entry) ? pinString(entry.version) : undefined;
999
+ return locked === undefined || compareVersions(locked, pinned) !== 0;
1000
+ });
1001
+ }
1002
+ }
1003
+ if (packages.length === 0) return undefined;
1004
+ return {
1005
+ kind: "engine-pins-changed-install-needed",
1006
+ lockfile,
1007
+ command,
1008
+ packages,
1009
+ note: `${lockfile} does not resolve the pinned ${packages.join(" and ")} yet; run \`${command}\` in the hub, then commit package.json together with ${lockfile}`,
1010
+ };
639
1011
  }
640
1012
 
641
1013
  function copySkeleton(host: WorkspaceHost, skeletonRoot: string, plan: WorkspacePlanCreate): void {
@@ -670,8 +1042,52 @@ function assertCleanTree(host: WorkspaceHost, directory: string): void {
670
1042
  }
671
1043
  }
672
1044
 
673
- function adoptHubFiles(host: WorkspaceHost, skeletonRoot: string, plan: WorkspacePlan & { advisorVersion: string }): void {
1045
+ /**
1046
+ * An inventory document Launcher composed (from a choice, or a merge),
1047
+ * checked again at write time by the same function every later read of it
1048
+ * uses, so a document Launcher writes is always one Launcher reads back.
1049
+ */
1050
+ function revalidatedDocument(document: string, hubOwner: string, label = "the chosen inventory"): string {
1051
+ const validated = validateInventoryDocument(document, { hubOwner });
1052
+ if (!validated.valid) throw new Error(`${label} ${validated.reason}`);
1053
+ return document;
1054
+ }
1055
+
1056
+ function adoptHubFiles(host: WorkspaceHost, skeletonRoot: string, plan: WorkspacePlanAdopt): readonly EnginePinChange[] {
674
1057
  assertCleanTree(host, plan.directory);
1058
+ // Resolve and strictly re-validate the inventory document BEFORE writing
1059
+ // anything, including the hub marker -- planWorkspace already validated it
1060
+ // once, but re-checking here (rather than trusting the earlier result)
1061
+ // means a document that changed on disk between plan and apply still
1062
+ // cannot land a mismatched shape, and it means this function alone
1063
+ // guarantees "fail before any file is touched" (#1334).
1064
+ let inventoryDocument: string | Uint8Array | undefined;
1065
+ if (plan.action === "adopt" && plan.chosenInventory !== undefined) {
1066
+ if (plan.chosenInventory.kind === "write") inventoryDocument = revalidatedDocument(plan.chosenInventory.document, plan.owner);
1067
+ } else if (plan.action === "adopt" && plan.mergedInventoryDocument !== undefined) {
1068
+ inventoryDocument = revalidatedDocument(plan.mergedInventoryDocument, plan.owner, "the merged inventory");
1069
+ } else if (plan.action === "adopt" && plan.mergedInventoryRepositories !== undefined) {
1070
+ // A plan carrying the merged entries without the rendered document: each entry is written whole, `packages`
1071
+ // included, in the same layout renderInventoryDocument() writes; the contract check decides whether it is valid.
1072
+ const repositories = plan.mergedInventoryRepositories;
1073
+ inventoryDocument = revalidatedDocument(`${JSON.stringify({ schemaVersion: 1, repositories }, null, 2)}\n`, plan.owner, "the merged inventory");
1074
+ } else if ("mergedInventoryIds" in plan && Array.isArray(plan.mergedInventoryIds)) {
1075
+ // A plan built by hand with ids only: there are no entries to keep, so each id is written alone.
1076
+ inventoryDocument = revalidatedDocument(renderInventoryDocument(plan.mergedInventoryIds.map((id) => ({ id }))), plan.owner, "the merged inventory");
1077
+ } else if ("inventorySource" in plan && typeof plan.inventorySource === "string") {
1078
+ const raw = host.readBytes(plan.inventorySource);
1079
+ if (raw === null) throw new Error(`inventory source is not readable: ${plan.inventorySource}`);
1080
+ const validated = validateInventoryDocument(raw, { hubOwner: plan.owner });
1081
+ if (!validated.valid) throw new Error(`inventory source at ${plan.inventorySource} ${validated.reason}`);
1082
+ if (validated.ids.length === 0) {
1083
+ throw new Error(`inventory source at ${plan.inventorySource} must be a populated inventory document`);
1084
+ }
1085
+ // Copied byte for byte: the bytes just validated are the bytes written.
1086
+ inventoryDocument = withFinalNewline(raw);
1087
+ }
1088
+ // The manifest is read and checked here too, for the same reason: mergeHubEnginePins refuses it only after
1089
+ // the marker and the composed files are written, which would leave a half-appointed checkout.
1090
+ assertManifestAppointable(host, plan.directory, skeletonRoot);
675
1091
  const marker = {
676
1092
  schemaVersion: 1,
677
1093
  kind: "account-hub",
@@ -679,21 +1095,8 @@ function adoptHubFiles(host: WorkspaceHost, skeletonRoot: string, plan: Workspac
679
1095
  repository: `${plan.owner}/${plan.repository}`,
680
1096
  };
681
1097
  writeSkeletonFile(host, plan.directory, WORKSPACE_MARKER_REL, `${JSON.stringify(marker, null, 2)}\n`);
682
- if ("mergedInventoryIds" in plan && Array.isArray(plan.mergedInventoryIds)) {
683
- const document = { schemaVersion: 1, repositories: plan.mergedInventoryIds.map((id) => ({ id })) };
684
- writeSkeletonFile(
685
- host,
686
- plan.directory,
687
- WORKSPACE_INVENTORY_REL,
688
- `${JSON.stringify(document, null, 2)}\n`,
689
- );
690
- } else if ("inventorySource" in plan && typeof plan.inventorySource === "string") {
691
- const raw = host.readText(plan.inventorySource);
692
- if (raw === null || inspectInventory(raw).status !== "populated") {
693
- throw new Error("inventory source is not a populated inventory document");
694
- }
695
- writeSkeletonFile(host, plan.directory, WORKSPACE_INVENTORY_REL, raw.endsWith("\n") ? raw : `${raw}\n`);
696
- }
1098
+ if (typeof inventoryDocument === "string") writeSkeletonFile(host, plan.directory, WORKSPACE_INVENTORY_REL, inventoryDocument);
1099
+ else if (inventoryDocument !== undefined) writeSkeletonBytes(host, plan.directory, WORKSPACE_INVENTORY_REL, inventoryDocument);
697
1100
  if (host.readText(join(plan.directory, "AGENTS.md")) === null) {
698
1101
  writeSkeletonFile(host, plan.directory, "AGENTS.md", CONSUMER_AGENTS_MD);
699
1102
  } else {
@@ -710,7 +1113,15 @@ function adoptHubFiles(host: WorkspaceHost, skeletonRoot: string, plan: Workspac
710
1113
  const ignore = host.readText(join(skeletonRoot, ".gitignore"));
711
1114
  if (ignore !== null) writeSkeletonFile(host, plan.directory, ".gitignore", ignore);
712
1115
  }
713
- mergeAdvisorPin(host, plan.directory, skeletonRoot, plan.advisorVersion, plan.owner, plan.repository);
1116
+ return mergeHubEnginePins(
1117
+ host,
1118
+ plan.directory,
1119
+ skeletonRoot,
1120
+ { advisorVersion: plan.advisorVersion, integratorVersion: plan.integratorVersion },
1121
+ plan.owner,
1122
+ plan.repository,
1123
+ "appoint",
1124
+ );
714
1125
  }
715
1126
 
716
1127
  function requireZero(result: CommandResult, label: string): void {
@@ -721,9 +1132,11 @@ function requireZero(result: CommandResult, label: string): void {
721
1132
 
722
1133
  /**
723
1134
  * Read-only pin and inventory report. Does not install or uninstall. Scans all
724
- * four dependency buckets; grades each pinned advisor version against the live
725
- * registry version, marking a pin older than live as a stale-pin finding and a
726
- * degraded report.
1135
+ * four dependency buckets for both hub engines (Advisor and Integrator);
1136
+ * grades each pinned version against that engine's live registry version,
1137
+ * when known, marking a pin older than live as a stale-pin finding and a
1138
+ * degraded report. `liveIntegratorVersion` is the last parameter so earlier
1139
+ * callers keep their argument positions.
727
1140
  */
728
1141
  export function reportHubHealth(
729
1142
  host: WorkspaceHost,
@@ -732,78 +1145,123 @@ export function reportHubHealth(
732
1145
  liveLauncherVersion?: string,
733
1146
  retiredThisRun: readonly string[] = [],
734
1147
  migration?: HubHealthReport["migration"],
1148
+ liveIntegratorVersion?: string,
735
1149
  ): HubHealthReport {
736
1150
  const extra = new Set<string>();
737
- const pins: Partial<Record<DependencyBucket, string>> = {};
1151
+ const pins: Record<HubEnginePackage, Partial<Record<DependencyBucket, string>>> = {
1152
+ [ADVISOR_PACKAGE]: {},
1153
+ [INTEGRATOR_PACKAGE]: {},
1154
+ };
738
1155
  const manifestRaw = host.readText(join(directory, "package.json"));
739
1156
  if (manifestRaw !== null) {
740
1157
  try {
741
1158
  const parsed: unknown = JSON.parse(manifestRaw);
742
1159
  if (isRecord(parsed)) {
743
1160
  for (const bucket of DEPENDENCY_BUCKETS) {
744
- const pin = clossysNames(parsed[bucket], extra);
745
- if (pin !== undefined) pins[bucket] = pin;
1161
+ const found = enginePinsIn(parsed[bucket], extra);
1162
+ for (const engine of HUB_ENGINE_PACKAGES) {
1163
+ const pinned = found[engine];
1164
+ if (pinned !== undefined) pins[engine][bucket] = pinned;
1165
+ }
746
1166
  }
747
1167
  }
748
1168
  } catch {
749
1169
  /* unreadable manifest is reported as missing pins */
750
1170
  }
751
1171
  }
752
- const pinFindings: PinFinding[] = Object.entries(pins).flatMap(([bucket, pinned]): PinFinding[] => {
753
- if (liveAdvisorVersion === undefined) return [];
754
- const comparison = compareVersions(pinned, liveAdvisorVersion);
755
- if (comparison === null) {
756
- return [{ bucket: bucket as DependencyBucket, pinned, grade: "indeterminate", note: `cannot compare ${pinned} with live ${liveAdvisorVersion}` }];
757
- }
758
- return comparison < 0
759
- ? [{ bucket: bucket as DependencyBucket, pinned, grade: "stale", note: `pinned ${pinned} is older than live ${liveAdvisorVersion}` }]
760
- : [];
761
- });
1172
+ const live: Record<HubEnginePackage, string | undefined> = {
1173
+ [ADVISOR_PACKAGE]: liveAdvisorVersion,
1174
+ [INTEGRATOR_PACKAGE]: liveIntegratorVersion,
1175
+ };
1176
+ const pinFindings: PinFinding[] = HUB_ENGINE_PACKAGES.flatMap((engine) =>
1177
+ DEPENDENCY_BUCKETS.flatMap((bucket): PinFinding[] => {
1178
+ const pinned = pins[engine][bucket];
1179
+ const liveVersion = live[engine];
1180
+ if (pinned === undefined || liveVersion === undefined) return [];
1181
+ const comparison = compareVersions(pinned, liveVersion);
1182
+ if (comparison === null) {
1183
+ return [{ package: engine, bucket, pinned, grade: "indeterminate", note: `cannot compare ${pinned} with live ${liveVersion}` }];
1184
+ }
1185
+ return comparison < 0
1186
+ ? [{ package: engine, bucket, pinned, grade: "stale", note: `pinned ${pinned} is older than live ${liveVersion}` }]
1187
+ : [];
1188
+ }),
1189
+ );
1190
+ const pinCount = (engine: HubEnginePackage): number => Object.values(pins[engine]).filter((value) => value !== undefined).length;
1191
+ const misplaced = (engine: HubEnginePackage): boolean =>
1192
+ pins[engine].devDependencies === undefined ||
1193
+ pins[engine].dependencies !== undefined ||
1194
+ pins[engine].optionalDependencies !== undefined ||
1195
+ pins[engine].peerDependencies !== undefined;
1196
+ const dualPin = HUB_ENGINE_PACKAGES.some((engine) => pinCount(engine) > 1);
762
1197
  const skillsManifest = summarizeSkillsManifest(
763
1198
  parseSkillManifest(host.readText(join(directory, SKILLS_MANIFEST_REL))),
764
1199
  liveLauncherVersion,
765
1200
  retiredThisRun,
766
1201
  );
1202
+ const enginePin = (engine: HubEnginePackage): HubEnginePin => {
1203
+ const found = pins[engine];
1204
+ const liveVersion = live[engine];
1205
+ return {
1206
+ ...(found.dependencies === undefined ? {} : { dependencies: found.dependencies }),
1207
+ ...(found.devDependencies === undefined ? {} : { devDependencies: found.devDependencies }),
1208
+ ...(found.optionalDependencies === undefined ? {} : { optionalDependencies: found.optionalDependencies }),
1209
+ ...(found.peerDependencies === undefined ? {} : { peerDependencies: found.peerDependencies }),
1210
+ ...(liveVersion === undefined ? {} : { live: liveVersion }),
1211
+ };
1212
+ };
1213
+ const inventory = inspectInventory(host.readBytes(join(directory, WORKSPACE_INVENTORY_REL)), readHub(host, directory)?.owner);
767
1214
  return {
768
1215
  marker: readHub(host, directory) === undefined ? "missing" : "present",
769
- inventory: inspectInventory(host.readText(join(directory, WORKSPACE_INVENTORY_REL))),
770
- advisorPin: {
771
- ...(pins.dependencies === undefined ? {} : { dependencies: pins.dependencies }),
772
- ...(pins.devDependencies === undefined ? {} : { devDependencies: pins.devDependencies }),
773
- ...(pins.optionalDependencies === undefined ? {} : { optionalDependencies: pins.optionalDependencies }),
774
- ...(pins.peerDependencies === undefined ? {} : { peerDependencies: pins.peerDependencies }),
775
- ...(liveAdvisorVersion === undefined ? {} : { live: liveAdvisorVersion }),
776
- },
777
- dualPin: Object.values(pins).filter((value) => value !== undefined).length > 1,
1216
+ inventory,
1217
+ advisorPin: enginePin(ADVISOR_PACKAGE),
1218
+ integratorPin: enginePin(INTEGRATOR_PACKAGE),
1219
+ dualPin,
778
1220
  extraClossys: [...extra].sort(),
779
1221
  pinFindings,
780
1222
  degraded:
781
1223
  pinFindings.some((finding) => finding.grade === "stale") ||
782
- Object.values(pins).filter((value) => value !== undefined).length > 1 ||
783
- pins.devDependencies === undefined ||
784
- pins.dependencies !== undefined ||
785
- pins.optionalDependencies !== undefined ||
786
- pins.peerDependencies !== undefined,
1224
+ dualPin ||
1225
+ HUB_ENGINE_PACKAGES.some(misplaced) ||
1226
+ inventory.status === "invalid",
787
1227
  ...(migration === undefined ? {} : { migration }),
788
1228
  skillsManifest,
789
1229
  };
790
1230
  }
791
1231
 
792
- export function formatHubHealth(report: HubHealthReport): string {
1232
+ function formatEnginePin(pin: HubEnginePin): string {
793
1233
  const pinParts: string[] = [];
794
1234
  for (const bucket of DEPENDENCY_BUCKETS) {
795
- const pinned = report.advisorPin[bucket];
1235
+ const pinned = pin[bucket];
796
1236
  if (pinned !== undefined) pinParts.push(`${bucket} ${pinned}`);
797
1237
  }
798
- const pin = pinParts.length === 0 ? "missing" : pinParts.join(" and ");
799
- const live = report.advisorPin.live === undefined ? "" : `; live ${report.advisorPin.live}`;
1238
+ const pinned = pinParts.length === 0 ? "missing" : pinParts.join(" and ");
1239
+ return `${pinned}${pin.live === undefined ? "" : `; live ${pin.live}`}`;
1240
+ }
1241
+
1242
+ export function formatHubHealth(report: HubHealthReport): string {
800
1243
  const extra = report.extraClossys.length === 0 ? "none" : report.extraClossys.join(", ");
801
1244
  const inventory =
802
- report.inventory.status === "populated" ? `populated (${report.inventory.count})` : report.inventory.status;
1245
+ report.inventory.status === "populated"
1246
+ ? `populated (${report.inventory.count})`
1247
+ : report.inventory.status === "invalid"
1248
+ ? `invalid${report.inventory.reason === undefined ? "" : ` -- ${report.inventory.reason}`}`
1249
+ : report.inventory.status;
803
1250
  const findings = report.pinFindings.map((finding) =>
804
- finding.note !== undefined ? `${finding.bucket} ${finding.note}` : `${finding.bucket} ${finding.grade}`,
1251
+ finding.note !== undefined
1252
+ ? `${finding.package} ${finding.bucket} ${finding.note}`
1253
+ : `${finding.package} ${finding.bucket} ${finding.grade}`,
805
1254
  );
806
1255
  const findingLine = findings.length === 0 ? "none" : findings.join("; ");
1256
+ const enginePinLines =
1257
+ report.enginePins === undefined
1258
+ ? []
1259
+ : [
1260
+ `engine pins changed in package.json: ${report.enginePins.changed.map(describeEnginePinChange).join("; ")}`,
1261
+ `next: ${report.enginePins.nextStep}`,
1262
+ ];
1263
+ const installLine =
1264
+ report.installNeeded === undefined ? [] : [`install needed (${report.installNeeded.kind}): ${report.installNeeded.note}`];
807
1265
  const skillParts: string[] = [];
808
1266
  if (report.skillComposition !== undefined) {
809
1267
  skillParts.push(
@@ -812,17 +1270,28 @@ export function formatHubHealth(report: HubHealthReport): string {
812
1270
  : `skills composed: ${report.skillComposition.composed.map((name) => `clossys-${name}`).join(", ")}`,
813
1271
  );
814
1272
  for (const skip of report.skillComposition.skipped) {
1273
+ // skip.packageDir names one of this package's own known package directories, never document text (see skills.ts).
815
1274
  skillParts.push(`skill skipped (${skip.packageDir}): ${skip.note}`);
816
1275
  }
1276
+ // rosterTargets and siblings' inventoryId are already position-safe by
1277
+ // the time they reach here: composeSkillRoster redacts every
1278
+ // stored-inventory id to `inventoryPositionLabel()`'s position label
1279
+ // before putting it in this report, because this whole report is also
1280
+ // JSON-dumped into the `health:` line below -- an id left raw in the
1281
+ // structured report would still reach the message that way even if this
1282
+ // prose line named it safely.
817
1283
  if (report.skillComposition.rosterTargets !== undefined && report.skillComposition.rosterTargets.length > 0) {
818
1284
  skillParts.push(`skill roster written: ${report.skillComposition.rosterTargets.join(", ")}`);
819
1285
  }
820
- for (const skip of report.skillComposition.rosterSkipped ?? []) {
821
- skillParts.push(`skill roster skipped (${skip.inventoryId}): ${skip.note}`);
1286
+ for (const sibling of report.skillComposition.siblings ?? []) {
1287
+ skillParts.push(`sibling (${sibling.inventoryId}): ${sibling.note}`);
822
1288
  }
823
1289
  if (report.skillComposition.retired !== undefined && report.skillComposition.retired.length > 0) {
824
1290
  skillParts.push(`skills retired: ${report.skillComposition.retired.map((name) => `clossys-${name}`).join(", ")}`);
825
1291
  }
1292
+ for (const kept of report.skillComposition.preserved ?? []) {
1293
+ skillParts.push(`skill preserved (clossys-${kept.packageDir}, not ${kept.action === "rewrite" ? "rewritten" : "retired"}): ${kept.note}`);
1294
+ }
826
1295
  }
827
1296
  const skillsManifestLine =
828
1297
  report.skillsManifest === undefined
@@ -841,14 +1310,17 @@ export function formatHubHealth(report: HubHealthReport): string {
841
1310
  ? undefined
842
1311
  : report.inventoryDrift.status === "indeterminate"
843
1312
  ? `inventory drift: indeterminate${report.inventoryDrift.note === undefined ? "" : ` -- ${report.inventoryDrift.note}`}`
844
- : `inventory drift: external-only ${report.inventoryDrift.externalOnly.length}, launcher-only ${report.inventoryDrift.launcherOnly.length}, agreeing ${report.inventoryDrift.agreeing.length}`;
1313
+ : `inventory drift: external-only ${report.inventoryDrift.externalOnly.count}, launcher-only ${report.inventoryDrift.launcherOnly.count}, agreeing ${report.inventoryDrift.agreeing.count}`;
845
1314
  return [
846
1315
  `hub marker: ${report.marker}`,
847
1316
  `inventory: ${inventory}`,
848
- `advisor pin: ${pin}${live}`,
1317
+ `advisor pin: ${formatEnginePin(report.advisorPin)}`,
1318
+ `integrator pin: ${formatEnginePin(report.integratorPin)}`,
849
1319
  `dual pin: ${report.dualPin ? "yes" : "no"}`,
850
1320
  `extra @clossys/*: ${extra}`,
851
1321
  `pin findings: ${findingLine}`,
1322
+ ...enginePinLines,
1323
+ ...installLine,
852
1324
  `degraded: ${report.degraded ? "yes" : "no"}`,
853
1325
  ...(migrationLine === undefined ? [] : [migrationLine]),
854
1326
  ...(linkedHostsLine === undefined ? [] : [linkedHostsLine]),
@@ -863,20 +1335,36 @@ function withHealth(
863
1335
  host: WorkspaceHost,
864
1336
  directory: string,
865
1337
  headline: string,
866
- liveAdvisorVersion?: string,
867
- skillComposition?: SkillCompositionResult & { linkedHosts?: readonly DiscoveredHost[] },
1338
+ liveEngines: HubEngineVersions,
1339
+ skillComposition?: SkillCompositionResult & {
1340
+ linkedHosts?: readonly DiscoveredHost[];
1341
+ },
868
1342
  liveLauncherVersion?: string,
869
1343
  migration?: HubHealthReport["migration"],
870
1344
  inventoryDrift?: HubHealthReport["inventoryDrift"],
1345
+ enginePinChanges: readonly EnginePinChange[] = [],
871
1346
  ): WorkspaceApplyResult {
872
- const base = reportHubHealth(host, directory, liveAdvisorVersion, liveLauncherVersion, skillComposition?.retired ?? [], migration);
873
- const rosterSkipped = skillComposition?.rosterSkipped ?? [];
1347
+ const base = reportHubHealth(
1348
+ host,
1349
+ directory,
1350
+ liveEngines.advisorVersion,
1351
+ liveLauncherVersion,
1352
+ skillComposition?.retired ?? [],
1353
+ migration,
1354
+ liveEngines.integratorVersion,
1355
+ );
1356
+ const preserved = skillComposition?.preserved ?? [];
1357
+ const installNeeded = engineInstallFinding(host, directory, enginePinChanges);
874
1358
  const health: HubHealthReport = {
875
1359
  ...base,
1360
+ ...(enginePinChanges.length === 0
1361
+ ? {}
1362
+ : { enginePins: { changed: enginePinChanges, nextStep: enginePinNextStep(host, directory) } }),
1363
+ ...(installNeeded === undefined ? {} : { installNeeded }),
876
1364
  ...(skillComposition === undefined ? {} : { skillComposition }),
877
1365
  ...(skillComposition?.linkedHosts === undefined ? {} : { linkedHosts: skillComposition.linkedHosts }),
878
1366
  ...(inventoryDrift === undefined || inventoryDrift.status === "no-external-source" ? {} : { inventoryDrift }),
879
- degraded: base.degraded || rosterSkipped.length > 0,
1367
+ degraded: base.degraded || preserved.length > 0 || installNeeded !== undefined,
880
1368
  };
881
1369
  return {
882
1370
  state: "satisfied",
@@ -891,8 +1379,8 @@ function withHealth(
891
1379
  * a note; marks ids whose check fails as unknown. Never mutates the inventory.
892
1380
  */
893
1381
  export function checkInventoryEntries(host: WorkspaceHost, directory: string): InventoryValidationReport {
894
- const raw = host.readText(join(directory, WORKSPACE_INVENTORY_REL));
895
- const observation = inspectInventory(raw);
1382
+ const hubOwner = readHub(host, directory)?.owner;
1383
+ const observation = inspectInventory(host.readBytes(join(directory, WORKSPACE_INVENTORY_REL)), hubOwner);
896
1384
  if (observation.status !== "populated") {
897
1385
  return { entries: [], skipped: true, note: `inventory is ${observation.status}; nothing to validate` };
898
1386
  }
@@ -900,7 +1388,7 @@ export function checkInventoryEntries(host: WorkspaceHost, directory: string): I
900
1388
  if (!available) {
901
1389
  return { entries: [], skipped: true, note: "`gh` is unavailable; inventory ids were not validated" };
902
1390
  }
903
- const ids = readInventoryRepositories(host, join(directory, WORKSPACE_INVENTORY_REL), "the hub inventory").filter(
1391
+ const ids = readInventoryRepositories(host, join(directory, WORKSPACE_INVENTORY_REL), "the hub inventory", hubOwner).filter(
904
1392
  (id) => id !== "",
905
1393
  );
906
1394
  const batch = 20;
@@ -924,6 +1412,7 @@ function shouldRefreshConsumerAgents(existing: string | null): boolean {
924
1412
  if (existing === null) return true;
925
1413
  if (existing === CONSUMER_AGENTS_MD) return false;
926
1414
  if (existing === LEGACY_CONSUMER_AGENTS_MD) return true;
1415
+ if (existing === SIBLING_COMPOSING_CONSUMER_AGENTS_MD) return true;
927
1416
  if (existing.includes("Run `npx @clossys/launcher` again to resume")) return true;
928
1417
  return false;
929
1418
  }
@@ -934,125 +1423,207 @@ function writeConsumerAgentsIfNeeded(host: WorkspaceHost, directory: string): vo
934
1423
  writeSkeletonFile(host, directory, "AGENTS.md", CONSUMER_AGENTS_MD);
935
1424
  }
936
1425
 
937
- function writeSisterConsumerAgentsIfNeeded(host: WorkspaceHost, directory: string): void {
938
- const existing = host.readText(join(directory, "AGENTS.md"));
939
- if (existing !== null && existing.trim() !== "" && existing !== SISTER_CONSUMER_AGENTS_MD) return;
940
- writeSkeletonFile(host, directory, "AGENTS.md", SISTER_CONSUMER_AGENTS_MD);
1426
+ /** What a hub run reports for each inventoried repository other than the hub. It never writes into one. */
1427
+ const SETUP_PULL_REQUEST_NOTE =
1428
+ "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";
1429
+ const BESIDE_HUB_NOTE = `checkout beside the hub; ${SETUP_PULL_REQUEST_NOTE}`;
1430
+ const CLONE_NOT_BESIDE_HUB_NOTE = `not cloned beside the hub; ${SETUP_PULL_REQUEST_NOTE}`;
1431
+
1432
+ /**
1433
+ * Splits an inventory id into owner/repository, trusting a caller that
1434
+ * already validated it with `isValidInventoryId` (every id reaching here
1435
+ * comes from a document `validateInventoryDocument` already accepted)
1436
+ * rather than re-deriving that rule -- but still runs it, so a caller that
1437
+ * somehow supplies an unvalidated id gets `null`, never a wrong split.
1438
+ */
1439
+ function parseInventoryRepositoryId(id: string, hubOwner: string): { owner: string; repository: string } | null {
1440
+ if (!isValidInventoryId(id)) return null;
1441
+ const slash = id.indexOf("/");
1442
+ if (slash === -1) return { owner: hubOwner, repository: id };
1443
+ return { owner: id.slice(0, slash), repository: id.slice(slash + 1) };
941
1444
  }
942
1445
 
943
- const CLONE_NOT_BESIDE_HUB_NOTE =
944
- "clone not next to the hub; voices appear here after this repository is cloned beside the hub and launcher resumes";
1446
+ /** A checkout's origin as `owner/name`, when it is a github.com repository. */
1447
+ function originRepository(host: WorkspaceHost, directory: string): string | undefined {
1448
+ const result = host.run("git", ["remote", "get-url", "origin"], { cwd: directory });
1449
+ const remote = result.status === 0 ? parseGitHubRemote(result.stdout.trim()) : null;
1450
+ return remote === null ? undefined : `${remote.owner}/${remote.repository}`;
1451
+ }
945
1452
 
946
- function parseInventoryRepositoryId(id: string, hubOwner: string): { owner: string; repository: string } | null {
947
- const trimmed = id.trim();
948
- if (trimmed === "") return null;
949
- if (trimmed.includes("/")) {
950
- const slash = trimmed.indexOf("/");
951
- const owner = trimmed.slice(0, slash);
952
- const repository = trimmed.slice(slash + 1);
953
- if (!OWNER.test(owner) || !REPO.test(repository)) return null;
954
- return { owner, repository };
955
- }
956
- if (!REPO.test(trimmed)) return null;
957
- return { owner: hubOwner, repository: trimmed };
1453
+ /**
1454
+ * The hub's own repository identity: its origin's `owner/name`, or, when
1455
+ * the checkout has no github.com origin, the repository its hub marker
1456
+ * records. Never its folder path (see identity.ts).
1457
+ */
1458
+ function hubRepositoryIdentity(host: WorkspaceHost, hubDirectory: string): string | undefined {
1459
+ return originRepository(host, hubDirectory) ?? readHub(host, hubDirectory)?.repository;
958
1460
  }
959
1461
 
960
- function resolveSisterCloneTargets(
961
- host: WorkspaceHost,
962
- hubDirectory: string,
963
- hubOwner: string,
964
- ): {
965
- readonly targets: readonly { readonly inventoryId: string; readonly directory: string }[];
966
- readonly skipped: readonly { readonly inventoryId: string; readonly note: string }[];
967
- } {
1462
+ /** Where an inventoried repository other than the hub stands, as a hub run sees it. */
1463
+ type SiblingStatus =
1464
+ | "beside-the-hub"
1465
+ | "not-cloned"
1466
+ | "other-account"
1467
+ | "foundry-supplier-tree"
1468
+ | "origin-mismatch"
1469
+ | "not-a-git-checkout"
1470
+ | "git-refused"
1471
+ | "invalid-id"
1472
+ | "inventory-invalid";
1473
+
1474
+ /**
1475
+ * `inventoryId` stays on every entry below for internal use only (a
1476
+ * `gh repo clone` argument, a directory lookup, a structured result a
1477
+ * caller inspects programmatically) -- never put directly in text a founder
1478
+ * or agent reads. `position` is the id's 0-based index in the stored
1479
+ * inventory's own `repositories` array; every message built from these
1480
+ * entries (skill-roster lines, `--clone-missing` output) names that
1481
+ * position instead, per the rule at the top of ./inventory-choice.ts.
1482
+ * `position` is `undefined` only for the one entry below that is not a
1483
+ * document id at all, the stored-inventory-file-itself failure.
1484
+ */
1485
+ interface InventoriedSibling {
1486
+ readonly inventoryId: string;
1487
+ readonly position?: number;
1488
+ readonly status: SiblingStatus;
1489
+ readonly note: string;
1490
+ }
1491
+
1492
+ /**
1493
+ * Classifies every inventoried repository other than the hub. Read-only:
1494
+ * it looks for a checkout beside the hub and reads that checkout's git
1495
+ * origin, and nothing else, so a sibling's working tree, legacy output or
1496
+ * old pins can neither change the result nor be changed by it. The hub run
1497
+ * reports the result; only `--clone-missing` acts on it, and only on
1498
+ * `not-cloned` entries.
1499
+ */
1500
+ function classifyInventoriedSiblings(host: WorkspaceHost, hubDirectory: string, hubOwner: string): readonly InventoriedSibling[] {
968
1501
  const parent = dirname(resolve(hubDirectory));
969
- const hubResolved = resolve(hubDirectory);
970
- const skipped: { inventoryId: string; note: string }[] = [];
971
- const targets: { inventoryId: string; directory: string }[] = [];
1502
+ const hubIdentity = hubRepositoryIdentity(host, hubDirectory);
1503
+ const siblings: InventoriedSibling[] = [];
972
1504
  const inventoryPath = join(hubDirectory, WORKSPACE_INVENTORY_REL);
973
1505
  let inventoryIds: readonly string[];
974
1506
  try {
975
- inventoryIds = readInventoryRepositories(host, inventoryPath, "the hub inventory");
976
- } catch {
977
- return { targets: [], skipped: [] };
1507
+ inventoryIds = readInventoryRepositories(host, inventoryPath, "the stored inventory", hubOwner);
1508
+ } catch (error) {
1509
+ // An invalid stored inventory is reported, never silently read for what
1510
+ // it happens to look like -- no clone is attempted from it (#1334).
1511
+ // WORKSPACE_INVENTORY_REL is Launcher's own constant path, not document
1512
+ // content, so it is safe to name directly; there is no per-id position
1513
+ // for a failure at the file level, so `position` stays undefined.
1514
+ const reason = error instanceof Error ? error.message : String(error);
1515
+ return [{ inventoryId: WORKSPACE_INVENTORY_REL, status: "inventory-invalid", note: reason }];
978
1516
  }
979
- for (const id of inventoryIds) {
980
- if (id === "") continue;
1517
+ for (const [position, id] of inventoryIds.entries()) {
981
1518
  const parsed = parseInventoryRepositoryId(id, hubOwner);
982
1519
  if (parsed === null) {
983
- skipped.push({ inventoryId: id, note: "inventory id is not a valid repository slug" });
1520
+ siblings.push({ inventoryId: id, position, status: "invalid-id", note: "inventory id is not a valid repository slug" });
984
1521
  continue;
985
1522
  }
986
- if (parsed.owner !== hubOwner) {
987
- skipped.push({ inventoryId: id, note: "other account; not this roster" });
1523
+ if (!belongsToOwner(id, hubOwner)) {
1524
+ siblings.push({ inventoryId: id, position, status: "other-account", note: "other account; not this roster" });
988
1525
  continue;
989
1526
  }
1527
+ // The hub itself is recognised by repository identity, not by folder path.
1528
+ if (hubIdentity !== undefined && sameRepository(id, hubIdentity, hubOwner)) continue;
990
1529
  const candidate = join(parent, parsed.repository);
991
- const candidateResolved = resolve(candidate);
992
- if (candidateResolved === hubResolved) continue;
993
1530
  if (!host.exists(candidate) || !host.isDirectory(candidate)) {
994
- skipped.push({ inventoryId: id, note: CLONE_NOT_BESIDE_HUB_NOTE });
1531
+ siblings.push({ inventoryId: id, position, status: "not-cloned", note: CLONE_NOT_BESIDE_HUB_NOTE });
995
1532
  continue;
996
1533
  }
997
1534
  if (looksLikeFoundry(host, candidate)) {
998
- skipped.push({ inventoryId: id, note: "foundry supplier tree; skills are not written here" });
1535
+ siblings.push({ inventoryId: id, position, status: "foundry-supplier-tree", note: "foundry supplier tree; skills are not written here" });
999
1536
  continue;
1000
1537
  }
1001
- const originResult = host.run("git", ["remote", "get-url", "origin"], { cwd: candidate });
1002
- const originUrl = originResult.status === 0 ? originResult.stdout.trim() : "";
1003
- const remote = originUrl === "" ? null : parseGitHubRemote(originUrl);
1004
- if (remote === null || remote.owner !== parsed.owner || remote.repository !== parsed.repository) {
1005
- skipped.push({ inventoryId: id, note: "git origin does not match inventory id" });
1538
+ if (!host.exists(join(candidate, ".git"))) {
1539
+ siblings.push({
1540
+ inventoryId: id,
1541
+ position,
1542
+ status: "not-a-git-checkout",
1543
+ note: "the folder beside the hub with this name is not a git checkout, so it cannot be matched to this inventory id",
1544
+ });
1006
1545
  continue;
1007
1546
  }
1008
- targets.push({ inventoryId: id, directory: candidate });
1547
+ const remote = host.run("git", ["remote", "get-url", "origin"], { cwd: candidate });
1548
+ if (remote.status !== 0 && /dubious ownership/i.test(remote.stderr)) {
1549
+ siblings.push({
1550
+ inventoryId: id,
1551
+ position,
1552
+ status: "git-refused",
1553
+ note: "git refuses to read this checkout (it reports dubious ownership), so its origin could not be matched to this inventory id",
1554
+ });
1555
+ continue;
1556
+ }
1557
+ const parsedOrigin = remote.status === 0 ? parseGitHubRemote(remote.stdout.trim()) : null;
1558
+ const origin = parsedOrigin === null ? undefined : `${parsedOrigin.owner}/${parsedOrigin.repository}`;
1559
+ if (origin === undefined || !sameRepository(origin, id, hubOwner)) {
1560
+ siblings.push({ inventoryId: id, position, status: "origin-mismatch", note: "git origin does not match inventory id" });
1561
+ continue;
1562
+ }
1563
+ siblings.push({ inventoryId: id, position, status: "beside-the-hub", note: BESIDE_HUB_NOTE });
1009
1564
  }
1010
- return { targets, skipped };
1565
+ return siblings;
1566
+ }
1567
+
1568
+ /** 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. */
1569
+ export function inventoryPositionLabel(inventoryId: string, position: number | undefined): string {
1570
+ return position === undefined ? inventoryId : `repositories[${position}] in the stored inventory`;
1011
1571
  }
1012
1572
 
1013
1573
  export interface CloneMissingOutcome {
1574
+ /** Internal use only (a `gh repo clone` argument, a caller's own lookup) -- never put directly in a printed message; see `position`. */
1014
1575
  readonly inventoryId: string;
1576
+ /** This id's 0-based position in the stored inventory's `repositories` array; what a message names instead of `inventoryId`. `undefined` only for the stored-inventory-file-itself failure. */
1577
+ readonly position?: number;
1015
1578
  readonly result: "cloned" | "skipped-other-reason" | "failed";
1016
1579
  readonly note: string;
1017
1580
  }
1018
1581
 
1019
1582
  /**
1020
1583
  * Explicit, approved action (#1179, the #1045 pattern): clones every
1021
- * inventoried repository that resolveSisterCloneTargets's own skip pass
1022
- * identified as "just needs a clone" (CLONE_NOT_BESIDE_HUB_NOTE), and only
1023
- * those -- every other skip reason (wrong account, foundry supplier tree,
1024
- * origin mismatch, invalid slug) is left exactly as skipped, never
1025
- * attempted. Never called from resume's default path; only from the
1026
- * --clone-missing flag. Reverses the launcher README's own no-clone
1027
- * default for exactly this one approved action.
1584
+ * inventoried repository that classifyInventoriedSiblings found not cloned
1585
+ * beside the hub, and only those -- every other reason (wrong account,
1586
+ * foundry supplier tree, origin mismatch, invalid slug, invalid inventory)
1587
+ * is reported as skipped, never attempted, and a checkout already beside
1588
+ * the hub is left out. Never called from resume's default path; only from
1589
+ * the --clone-missing flag. Reverses the launcher README's own no-clone
1590
+ * default for exactly this one approved action. Cloning is not composing:
1591
+ * a cloned repository receives its team only once it is staffed in an
1592
+ * approved plan, with that plan's setup pull request.
1028
1593
  */
1029
1594
  export function cloneMissingInventoryRepositories(
1030
1595
  host: WorkspaceHost,
1031
1596
  hubDirectory: string,
1032
1597
  hubOwner: string,
1033
1598
  ): readonly CloneMissingOutcome[] {
1034
- const { skipped } = resolveSisterCloneTargets(host, hubDirectory, hubOwner);
1035
1599
  const parent = dirname(resolve(hubDirectory));
1036
1600
  const outcomes: CloneMissingOutcome[] = [];
1037
- for (const skip of skipped) {
1038
- if (skip.note !== CLONE_NOT_BESIDE_HUB_NOTE) {
1039
- outcomes.push({ inventoryId: skip.inventoryId, result: "skipped-other-reason", note: skip.note });
1601
+ for (const skip of classifyInventoriedSiblings(host, hubDirectory, hubOwner)) {
1602
+ if (skip.status === "beside-the-hub") continue;
1603
+ if (skip.status !== "not-cloned") {
1604
+ outcomes.push({ inventoryId: skip.inventoryId, position: skip.position, result: "skipped-other-reason", note: skip.note });
1040
1605
  continue;
1041
1606
  }
1042
1607
  const parsed = parseInventoryRepositoryId(skip.inventoryId, hubOwner);
1043
1608
  if (parsed === null) {
1044
- outcomes.push({ inventoryId: skip.inventoryId, result: "failed", note: "inventory id is not a valid repository slug" });
1609
+ outcomes.push({ inventoryId: skip.inventoryId, position: skip.position, result: "failed", note: "inventory id is not a valid repository slug" });
1045
1610
  continue;
1046
1611
  }
1612
+ // Cloning itself still uses the raw id and the real path -- that is the whole
1613
+ // point of this action -- but the *reported* note never repeats either: the
1614
+ // folder name comes straight from the inventory (a document), and `gh`'s own
1615
+ // stderr on failure names the repository it could not find or clone. Both are
1616
+ // fixed text instead (#1179); `position` is still how this outcome is named.
1047
1617
  const siblingPath = join(parent, parsed.repository);
1048
1618
  const result = host.run("gh", ["repo", "clone", `${hubOwner}/${parsed.repository}`, siblingPath]);
1049
1619
  if (result.status === 0) {
1050
- outcomes.push({ inventoryId: skip.inventoryId, result: "cloned", note: `cloned to ${siblingPath}` });
1620
+ outcomes.push({ inventoryId: skip.inventoryId, position: skip.position, result: "cloned", note: "cloned beside the hub" });
1051
1621
  } else {
1052
1622
  outcomes.push({
1053
1623
  inventoryId: skip.inventoryId,
1624
+ position: skip.position,
1054
1625
  result: "failed",
1055
- note: `gh repo clone exited ${result.status ?? "null"}: ${result.stderr.trim() || "no stderr"}`,
1626
+ note: `gh repo clone exited ${result.status ?? "null"}`,
1056
1627
  });
1057
1628
  }
1058
1629
  }
@@ -1127,6 +1698,16 @@ function recordLinkedHosts(host: WorkspaceHost, directory: string): readonly Dis
1127
1698
  return linkedHosts;
1128
1699
  }
1129
1700
 
1701
+ /**
1702
+ * Composes the team in the hub, and only the hub: this writes nothing into
1703
+ * any checkout beside the hub. An inventoried product repository receives
1704
+ * its skills, skills manifest, host discovery links and guidance only once
1705
+ * it is staffed in an approved plan, with that plan's setup pull request. Each inventoried
1706
+ * repository other than the hub is reported, read-only, as a sibling, named
1707
+ * by its `inventoryPositionLabel()` position -- never its raw stored
1708
+ * inventory id -- because the whole report is JSON-dumped into the apply
1709
+ * message's `health:` line (see `formatHubHealth`).
1710
+ */
1130
1711
  function composeSkillRoster(
1131
1712
  host: WorkspaceHost,
1132
1713
  hubDirectory: string,
@@ -1135,7 +1716,7 @@ function composeSkillRoster(
1135
1716
  options: { launcherPackageRoot: string; skillCatalogueRoot?: string; contractPath?: string },
1136
1717
  ): SkillCompositionResult & {
1137
1718
  readonly rosterTargets: readonly string[];
1138
- readonly rosterSkipped: readonly { readonly inventoryId: string; readonly note: string }[];
1719
+ readonly siblings: readonly { readonly inventoryId: string; readonly note: string }[];
1139
1720
  readonly linkedHosts: readonly DiscoveredHost[];
1140
1721
  } {
1141
1722
  const composeOptions = {
@@ -1148,15 +1729,11 @@ function composeSkillRoster(
1148
1729
  writeConsumerAgentsIfNeeded(host, hubDirectory);
1149
1730
  writeClossysReadme(host, hubDirectory);
1150
1731
  const hubId = hubRosterId(host, hubDirectory, hubOwner, hubRepository);
1151
- const rosterTargets: string[] = [hubId];
1152
- const { targets, skipped } = resolveSisterCloneTargets(host, hubDirectory, hubOwner);
1153
- for (const target of targets) {
1154
- recordLinkedHosts(host, target.directory);
1155
- composeSkills(host, target.directory, composeOptions);
1156
- writeSisterConsumerAgentsIfNeeded(host, target.directory);
1157
- rosterTargets.push(target.inventoryId);
1158
- }
1159
- return { ...hubSkill, rosterTargets, rosterSkipped: skipped, linkedHosts };
1732
+ const siblings = classifyInventoriedSiblings(host, hubDirectory, hubOwner)
1733
+ // An invalid stored inventory is a hub finding: the health report's inventory line names it.
1734
+ .filter((sibling) => sibling.status !== "inventory-invalid")
1735
+ .map(({ inventoryId, position, note }) => ({ inventoryId: inventoryPositionLabel(inventoryId, position), note }));
1736
+ return { ...hubSkill, rosterTargets: [hubId], siblings, linkedHosts };
1160
1737
  }
1161
1738
 
1162
1739
  function finishHubApply(
@@ -1166,11 +1743,12 @@ function finishHubApply(
1166
1743
  launcherPackageRoot: string,
1167
1744
  hubOwner: string,
1168
1745
  hubRepository: string,
1169
- liveAdvisorVersion?: string,
1746
+ liveEngines: HubEngineVersions,
1170
1747
  skillCatalogueRoot?: string,
1171
1748
  contractPath?: string,
1172
1749
  liveLauncherVersion?: string,
1173
1750
  migration?: HubHealthReport["migration"],
1751
+ enginePinChanges: readonly EnginePinChange[] = [],
1174
1752
  ): WorkspaceApplyResult {
1175
1753
  const skillComposition = composeSkillRoster(host, directory, hubOwner, hubRepository, {
1176
1754
  launcherPackageRoot,
@@ -1180,31 +1758,119 @@ function finishHubApply(
1180
1758
  // #1216: when the hub marker declares an external inventory, report drift against
1181
1759
  // it on every apply (create's fresh marker never declares one, so this is a no-op there).
1182
1760
  const hubDocument = readHub(host, directory);
1183
- const inventoryDrift = reportInventoryDrift(host, directory, hubDocument?.externalInventory, WORKSPACE_INVENTORY_REL);
1184
- return withHealth(host, directory, headline, liveAdvisorVersion, skillComposition, liveLauncherVersion, migration, inventoryDrift);
1761
+ const inventoryDrift = reportInventoryDrift(host, directory, hubDocument?.externalInventory, WORKSPACE_INVENTORY_REL, hubOwner);
1762
+ return withHealth(host, directory, headline, liveEngines, skillComposition, liveLauncherVersion, migration, inventoryDrift, enginePinChanges);
1185
1763
  }
1186
1764
 
1187
1765
  /**
1188
1766
  * Migrates a legacy `.clossys/` hub marker (and its sibling inventory, when
1189
1767
  * present) to `clossys/.state/`, then removes the old directory. Called only
1190
1768
  * when `locateHub` found the marker at the legacy path and nowhere else
1191
- * (`plan.migrateFrom === "legacy"`); a hub with markers at both paths is
1192
- * refused by `planWorkspace` before apply ever runs, so this never merges
1193
- * two hub states.
1769
+ * (`plan.migrateFrom === "legacy"`, or `classifyClonedHub` for a fresh
1770
+ * clone); a hub with markers at both paths is refused by `planWorkspace`
1771
+ * (or, for a clone, by `classifyClonedHub`) before anything is written, so
1772
+ * this never merges two hub states.
1194
1773
  */
1195
1774
  function migrateLegacyHubState(host: WorkspaceHost, directory: string): HubHealthReport["migration"] {
1196
1775
  const markerRaw = host.readText(join(directory, LEGACY_WORKSPACE_MARKER_REL));
1197
1776
  if (markerRaw === null) return undefined;
1198
1777
  writeSkeletonFile(host, directory, WORKSPACE_MARKER_REL, markerRaw.endsWith("\n") ? markerRaw : `${markerRaw}\n`);
1199
- const inventoryRaw = host.readText(join(directory, LEGACY_WORKSPACE_INVENTORY_REL));
1200
- if (inventoryRaw !== null) {
1201
- writeSkeletonFile(host, directory, WORKSPACE_INVENTORY_REL, inventoryRaw.endsWith("\n") ? inventoryRaw : `${inventoryRaw}\n`);
1202
- }
1778
+ // Moved byte for byte, so bytes that are not valid UTF-8 are still refused when read from the new place.
1779
+ const inventoryBytes = host.readBytes(join(directory, LEGACY_WORKSPACE_INVENTORY_REL));
1780
+ if (inventoryBytes !== null) writeSkeletonBytes(host, directory, WORKSPACE_INVENTORY_REL, withFinalNewline(inventoryBytes));
1203
1781
  host.remove(join(directory, LEGACY_STATE_DIR_REL));
1204
1782
  return { status: "migrated", from: LEGACY_STATE_DIR_REL, to: STATE_DIR_REL };
1205
1783
  }
1206
1784
 
1207
- /** Applies a create, resume, or adopt plan through the host. Resume refreshes composed skills and stale AGENTS.md guidance. */
1785
+ /** The apply message's line saying what `--repositories` did to the inventory, or nothing without it. */
1786
+ function chosenInventoryNote(chosen: ChosenInventory | undefined): string {
1787
+ return chosen === undefined ? "" : `\n${describeChosenInventory(chosen)}`;
1788
+ }
1789
+
1790
+ function engineVersionsOf(plan: WorkspacePlan): HubEngineVersions {
1791
+ return {
1792
+ ...(plan.advisorVersion === undefined ? {} : { advisorVersion: plan.advisorVersion }),
1793
+ ...(plan.integratorVersion === undefined ? {} : { integratorVersion: plan.integratorVersion }),
1794
+ };
1795
+ }
1796
+
1797
+ /**
1798
+ * Classifies a freshly cloned `{owner}/workspace` (a resume plan with
1799
+ * `clone: true`) by its marker, the way `planWorkspace` classifies a local
1800
+ * checkout, and returns how apply must continue (#1585). It only reads: every
1801
+ * refusal it decides is thrown here, before apply writes anything into the
1802
+ * clone, and the appoint path's own refusals (a `package.json` that is not a
1803
+ * JSON object among them) are thrown by `adoptHubFiles` before its first write.
1804
+ *
1805
+ * - both markers: refused, as `planWorkspace` refuses them locally;
1806
+ * - a marker (current or legacy) for another owner: refused;
1807
+ * - only the legacy marker: resume, migrating it;
1808
+ * - only the current marker: resume;
1809
+ * - no marker: an existing repository with unrelated content is being
1810
+ * appointed, so apply takes the adopt path, which keeps its files. That
1811
+ * path needs both engine versions, refuses the Foundry supplier tree, and
1812
+ * refuses an on-disk inventory that fails its contract (a missing or valid
1813
+ * one is kept as it is; this path writes no inventory), and refuses a
1814
+ * `package.json` that is not a JSON object (checked by `adoptHubFiles`).
1815
+ */
1816
+ function classifyClonedHub(
1817
+ host: WorkspaceHost,
1818
+ plan: WorkspacePlanResume,
1819
+ ): { kind: "resume"; migrateFrom?: "legacy" } | { kind: "appoint"; plan: WorkspacePlanAdopt } {
1820
+ const located = locateHub(host, plan.directory);
1821
+ if (located.migration === "indeterminate") {
1822
+ throw new Error(
1823
+ `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`,
1824
+ );
1825
+ }
1826
+ if (located.document !== undefined) {
1827
+ if (!sameOwner(located.document.owner, plan.owner)) {
1828
+ throw new Error(
1829
+ `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`,
1830
+ );
1831
+ }
1832
+ return located.migration === "legacy" ? { kind: "resume", migrateFrom: "legacy" } : { kind: "resume" };
1833
+ }
1834
+ if (!plan.advisorVersion) {
1835
+ throw new Error(`cannot read a public ${ADVISOR_PACKAGE} version from the npm registry; appointing the cloned ${plan.owner}/${plan.repository} pins it`);
1836
+ }
1837
+ if (!plan.integratorVersion) {
1838
+ throw new Error(`cannot read a public ${INTEGRATOR_PACKAGE} version from the npm registry; appointing the cloned ${plan.owner}/${plan.repository} pins it`);
1839
+ }
1840
+ if (looksLikeFoundry(host, plan.directory)) {
1841
+ throw new Error(
1842
+ `refusing to appoint the cloned ${plan.owner}/${plan.repository}: it looks like the Foundry supplier tree`,
1843
+ );
1844
+ }
1845
+ const inventory = inspectInventory(host.readBytes(join(plan.directory, WORKSPACE_INVENTORY_REL)), plan.owner);
1846
+ if (inventory.status === "invalid") {
1847
+ throw new Error(
1848
+ `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`,
1849
+ );
1850
+ }
1851
+ return {
1852
+ kind: "appoint",
1853
+ plan: {
1854
+ action: "adopt",
1855
+ owner: plan.owner,
1856
+ repository: plan.repository,
1857
+ directory: plan.directory,
1858
+ advisorVersion: plan.advisorVersion,
1859
+ integratorVersion: plan.integratorVersion,
1860
+ },
1861
+ };
1862
+ }
1863
+
1864
+ /**
1865
+ * Applies a create, resume, or adopt plan through the host. Resume refreshes
1866
+ * composed skills and stale AGENTS.md guidance. A resume plan that clones
1867
+ * (`clone: true`, planned from an empty directory) is classified only after
1868
+ * the clone, by `classifyClonedHub`: a marked clone resumes (migrating a
1869
+ * legacy marker), an unmarked one is appointed through the adopt path, and
1870
+ * every refusal Launcher decides happens before anything is written into the clone (#1585).
1871
+ * Every path writes only into the hub checkout, never into an inventoried
1872
+ * repository beside it.
1873
+ */
1208
1874
  export function applyWorkspacePlan(
1209
1875
  host: WorkspaceHost,
1210
1876
  plan: WorkspacePlan,
@@ -1216,25 +1882,40 @@ export function applyWorkspacePlan(
1216
1882
  const contractPath = options.contractPath;
1217
1883
  const liveLauncherVersion = options.liveLauncherVersion;
1218
1884
  if (plan.action === "resume") {
1885
+ let migrateFrom = plan.migrateFrom;
1219
1886
  if (plan.clone) {
1220
1887
  requireZero(
1221
1888
  host.run("gh", ["repo", "clone", `${plan.owner}/${plan.repository}`, plan.directory]),
1222
1889
  "gh repo clone",
1223
1890
  );
1891
+ const cloned = classifyClonedHub(host, plan);
1892
+ if (cloned.kind === "appoint") return applyAdoptPlan(host, cloned.plan, skeletonRoot, options);
1893
+ migrateFrom = cloned.migrateFrom;
1894
+ }
1895
+ const migration = migrateFrom === "legacy" ? migrateLegacyHubState(host, plan.directory) : undefined;
1896
+ // --repositories (#1179): write the chosen inventory before composing, so
1897
+ // this same run's health report lists the repositories just chosen.
1898
+ if (plan.chosenInventory?.kind === "write") {
1899
+ writeSkeletonFile(host, plan.directory, WORKSPACE_INVENTORY_REL, revalidatedDocument(plan.chosenInventory.document, plan.owner));
1224
1900
  }
1225
- const migration = plan.migrateFrom === "legacy" ? migrateLegacyHubState(host, plan.directory) : undefined;
1901
+ // Resume raises the hub's engine pins to live: a frozen Advisor is
1902
+ // bumped and Integrator is added, for each engine whose live version
1903
+ // observeWorkspace could read. The health report says what changed and
1904
+ // that the install, and a commit with the lockfile, come next.
1905
+ const enginePinChanges = mergeHubEnginePins(host, plan.directory, skeletonRoot, engineVersionsOf(plan), plan.owner, plan.repository, "resume");
1226
1906
  return finishHubApply(
1227
1907
  host,
1228
1908
  plan.directory,
1229
- `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.`,
1909
+ `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)}`,
1230
1910
  launcherPackageRoot,
1231
1911
  plan.owner,
1232
1912
  plan.repository,
1233
- plan.advisorVersion,
1913
+ engineVersionsOf(plan),
1234
1914
  skillCatalogueRoot,
1235
1915
  contractPath,
1236
1916
  liveLauncherVersion,
1237
1917
  migration,
1918
+ enginePinChanges,
1238
1919
  );
1239
1920
  }
1240
1921
  if (plan.action === "create") {
@@ -1254,24 +1935,41 @@ export function applyWorkspacePlan(
1254
1935
  launcherPackageRoot,
1255
1936
  plan.owner,
1256
1937
  plan.repository,
1257
- plan.advisorVersion,
1938
+ engineVersionsOf(plan),
1258
1939
  skillCatalogueRoot,
1259
1940
  contractPath,
1260
1941
  liveLauncherVersion,
1261
1942
  );
1262
1943
  }
1263
- adoptHubFiles(host, skeletonRoot, plan);
1944
+ return applyAdoptPlan(host, plan, skeletonRoot, options);
1945
+ }
1946
+
1947
+ /** Appoints an existing checkout as the hub, keeping its files: the adopt plan's apply, also reached from an unmarked clone (#1585). */
1948
+ function applyAdoptPlan(
1949
+ host: WorkspaceHost,
1950
+ plan: WorkspacePlanAdopt,
1951
+ skeletonRoot: string,
1952
+ options: ApplyWorkspaceOptions,
1953
+ ): WorkspaceApplyResult {
1954
+ const launcherPackageRoot = options.launcherPackageRoot ?? resolve(skeletonRoot, "..");
1955
+ const { skillCatalogueRoot, contractPath, liveLauncherVersion } = options;
1956
+ const enginePinChanges = adoptHubFiles(host, skeletonRoot, plan);
1957
+ const inventoryReplacedNote = plan.replacesInvalidInventory === true
1958
+ ? " The on-disk inventory failed schema validation; --inventory replaced it."
1959
+ : "";
1264
1960
  return finishHubApply(
1265
1961
  host,
1266
1962
  plan.directory,
1267
- `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.`,
1963
+ `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)}`,
1268
1964
  launcherPackageRoot,
1269
1965
  plan.owner,
1270
1966
  plan.repository,
1271
- plan.advisorVersion,
1967
+ engineVersionsOf(plan),
1272
1968
  skillCatalogueRoot,
1273
1969
  contractPath,
1274
1970
  liveLauncherVersion,
1971
+ undefined,
1972
+ enginePinChanges,
1275
1973
  );
1276
1974
  }
1277
1975