@clossys/launcher 0.1.2 → 0.3.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 (114) hide show
  1. package/CHANGELOG.md +87 -0
  2. package/README.md +231 -19
  3. package/contracts/conversation-contract.md +40 -0
  4. package/dist/apply-plan-cli.d.ts +7 -0
  5. package/dist/apply-plan-cli.d.ts.map +1 -0
  6. package/dist/apply-plan-cli.js +96 -0
  7. package/dist/apply-plan-cli.js.map +1 -0
  8. package/dist/apply-plan.d.ts +79 -0
  9. package/dist/apply-plan.d.ts.map +1 -0
  10. package/dist/apply-plan.js +129 -0
  11. package/dist/apply-plan.js.map +1 -0
  12. package/dist/check-cli.d.ts.map +1 -1
  13. package/dist/check-cli.js +7 -0
  14. package/dist/check-cli.js.map +1 -1
  15. package/dist/cli.d.ts +2 -1
  16. package/dist/cli.d.ts.map +1 -1
  17. package/dist/cli.js +43 -14
  18. package/dist/cli.js.map +1 -1
  19. package/dist/contract.d.ts +28 -0
  20. package/dist/contract.d.ts.map +1 -0
  21. package/dist/contract.js +78 -0
  22. package/dist/contract.js.map +1 -0
  23. package/dist/core.d.ts +50 -8
  24. package/dist/core.d.ts.map +1 -1
  25. package/dist/core.js +458 -40
  26. package/dist/core.js.map +1 -1
  27. package/dist/doctor-cli.d.ts +4 -0
  28. package/dist/doctor-cli.d.ts.map +1 -0
  29. package/dist/doctor-cli.js +32 -0
  30. package/dist/doctor-cli.js.map +1 -0
  31. package/dist/doctor.d.ts +28 -0
  32. package/dist/doctor.d.ts.map +1 -0
  33. package/dist/doctor.js +68 -0
  34. package/dist/doctor.js.map +1 -0
  35. package/dist/host.d.ts.map +1 -1
  36. package/dist/host.js +19 -1
  37. package/dist/host.js.map +1 -1
  38. package/dist/hosts.d.ts +14 -0
  39. package/dist/hosts.d.ts.map +1 -0
  40. package/dist/hosts.js +61 -0
  41. package/dist/hosts.js.map +1 -0
  42. package/dist/index.d.ts +15 -2
  43. package/dist/index.d.ts.map +1 -1
  44. package/dist/index.js +7 -1
  45. package/dist/index.js.map +1 -1
  46. package/dist/inventory-adoption.d.ts +20 -0
  47. package/dist/inventory-adoption.d.ts.map +1 -0
  48. package/dist/inventory-adoption.js +67 -0
  49. package/dist/inventory-adoption.js.map +1 -0
  50. package/dist/manifest.d.ts +20 -0
  51. package/dist/manifest.d.ts.map +1 -0
  52. package/dist/manifest.js +106 -0
  53. package/dist/manifest.js.map +1 -0
  54. package/dist/model-profile.d.ts +46 -0
  55. package/dist/model-profile.d.ts.map +1 -0
  56. package/dist/model-profile.js +98 -0
  57. package/dist/model-profile.js.map +1 -0
  58. package/dist/product-repository.d.ts +26 -0
  59. package/dist/product-repository.d.ts.map +1 -0
  60. package/dist/product-repository.js +49 -0
  61. package/dist/product-repository.js.map +1 -0
  62. package/dist/skills.d.ts +39 -0
  63. package/dist/skills.d.ts.map +1 -0
  64. package/dist/skills.js +197 -0
  65. package/dist/skills.js.map +1 -0
  66. package/dist/types.d.ts +78 -1
  67. package/dist/types.d.ts.map +1 -1
  68. package/model-profiles/claude-code.json +10 -0
  69. package/model-profiles/codex.json +10 -0
  70. package/model-profiles/cursor.json +10 -0
  71. package/package.json +11 -5
  72. package/skeleton/README.md +15 -20
  73. package/skeleton/package.json +1 -1
  74. package/skill/SKILL.md +53 -0
  75. package/skill-catalogue/advisor/SKILL.md +52 -0
  76. package/skill-catalogue/architect/SKILL.md +42 -0
  77. package/skill-catalogue/bouncer/SKILL.md +43 -0
  78. package/skill-catalogue/builder/SKILL.md +42 -0
  79. package/skill-catalogue/butler/SKILL.md +43 -0
  80. package/skill-catalogue/controller/SKILL.md +50 -0
  81. package/skill-catalogue/customer/SKILL.md +92 -0
  82. package/skill-catalogue/designer/SKILL.md +66 -0
  83. package/skill-catalogue/giver/SKILL.md +43 -0
  84. package/skill-catalogue/influencer/SKILL.md +43 -0
  85. package/skill-catalogue/inspector/SKILL.md +42 -0
  86. package/skill-catalogue/integrator/SKILL.md +42 -0
  87. package/skill-catalogue/keeper/SKILL.md +43 -0
  88. package/skill-catalogue/launcher/SKILL.md +53 -0
  89. package/skill-catalogue/locksmith/SKILL.md +42 -0
  90. package/skill-catalogue/messenger/SKILL.md +43 -0
  91. package/skill-catalogue/observer/SKILL.md +42 -0
  92. package/skill-catalogue/publisher/SKILL.md +64 -0
  93. package/skill-catalogue/starter/SKILL.md +48 -0
  94. package/skill-catalogue/strategist/SKILL.md +85 -0
  95. package/skill-catalogue/writer/SKILL.md +56 -0
  96. package/src/apply-plan-cli.ts +94 -0
  97. package/src/apply-plan.ts +172 -0
  98. package/src/check-cli.ts +7 -0
  99. package/src/cli.ts +51 -13
  100. package/src/contract.ts +81 -0
  101. package/src/core.ts +555 -38
  102. package/src/doctor-cli.ts +33 -0
  103. package/src/doctor.ts +145 -0
  104. package/src/host.ts +17 -1
  105. package/src/hosts.ts +79 -0
  106. package/src/index.ts +36 -0
  107. package/src/inventory-adoption.ts +85 -0
  108. package/src/manifest.ts +103 -0
  109. package/src/model-profile.ts +148 -0
  110. package/src/product-repository.ts +73 -0
  111. package/src/skills.ts +225 -0
  112. package/src/types.ts +74 -1
  113. /package/skeleton/{.clossys → clossys/.state}/inventory.json +0 -0
  114. /package/skeleton/{.clossys → clossys/.state}/workspace.json +0 -0
package/src/core.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { dirname, join, resolve, sep } from "node:path";
2
2
  import { fileURLToPath } from "node:url";
3
3
  import type {
4
+ ApplyWorkspaceOptions,
4
5
  CommandResult,
5
6
  CwdObservation,
6
7
  DependencyBucket,
@@ -18,11 +19,25 @@ import type {
18
19
  WorkspacePlanCreate,
19
20
  WorkspaceRefusal,
20
21
  } from "./types.js";
22
+ import { composeSkills, SKILLS_MANIFEST_REL, type SkillCompositionResult } from "./skills.js";
23
+ import { parseSkillManifest, summarizeSkillsManifest } from "./manifest.js";
24
+ import { detectLinkedHosts, serializeHostRecord, HOSTS_REL, type DiscoveredHost } from "./hosts.js";
25
+ import { reportInventoryDrift } from "./inventory-adoption.js";
21
26
 
22
27
  export const DEFAULT_REPOSITORY_NAME = "workspace";
23
- export const WORKSPACE_MARKER_REL = ".clossys/workspace.json";
24
- export const WORKSPACE_INVENTORY_REL = ".clossys/inventory.json";
28
+ /** The one visible, per-repository Clossys folder (#1171). Every role's output lives under it. */
29
+ export const CLOSSYS_DIR_REL = "clossys";
30
+ /** Machine files only: hub marker, inventory, skills manifest. Visible (not dot-hidden) so it is easy to find, but not a place a person edits by hand. */
31
+ export const STATE_DIR_REL = join(CLOSSYS_DIR_REL, ".state");
32
+ export const WORKSPACE_MARKER_REL = join(STATE_DIR_REL, "workspace.json");
33
+ export const WORKSPACE_INVENTORY_REL = join(STATE_DIR_REL, "inventory.json");
34
+ export const CLOSSYS_README_REL = join(CLOSSYS_DIR_REL, "README.md");
35
+ /** Pre-#1171 machine-state directory. Resume migrates it automatically; see `locateHub`. */
36
+ export const LEGACY_STATE_DIR_REL = ".clossys";
37
+ export const LEGACY_WORKSPACE_MARKER_REL = join(LEGACY_STATE_DIR_REL, "workspace.json");
38
+ export const LEGACY_WORKSPACE_INVENTORY_REL = join(LEGACY_STATE_DIR_REL, "inventory.json");
25
39
  export const ADVISOR_PACKAGE = "@clossys/advisor";
40
+ export const LAUNCHER_PACKAGE = "@clossys/launcher";
26
41
 
27
42
  const DEPENDENCY_BUCKETS: readonly DependencyBucket[] = [
28
43
  "dependencies",
@@ -44,10 +59,54 @@ const SKELETON_FILES = [
44
59
  /** Written at generate time so this package never ships a nested AGENTS.md. */
45
60
  export const CONSUMER_AGENTS_MD = `# Account workspace
46
61
 
62
+ This folder is the account hub for Foundry packages.
63
+
64
+ After \`npx @clossys/launcher\`, the same \`@clossys-*\` team is composed in
65
+ every inventoried checkout beside this hub. Talk with \`@clossys-advisor\` and
66
+ \`@clossys-<package>\` here or in a product repository. A missing \`@\` mention
67
+ is not how we signal incompatibility — \`@clossys-advisor\` is the hiring check.
68
+
69
+ Run \`npx @clossys/launcher\` again for hub health and to refresh voices on
70
+ clones next to the hub, not as how you talk to packages.
71
+
72
+ The person in this folder is a founder, not an engineer. Speak like a
73
+ person. Do not dump machine identifiers, JSON, hashes, or grant fields
74
+ unless they ask.
75
+
76
+ Advisor is read-only until the sponsor approves a next action.
77
+ `;
78
+
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
+ /** Previous generate-time guidance; used to refresh stale hub AGENTS.md on resume. */
89
+ export const LEGACY_CONSUMER_AGENTS_MD = `# Account workspace
90
+
47
91
  This repository is the account hub for Foundry packages. It inventories
48
92
  where packages are installed and coordinates engagement. It is not a
49
93
  product application and does not need the whole catalogue installed here.
50
94
 
95
+ The person in this folder is a founder, not an engineer. Speak like a
96
+ person. Do not dump machine identifiers, JSON, hashes, or grant fields
97
+ unless they ask.
98
+
99
+ When there is a next step, say only:
100
+
101
+ 1. Where we are (one sentence).
102
+ 2. What you should do next (one sentence).
103
+ 3. What we will not do until you say yes.
104
+ 4. Whether anything will be saved to git (usually no).
105
+
106
+ Wait for a plain yes before changing files. "Approved" in chat is
107
+ permission for that one step only. It is not a lasting grant and it
108
+ does not become a commit unless someone later saves a file.
109
+
51
110
  Open this folder in your coding agent. Advisor is read-only until you
52
111
  approve a next action.
53
112
 
@@ -140,8 +199,8 @@ export function isHubDocument(value: unknown): value is HubDocument {
140
199
  return true;
141
200
  }
142
201
 
143
- function readHub(host: WorkspaceHost, directory: string): HubDocument | undefined {
144
- const raw = host.readText(join(directory, WORKSPACE_MARKER_REL));
202
+ function readHubAt(host: WorkspaceHost, path: string): HubDocument | undefined {
203
+ const raw = host.readText(path);
145
204
  if (raw === null) return undefined;
146
205
  try {
147
206
  const parsed: unknown = JSON.parse(raw);
@@ -151,7 +210,31 @@ function readHub(host: WorkspaceHost, directory: string): HubDocument | undefine
151
210
  }
152
211
  }
153
212
 
154
- /** Classifies a generated hub inventory (packed template skeleton/.clossys/inventory.json; the generated path does not ship) without inventing repositories. */
213
+ /**
214
+ * Locates the hub marker across the `.clossys/` -> `clossys/.state/`
215
+ * migration (#1171). `clean`: only the current path has a marker. `legacy`:
216
+ * only the old path does; resume migrates it (see `migrateLegacyHubState`).
217
+ * `indeterminate`: both paths carry a parseable marker; launcher never
218
+ * merges them silently, so `planWorkspace` refuses instead. `none`: neither
219
+ * path has one.
220
+ */
221
+ function locateHub(
222
+ host: WorkspaceHost,
223
+ directory: string,
224
+ ): { document?: HubDocument; migration: "clean" | "legacy" | "indeterminate" | "none" } {
225
+ const current = readHubAt(host, join(directory, WORKSPACE_MARKER_REL));
226
+ const legacy = readHubAt(host, join(directory, LEGACY_WORKSPACE_MARKER_REL));
227
+ if (current !== undefined && legacy !== undefined) return { migration: "indeterminate" };
228
+ if (current !== undefined) return { document: current, migration: "clean" };
229
+ if (legacy !== undefined) return { document: legacy, migration: "legacy" };
230
+ return { migration: "none" };
231
+ }
232
+
233
+ function readHub(host: WorkspaceHost, directory: string): HubDocument | undefined {
234
+ return locateHub(host, directory).document;
235
+ }
236
+
237
+ /** Classifies a generated hub inventory (packed template skeleton/clossys/.state/inventory.json; the generated path does not ship) without inventing repositories. */
155
238
  export function inspectInventory(raw: string | null): InventoryObservation {
156
239
  if (raw === null) return { status: "missing", count: 0 };
157
240
  try {
@@ -205,6 +288,17 @@ export function hasAdvisorPin(manifest: unknown): boolean {
205
288
  }
206
289
 
207
290
  /** Collects GitHub owner, cwd shape, default-hub presence, and the public Advisor pin. */
291
+ /**
292
+ * Reads the public `@clossys/launcher` registry version, used only to grade
293
+ * catalogue-sourced skill staleness in the health report (#1183). A missing
294
+ * or unparseable read leaves staleness ungraded rather than refusing.
295
+ */
296
+ 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;
300
+ }
301
+
208
302
  export function observeWorkspace(host: WorkspaceHost): WorkspaceObservation {
209
303
  const cwd = host.cwd;
210
304
  const ghAvailable = commandAvailable(host, "gh");
@@ -241,14 +335,17 @@ export function observeWorkspace(host: WorkspaceHost): WorkspaceObservation {
241
335
  const viewed = host.run("gh", ["repo", "view", `${ownerGuess}/${DEFAULT_REPOSITORY_NAME}`, "--json", "name"]);
242
336
  if (viewed.status === 0) remoteDefaultHub = { owner: ownerGuess, repository: DEFAULT_REPOSITORY_NAME };
243
337
  }
244
- // Skip the registry read when the tree already pins Advisor in some bucket:
245
- // adopt leaves an existing pin alone, so no live version is needed to plan it.
246
- const manifestPinsAdvisor = hasAdvisorPin(readJson(host, join(cwd, "package.json")));
247
- if (!manifestPinsAdvisor) {
248
- const viewedAdvisor = host.run("npm", ["view", ADVISOR_PACKAGE, "version"]);
249
- const version = viewedAdvisor.stdout.trim();
250
- if (viewedAdvisor.status === 0 && /^\d+\.\d+\.\d+$/.test(version)) advisorVersion = version;
251
- }
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;
341
+
342
+ const hubLocation = locateHub(host, cwd);
343
+ // While only the legacy `.clossys/` marker exists, its sibling inventory is
344
+ // the one resume will migrate; read from there so planning sees it too.
345
+ const inventoryRaw =
346
+ hubLocation.migration === "legacy"
347
+ ? host.readText(join(cwd, LEGACY_WORKSPACE_INVENTORY_REL))
348
+ : host.readText(join(cwd, WORKSPACE_INVENTORY_REL));
252
349
 
253
350
  const cwdObservation: CwdObservation = {
254
351
  absolutePath: cwd,
@@ -256,9 +353,10 @@ export function observeWorkspace(host: WorkspaceHost): WorkspaceObservation {
256
353
  git,
257
354
  ...(githubOwner === undefined ? {} : { githubOwner }),
258
355
  ...(githubRepository === undefined ? {} : { githubRepository }),
259
- ...(readHub(host, cwd) === undefined ? {} : { hub: readHub(host, cwd) }),
356
+ ...(hubLocation.document === undefined ? {} : { hub: hubLocation.document }),
357
+ ...(hubLocation.migration === "none" ? {} : { hubMigration: hubLocation.migration }),
260
358
  looksLikeFoundry: looksLikeFoundry(host, cwd),
261
- inventory: inspectInventory(host.readText(join(cwd, WORKSPACE_INVENTORY_REL))),
359
+ inventory: inspectInventory(inventoryRaw),
262
360
  };
263
361
 
264
362
  return {
@@ -295,7 +393,7 @@ function resolveOwner(observation: WorkspaceObservation, host: WorkspaceHost): {
295
393
  * Decides create, resume, or adopt from a cwd observation.
296
394
  * Appointing means: run this from the GitHub repository that should own the hub.
297
395
  */
298
- function readInventoryRepositories(host: WorkspaceHost, source: string, label: string): readonly string[] {
396
+ export function readInventoryRepositories(host: WorkspaceHost, source: string, label: string): readonly string[] {
299
397
  const raw = host.readText(source);
300
398
  if (raw === null) return [];
301
399
  try {
@@ -328,7 +426,7 @@ function resolveAdoptInventory(
328
426
  if (!onDiskPopulated && !trimmed) {
329
427
  return refuse(
330
428
  "violated",
331
- "appointing requires a populated generated hub inventory (packed template skeleton/.clossys/inventory.json; the generated path does not ship), or --inventory <path> to a populated inventory document",
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",
332
430
  );
333
431
  }
334
432
  if (!trimmed) return {};
@@ -364,6 +462,12 @@ export function planWorkspace(
364
462
  options: { inventoryPath?: string } = {},
365
463
  ): WorkspaceDecision {
366
464
  const { cwd } = observation;
465
+ if (cwd.hubMigration === "indeterminate") {
466
+ return refuse(
467
+ "indeterminate",
468
+ `both ${WORKSPACE_MARKER_REL} and the legacy ${LEGACY_WORKSPACE_MARKER_REL} are present; launcher never merges them silently -- remove one before resuming`,
469
+ );
470
+ }
367
471
  const envOwner = observation.envOwner ?? (host.env.CLOSSYS_OWNER?.trim() || undefined);
368
472
  if (
369
473
  envOwner !== undefined &&
@@ -391,6 +495,8 @@ export function planWorkspace(
391
495
  repository: repoNameFromSlug(cwd.hub.repository, DEFAULT_REPOSITORY_NAME),
392
496
  directory: cwd.absolutePath,
393
497
  clone: false,
498
+ ...(observation.advisorVersion === undefined ? {} : { advisorVersion: observation.advisorVersion }),
499
+ ...(cwd.hubMigration === "legacy" ? { migrateFrom: "legacy" as const } : {}),
394
500
  };
395
501
  }
396
502
  if (cwd.git && cwd.githubOwner && cwd.githubRepository) {
@@ -427,6 +533,7 @@ export function planWorkspace(
427
533
  repository: observation.remoteDefaultHub.repository,
428
534
  directory: cwd.absolutePath,
429
535
  clone: true,
536
+ ...(observation.advisorVersion === undefined ? {} : { advisorVersion: observation.advisorVersion }),
430
537
  };
431
538
  }
432
539
  if (!observation.ghAvailable) {
@@ -448,7 +555,7 @@ function containedPath(root: string, relativePath: string): string {
448
555
  const resolved = resolve(root, relativePath);
449
556
  const prefix = root.endsWith(sep) ? root : `${root}${sep}`;
450
557
  if (resolved !== root && !resolved.startsWith(prefix)) {
451
- throw new Error(`refusing to write outside the hub directory: ${relativePath}`);
558
+ throw new Error(`refusing to write outside the workspace directory: ${relativePath}`);
452
559
  }
453
560
  return resolved;
454
561
  }
@@ -483,12 +590,18 @@ function clossysNames(bucket: unknown, extra: Set<string>): string | undefined {
483
590
  return advisor;
484
591
  }
485
592
 
486
- /** Leaves an existing Advisor pin in whichever bucket it already occupies. */
593
+ /**
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`.
598
+ */
487
599
  function mergeAdvisorPin(
488
600
  host: WorkspaceHost,
489
601
  directory: string,
490
602
  skeletonRoot: string,
491
603
  advisorVersion: string,
604
+ owner: string,
492
605
  repository: string,
493
606
  ): void {
494
607
  const path = join(directory, "package.json");
@@ -496,7 +609,7 @@ function mergeAdvisorPin(
496
609
  if (raw === null) {
497
610
  const skeleton = host.readText(join(skeletonRoot, "package.json"));
498
611
  if (skeleton === null) throw new Error("missing skeleton package.json");
499
- writeSkeletonFile(host, directory, "package.json", substitute(skeleton, { owner: repository, repository, advisorVersion }));
612
+ writeSkeletonFile(host, directory, "package.json", substitute(skeleton, { owner, repository, advisorVersion }));
500
613
  return;
501
614
  }
502
615
  let manifest: Record<string, unknown>;
@@ -507,12 +620,21 @@ function mergeAdvisorPin(
507
620
  } catch {
508
621
  throw new Error("existing package.json is unreadable JSON");
509
622
  }
510
- const extra = new Set<string>();
511
- const pinnedSomewhere = DEPENDENCY_BUCKETS.some((bucket) => clossysNames(manifest[bucket], extra) !== undefined);
512
- if (pinnedSomewhere) return;
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
+ }
513
632
  const devDependencies = isRecord(manifest.devDependencies) ? { ...manifest.devDependencies } : {};
514
633
  devDependencies[ADVISOR_PACKAGE] = advisorVersion;
515
634
  manifest.devDependencies = devDependencies;
635
+ if (repository === DEFAULT_REPOSITORY_NAME) {
636
+ manifest.name = `@${owner}/${repository}`;
637
+ }
516
638
  host.writeText(path, `${JSON.stringify(manifest, null, 2)}\n`);
517
639
  }
518
640
 
@@ -574,6 +696,8 @@ function adoptHubFiles(host: WorkspaceHost, skeletonRoot: string, plan: Workspac
574
696
  }
575
697
  if (host.readText(join(plan.directory, "AGENTS.md")) === null) {
576
698
  writeSkeletonFile(host, plan.directory, "AGENTS.md", CONSUMER_AGENTS_MD);
699
+ } else {
700
+ writeConsumerAgentsIfNeeded(host, plan.directory);
577
701
  }
578
702
  if (host.readText(join(plan.directory, "CLAUDE.md")) === null) {
579
703
  writeSkeletonFile(host, plan.directory, "CLAUDE.md", CONSUMER_CLAUDE_MD);
@@ -586,7 +710,7 @@ function adoptHubFiles(host: WorkspaceHost, skeletonRoot: string, plan: Workspac
586
710
  const ignore = host.readText(join(skeletonRoot, ".gitignore"));
587
711
  if (ignore !== null) writeSkeletonFile(host, plan.directory, ".gitignore", ignore);
588
712
  }
589
- mergeAdvisorPin(host, plan.directory, skeletonRoot, plan.advisorVersion, plan.repository);
713
+ mergeAdvisorPin(host, plan.directory, skeletonRoot, plan.advisorVersion, plan.owner, plan.repository);
590
714
  }
591
715
 
592
716
  function requireZero(result: CommandResult, label: string): void {
@@ -601,7 +725,14 @@ function requireZero(result: CommandResult, label: string): void {
601
725
  * registry version, marking a pin older than live as a stale-pin finding and a
602
726
  * degraded report.
603
727
  */
604
- export function reportHubHealth(host: WorkspaceHost, directory: string, liveAdvisorVersion?: string): HubHealthReport {
728
+ export function reportHubHealth(
729
+ host: WorkspaceHost,
730
+ directory: string,
731
+ liveAdvisorVersion?: string,
732
+ liveLauncherVersion?: string,
733
+ retiredThisRun: readonly string[] = [],
734
+ migration?: HubHealthReport["migration"],
735
+ ): HubHealthReport {
605
736
  const extra = new Set<string>();
606
737
  const pins: Partial<Record<DependencyBucket, string>> = {};
607
738
  const manifestRaw = host.readText(join(directory, "package.json"));
@@ -628,6 +759,11 @@ export function reportHubHealth(host: WorkspaceHost, directory: string, liveAdvi
628
759
  ? [{ bucket: bucket as DependencyBucket, pinned, grade: "stale", note: `pinned ${pinned} is older than live ${liveAdvisorVersion}` }]
629
760
  : [];
630
761
  });
762
+ const skillsManifest = summarizeSkillsManifest(
763
+ parseSkillManifest(host.readText(join(directory, SKILLS_MANIFEST_REL))),
764
+ liveLauncherVersion,
765
+ retiredThisRun,
766
+ );
631
767
  return {
632
768
  marker: readHub(host, directory) === undefined ? "missing" : "present",
633
769
  inventory: inspectInventory(host.readText(join(directory, WORKSPACE_INVENTORY_REL))),
@@ -641,7 +777,15 @@ export function reportHubHealth(host: WorkspaceHost, directory: string, liveAdvi
641
777
  dualPin: Object.values(pins).filter((value) => value !== undefined).length > 1,
642
778
  extraClossys: [...extra].sort(),
643
779
  pinFindings,
644
- degraded: pinFindings.some((finding) => finding.grade === "stale"),
780
+ degraded:
781
+ 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,
787
+ ...(migration === undefined ? {} : { migration }),
788
+ skillsManifest,
645
789
  };
646
790
  }
647
791
 
@@ -660,6 +804,44 @@ export function formatHubHealth(report: HubHealthReport): string {
660
804
  finding.note !== undefined ? `${finding.bucket} ${finding.note}` : `${finding.bucket} ${finding.grade}`,
661
805
  );
662
806
  const findingLine = findings.length === 0 ? "none" : findings.join("; ");
807
+ const skillParts: string[] = [];
808
+ if (report.skillComposition !== undefined) {
809
+ skillParts.push(
810
+ report.skillComposition.composed.length === 0
811
+ ? "skills composed: none"
812
+ : `skills composed: ${report.skillComposition.composed.map((name) => `clossys-${name}`).join(", ")}`,
813
+ );
814
+ for (const skip of report.skillComposition.skipped) {
815
+ skillParts.push(`skill skipped (${skip.packageDir}): ${skip.note}`);
816
+ }
817
+ if (report.skillComposition.rosterTargets !== undefined && report.skillComposition.rosterTargets.length > 0) {
818
+ skillParts.push(`skill roster written: ${report.skillComposition.rosterTargets.join(", ")}`);
819
+ }
820
+ for (const skip of report.skillComposition.rosterSkipped ?? []) {
821
+ skillParts.push(`skill roster skipped (${skip.inventoryId}): ${skip.note}`);
822
+ }
823
+ if (report.skillComposition.retired !== undefined && report.skillComposition.retired.length > 0) {
824
+ skillParts.push(`skills retired: ${report.skillComposition.retired.map((name) => `clossys-${name}`).join(", ")}`);
825
+ }
826
+ }
827
+ const skillsManifestLine =
828
+ report.skillsManifest === undefined
829
+ ? undefined
830
+ : report.skillsManifest.status === "missing"
831
+ ? "skills manifest: missing"
832
+ : `skills: ${report.skillsManifest.stale} out of date, ${report.skillsManifest.retired} retired (${report.skillsManifest.total} composed)`;
833
+ const migrationLine =
834
+ report.migration === undefined ? undefined : `migration: moved hub state from ${report.migration.from} to ${report.migration.to}`;
835
+ const linkedHostsLine =
836
+ report.linkedHosts === undefined
837
+ ? undefined
838
+ : `linked hosts: ${report.linkedHosts.length === 0 ? "none detected" : report.linkedHosts.join(", ")}`;
839
+ const inventoryDriftLine =
840
+ report.inventoryDrift === undefined
841
+ ? undefined
842
+ : report.inventoryDrift.status === "indeterminate"
843
+ ? `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}`;
663
845
  return [
664
846
  `hub marker: ${report.marker}`,
665
847
  `inventory: ${inventory}`,
@@ -668,10 +850,41 @@ export function formatHubHealth(report: HubHealthReport): string {
668
850
  `extra @clossys/*: ${extra}`,
669
851
  `pin findings: ${findingLine}`,
670
852
  `degraded: ${report.degraded ? "yes" : "no"}`,
853
+ ...(migrationLine === undefined ? [] : [migrationLine]),
854
+ ...(linkedHostsLine === undefined ? [] : [linkedHostsLine]),
855
+ ...(inventoryDriftLine === undefined ? [] : [inventoryDriftLine]),
856
+ ...(skillsManifestLine === undefined ? [] : [skillsManifestLine]),
857
+ ...(skillParts.length === 0 ? [] : skillParts),
671
858
  `health: ${JSON.stringify(report)}`,
672
859
  ].join("\n");
673
860
  }
674
861
 
862
+ function withHealth(
863
+ host: WorkspaceHost,
864
+ directory: string,
865
+ headline: string,
866
+ liveAdvisorVersion?: string,
867
+ skillComposition?: SkillCompositionResult & { linkedHosts?: readonly DiscoveredHost[] },
868
+ liveLauncherVersion?: string,
869
+ migration?: HubHealthReport["migration"],
870
+ inventoryDrift?: HubHealthReport["inventoryDrift"],
871
+ ): WorkspaceApplyResult {
872
+ const base = reportHubHealth(host, directory, liveAdvisorVersion, liveLauncherVersion, skillComposition?.retired ?? [], migration);
873
+ const rosterSkipped = skillComposition?.rosterSkipped ?? [];
874
+ const health: HubHealthReport = {
875
+ ...base,
876
+ ...(skillComposition === undefined ? {} : { skillComposition }),
877
+ ...(skillComposition?.linkedHosts === undefined ? {} : { linkedHosts: skillComposition.linkedHosts }),
878
+ ...(inventoryDrift === undefined || inventoryDrift.status === "no-external-source" ? {} : { inventoryDrift }),
879
+ degraded: base.degraded || rosterSkipped.length > 0,
880
+ };
881
+ return {
882
+ state: "satisfied",
883
+ message: `${headline}\n${formatHubHealth(health)}`,
884
+ health,
885
+ };
886
+ }
887
+
675
888
  /**
676
889
  * Read-only inventory validation: runs `gh repo view --json name` for each
677
890
  * repository id, batched. Tolerates a missing or failing `gh` by skipping with
@@ -707,22 +920,301 @@ export function checkInventoryEntries(host: WorkspaceHost, directory: string): I
707
920
  return { entries, skipped: false };
708
921
  }
709
922
 
710
- function withHealth(
923
+ function shouldRefreshConsumerAgents(existing: string | null): boolean {
924
+ if (existing === null) return true;
925
+ if (existing === CONSUMER_AGENTS_MD) return false;
926
+ if (existing === LEGACY_CONSUMER_AGENTS_MD) return true;
927
+ if (existing.includes("Run `npx @clossys/launcher` again to resume")) return true;
928
+ return false;
929
+ }
930
+
931
+ function writeConsumerAgentsIfNeeded(host: WorkspaceHost, directory: string): void {
932
+ const existing = host.readText(join(directory, "AGENTS.md"));
933
+ if (!shouldRefreshConsumerAgents(existing)) return;
934
+ writeSkeletonFile(host, directory, "AGENTS.md", CONSUMER_AGENTS_MD);
935
+ }
936
+
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);
941
+ }
942
+
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";
945
+
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 };
958
+ }
959
+
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
+ } {
968
+ const parent = dirname(resolve(hubDirectory));
969
+ const hubResolved = resolve(hubDirectory);
970
+ const skipped: { inventoryId: string; note: string }[] = [];
971
+ const targets: { inventoryId: string; directory: string }[] = [];
972
+ const inventoryPath = join(hubDirectory, WORKSPACE_INVENTORY_REL);
973
+ let inventoryIds: readonly string[];
974
+ try {
975
+ inventoryIds = readInventoryRepositories(host, inventoryPath, "the hub inventory");
976
+ } catch {
977
+ return { targets: [], skipped: [] };
978
+ }
979
+ for (const id of inventoryIds) {
980
+ if (id === "") continue;
981
+ const parsed = parseInventoryRepositoryId(id, hubOwner);
982
+ if (parsed === null) {
983
+ skipped.push({ inventoryId: id, note: "inventory id is not a valid repository slug" });
984
+ continue;
985
+ }
986
+ if (parsed.owner !== hubOwner) {
987
+ skipped.push({ inventoryId: id, note: "other account; not this roster" });
988
+ continue;
989
+ }
990
+ const candidate = join(parent, parsed.repository);
991
+ const candidateResolved = resolve(candidate);
992
+ if (candidateResolved === hubResolved) continue;
993
+ if (!host.exists(candidate) || !host.isDirectory(candidate)) {
994
+ skipped.push({ inventoryId: id, note: CLONE_NOT_BESIDE_HUB_NOTE });
995
+ continue;
996
+ }
997
+ if (looksLikeFoundry(host, candidate)) {
998
+ skipped.push({ inventoryId: id, note: "foundry supplier tree; skills are not written here" });
999
+ continue;
1000
+ }
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" });
1006
+ continue;
1007
+ }
1008
+ targets.push({ inventoryId: id, directory: candidate });
1009
+ }
1010
+ return { targets, skipped };
1011
+ }
1012
+
1013
+ export interface CloneMissingOutcome {
1014
+ readonly inventoryId: string;
1015
+ readonly result: "cloned" | "skipped-other-reason" | "failed";
1016
+ readonly note: string;
1017
+ }
1018
+
1019
+ /**
1020
+ * 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.
1028
+ */
1029
+ export function cloneMissingInventoryRepositories(
1030
+ host: WorkspaceHost,
1031
+ hubDirectory: string,
1032
+ hubOwner: string,
1033
+ ): readonly CloneMissingOutcome[] {
1034
+ const { skipped } = resolveSisterCloneTargets(host, hubDirectory, hubOwner);
1035
+ const parent = dirname(resolve(hubDirectory));
1036
+ 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 });
1040
+ continue;
1041
+ }
1042
+ const parsed = parseInventoryRepositoryId(skip.inventoryId, hubOwner);
1043
+ if (parsed === null) {
1044
+ outcomes.push({ inventoryId: skip.inventoryId, result: "failed", note: "inventory id is not a valid repository slug" });
1045
+ continue;
1046
+ }
1047
+ const siblingPath = join(parent, parsed.repository);
1048
+ const result = host.run("gh", ["repo", "clone", `${hubOwner}/${parsed.repository}`, siblingPath]);
1049
+ if (result.status === 0) {
1050
+ outcomes.push({ inventoryId: skip.inventoryId, result: "cloned", note: `cloned to ${siblingPath}` });
1051
+ } else {
1052
+ outcomes.push({
1053
+ inventoryId: skip.inventoryId,
1054
+ result: "failed",
1055
+ note: `gh repo clone exited ${result.status ?? "null"}: ${result.stderr.trim() || "no stderr"}`,
1056
+ });
1057
+ }
1058
+ }
1059
+ return outcomes;
1060
+ }
1061
+
1062
+ function hubRosterId(host: WorkspaceHost, hubDirectory: string, hubOwner: string, hubRepository: string): string {
1063
+ const document = readHub(host, hubDirectory);
1064
+ if (document !== undefined) return document.repository;
1065
+ return `${hubOwner}/${hubRepository}`;
1066
+ }
1067
+
1068
+ /**
1069
+ * Regenerates the generated `README.md` at the root of `clossys/`: an index of which `clossys/<role>/`
1070
+ * folders are active here, and what `.state/` holds. Written on every apply
1071
+ * so it never drifts from what is actually on disk (#1171).
1072
+ */
1073
+ function writeClossysReadme(host: WorkspaceHost, directory: string): void {
1074
+ const root = join(directory, CLOSSYS_DIR_REL);
1075
+ const roles = host
1076
+ .isDirectory(root)
1077
+ ? host
1078
+ .readDir(root)
1079
+ .filter((name) => name !== ".state" && name !== "README.md" && host.isDirectory(join(root, name)))
1080
+ .sort((a, b) => a.localeCompare(b))
1081
+ : [];
1082
+ const hasBrief = host.exists(join(root, "brief.json"));
1083
+ const lines = [
1084
+ "# clossys/",
1085
+ "",
1086
+ "Generated by `@clossys/launcher`. This file is rewritten on every launcher",
1087
+ `run to reflect what is active here; do not edit it by hand. Last generated: ${host.now()}.`,
1088
+ "",
1089
+ "## Engagement brief",
1090
+ "",
1091
+ ...(hasBrief
1092
+ ? ["`clossys/brief.json` — why each role is staffed here, its goals, handoffs, and sequence (owner: @clossys/advisor)."]
1093
+ : ["No engagement brief yet. `@clossys-advisor` writes `clossys/brief.json` once a plan is approved."]),
1094
+ "",
1095
+ "## Active roles",
1096
+ "",
1097
+ ...(roles.length === 0
1098
+ ? ["No role folder is active here yet."]
1099
+ : roles.map((role) => `- \`clossys/${role}/\` — @clossys-${role}`)),
1100
+ "",
1101
+ "## Machine state",
1102
+ "",
1103
+ "`clossys/.state/` holds machine files only: the hub marker, the inventory,",
1104
+ "and the skills manifest. It is visible so it is easy to find, but it is not",
1105
+ "a place a person edits by hand.",
1106
+ "",
1107
+ ];
1108
+ writeSkeletonFile(host, directory, CLOSSYS_README_REL, lines.join("\n"));
1109
+ }
1110
+
1111
+ /**
1112
+ * Reads which coding-agent hosts already had skill discovery linked in
1113
+ * `directory` BEFORE this call, then records that snapshot to
1114
+ * `clossys/.state/hosts.json` (#1180). Deliberately called ahead of
1115
+ * `composeSkills`, which unconditionally stamps discovery links for every
1116
+ * host once it runs -- reading afterward would report "all hosts" on every
1117
+ * apply and make the record meaningless.
1118
+ */
1119
+ function recordLinkedHosts(host: WorkspaceHost, directory: string): readonly DiscoveredHost[] {
1120
+ const linkedHosts = detectLinkedHosts(host, directory);
1121
+ writeSkeletonFile(
1122
+ host,
1123
+ directory,
1124
+ HOSTS_REL,
1125
+ serializeHostRecord({ schemaVersion: 1, linkedHosts, recordedAt: host.now() }),
1126
+ );
1127
+ return linkedHosts;
1128
+ }
1129
+
1130
+ function composeSkillRoster(
1131
+ host: WorkspaceHost,
1132
+ hubDirectory: string,
1133
+ hubOwner: string,
1134
+ hubRepository: string,
1135
+ options: { launcherPackageRoot: string; skillCatalogueRoot?: string; contractPath?: string },
1136
+ ): SkillCompositionResult & {
1137
+ readonly rosterTargets: readonly string[];
1138
+ readonly rosterSkipped: readonly { readonly inventoryId: string; readonly note: string }[];
1139
+ readonly linkedHosts: readonly DiscoveredHost[];
1140
+ } {
1141
+ const composeOptions = {
1142
+ launcherPackageRoot: options.launcherPackageRoot,
1143
+ ...(options.skillCatalogueRoot === undefined ? {} : { skillCatalogueRoot: options.skillCatalogueRoot }),
1144
+ ...(options.contractPath === undefined ? {} : { contractPath: options.contractPath }),
1145
+ };
1146
+ const linkedHosts = recordLinkedHosts(host, hubDirectory);
1147
+ const hubSkill = composeSkills(host, hubDirectory, composeOptions);
1148
+ writeConsumerAgentsIfNeeded(host, hubDirectory);
1149
+ writeClossysReadme(host, hubDirectory);
1150
+ 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 };
1160
+ }
1161
+
1162
+ function finishHubApply(
711
1163
  host: WorkspaceHost,
712
1164
  directory: string,
713
1165
  headline: string,
1166
+ launcherPackageRoot: string,
1167
+ hubOwner: string,
1168
+ hubRepository: string,
714
1169
  liveAdvisorVersion?: string,
1170
+ skillCatalogueRoot?: string,
1171
+ contractPath?: string,
1172
+ liveLauncherVersion?: string,
1173
+ migration?: HubHealthReport["migration"],
715
1174
  ): WorkspaceApplyResult {
716
- const health = reportHubHealth(host, directory, liveAdvisorVersion);
717
- return {
718
- state: "satisfied",
719
- message: `${headline}\n${formatHubHealth(health)}`,
720
- health,
721
- };
1175
+ const skillComposition = composeSkillRoster(host, directory, hubOwner, hubRepository, {
1176
+ launcherPackageRoot,
1177
+ ...(skillCatalogueRoot === undefined ? {} : { skillCatalogueRoot }),
1178
+ ...(contractPath === undefined ? {} : { contractPath }),
1179
+ });
1180
+ // #1216: when the hub marker declares an external inventory, report drift against
1181
+ // it on every apply (create's fresh marker never declares one, so this is a no-op there).
1182
+ 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);
722
1185
  }
723
1186
 
724
- /** Applies a create, resume, or adopt plan through the host. Resume does not write. */
725
- export function applyWorkspacePlan(host: WorkspaceHost, plan: WorkspacePlan, skeletonRoot: string): WorkspaceApplyResult {
1187
+ /**
1188
+ * Migrates a legacy `.clossys/` hub marker (and its sibling inventory, when
1189
+ * present) to `clossys/.state/`, then removes the old directory. Called only
1190
+ * 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.
1194
+ */
1195
+ function migrateLegacyHubState(host: WorkspaceHost, directory: string): HubHealthReport["migration"] {
1196
+ const markerRaw = host.readText(join(directory, LEGACY_WORKSPACE_MARKER_REL));
1197
+ if (markerRaw === null) return undefined;
1198
+ 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
+ }
1203
+ host.remove(join(directory, LEGACY_STATE_DIR_REL));
1204
+ return { status: "migrated", from: LEGACY_STATE_DIR_REL, to: STATE_DIR_REL };
1205
+ }
1206
+
1207
+ /** Applies a create, resume, or adopt plan through the host. Resume refreshes composed skills and stale AGENTS.md guidance. */
1208
+ export function applyWorkspacePlan(
1209
+ host: WorkspaceHost,
1210
+ plan: WorkspacePlan,
1211
+ skeletonRoot: string,
1212
+ options: ApplyWorkspaceOptions = {},
1213
+ ): WorkspaceApplyResult {
1214
+ const launcherPackageRoot = options.launcherPackageRoot ?? resolve(skeletonRoot, "..");
1215
+ const skillCatalogueRoot = options.skillCatalogueRoot;
1216
+ const contractPath = options.contractPath;
1217
+ const liveLauncherVersion = options.liveLauncherVersion;
726
1218
  if (plan.action === "resume") {
727
1219
  if (plan.clone) {
728
1220
  requireZero(
@@ -730,10 +1222,19 @@ export function applyWorkspacePlan(host: WorkspaceHost, plan: WorkspacePlan, ske
730
1222
  "gh repo clone",
731
1223
  );
732
1224
  }
733
- return withHealth(
1225
+ const migration = plan.migrateFrom === "legacy" ? migrateLegacyHubState(host, plan.directory) : undefined;
1226
+ return finishHubApply(
734
1227
  host,
735
1228
  plan.directory,
736
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.`,
1230
+ launcherPackageRoot,
1231
+ plan.owner,
1232
+ plan.repository,
1233
+ plan.advisorVersion,
1234
+ skillCatalogueRoot,
1235
+ contractPath,
1236
+ liveLauncherVersion,
1237
+ migration,
737
1238
  );
738
1239
  }
739
1240
  if (plan.action === "create") {
@@ -746,22 +1247,38 @@ export function applyWorkspacePlan(host: WorkspaceHost, plan: WorkspacePlan, ske
746
1247
  ),
747
1248
  "gh repo create",
748
1249
  );
749
- return withHealth(
1250
+ return finishHubApply(
750
1251
  host,
751
1252
  plan.directory,
752
1253
  `created ${plan.owner}/${plan.repository} as the account hub\nOpen this folder in your coding agent. Advisor stays read-only until you approve a next action.`,
1254
+ launcherPackageRoot,
1255
+ plan.owner,
1256
+ plan.repository,
753
1257
  plan.advisorVersion,
1258
+ skillCatalogueRoot,
1259
+ contractPath,
1260
+ liveLauncherVersion,
754
1261
  );
755
1262
  }
756
1263
  adoptHubFiles(host, skeletonRoot, plan);
757
- return withHealth(
1264
+ return finishHubApply(
758
1265
  host,
759
1266
  plan.directory,
760
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.`,
1268
+ launcherPackageRoot,
1269
+ plan.owner,
1270
+ plan.repository,
761
1271
  plan.advisorVersion,
1272
+ skillCatalogueRoot,
1273
+ contractPath,
1274
+ liveLauncherVersion,
762
1275
  );
763
1276
  }
764
1277
 
1278
+ export function launcherPackageRootFromModule(moduleUrl: string): string {
1279
+ return resolve(dirname(fileURLToPath(moduleUrl)), "..");
1280
+ }
1281
+
765
1282
  export function skeletonRootFromModule(moduleUrl: string): string {
766
1283
  return resolve(dirname(fileURLToPath(moduleUrl)), "..", "skeleton");
767
1284
  }