@clossys/launcher 0.1.5 → 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 (101) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/README.md +209 -20
  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 +3 -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 +37 -11
  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 +38 -6
  24. package/dist/core.d.ts.map +1 -1
  25. package/dist/core.js +289 -42
  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 +3 -0
  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 +20 -1
  63. package/dist/skills.d.ts.map +1 -1
  64. package/dist/skills.js +98 -7
  65. package/dist/skills.js.map +1 -1
  66. package/dist/types.d.ts +57 -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 +9 -4
  72. package/skeleton/README.md +5 -0
  73. package/skeleton/package.json +1 -1
  74. package/skill/SKILL.md +53 -0
  75. package/skill-catalogue/advisor/SKILL.md +3 -1
  76. package/skill-catalogue/controller/SKILL.md +8 -0
  77. package/skill-catalogue/customer/SKILL.md +92 -0
  78. package/skill-catalogue/designer/SKILL.md +18 -2
  79. package/skill-catalogue/inspector/SKILL.md +1 -1
  80. package/skill-catalogue/publisher/SKILL.md +17 -3
  81. package/skill-catalogue/strategist/SKILL.md +37 -3
  82. package/skill-catalogue/writer/SKILL.md +7 -2
  83. package/src/apply-plan-cli.ts +94 -0
  84. package/src/apply-plan.ts +172 -0
  85. package/src/check-cli.ts +3 -0
  86. package/src/cli.ts +45 -10
  87. package/src/contract.ts +81 -0
  88. package/src/core.ts +337 -37
  89. package/src/doctor-cli.ts +33 -0
  90. package/src/doctor.ts +145 -0
  91. package/src/host.ts +3 -0
  92. package/src/hosts.ts +79 -0
  93. package/src/index.ts +33 -0
  94. package/src/inventory-adoption.ts +85 -0
  95. package/src/manifest.ts +103 -0
  96. package/src/model-profile.ts +148 -0
  97. package/src/product-repository.ts +73 -0
  98. package/src/skills.ts +113 -8
  99. package/src/types.ts +58 -1
  100. /package/skeleton/{.clossys → clossys/.state}/inventory.json +0 -0
  101. /package/skeleton/{.clossys → clossys/.state}/workspace.json +0 -0
package/src/core.ts CHANGED
@@ -19,12 +19,25 @@ import type {
19
19
  WorkspacePlanCreate,
20
20
  WorkspaceRefusal,
21
21
  } from "./types.js";
22
- import { composeSkills, type SkillCompositionResult } from "./skills.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";
23
26
 
24
27
  export const DEFAULT_REPOSITORY_NAME = "workspace";
25
- export const WORKSPACE_MARKER_REL = ".clossys/workspace.json";
26
- 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");
27
39
  export const ADVISOR_PACKAGE = "@clossys/advisor";
40
+ export const LAUNCHER_PACKAGE = "@clossys/launcher";
28
41
 
29
42
  const DEPENDENCY_BUCKETS: readonly DependencyBucket[] = [
30
43
  "dependencies",
@@ -56,6 +69,10 @@ is not how we signal incompatibility — \`@clossys-advisor\` is the hiring chec
56
69
  Run \`npx @clossys/launcher\` again for hub health and to refresh voices on
57
70
  clones next to the hub, not as how you talk to packages.
58
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
+
59
76
  Advisor is read-only until the sponsor approves a next action.
60
77
  `;
61
78
 
@@ -75,6 +92,21 @@ This repository is the account hub for Foundry packages. It inventories
75
92
  where packages are installed and coordinates engagement. It is not a
76
93
  product application and does not need the whole catalogue installed here.
77
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
+
78
110
  Open this folder in your coding agent. Advisor is read-only until you
79
111
  approve a next action.
80
112
 
@@ -167,8 +199,8 @@ export function isHubDocument(value: unknown): value is HubDocument {
167
199
  return true;
168
200
  }
169
201
 
170
- function readHub(host: WorkspaceHost, directory: string): HubDocument | undefined {
171
- const raw = host.readText(join(directory, WORKSPACE_MARKER_REL));
202
+ function readHubAt(host: WorkspaceHost, path: string): HubDocument | undefined {
203
+ const raw = host.readText(path);
172
204
  if (raw === null) return undefined;
173
205
  try {
174
206
  const parsed: unknown = JSON.parse(raw);
@@ -178,7 +210,31 @@ function readHub(host: WorkspaceHost, directory: string): HubDocument | undefine
178
210
  }
179
211
  }
180
212
 
181
- /** 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. */
182
238
  export function inspectInventory(raw: string | null): InventoryObservation {
183
239
  if (raw === null) return { status: "missing", count: 0 };
184
240
  try {
@@ -232,6 +288,17 @@ export function hasAdvisorPin(manifest: unknown): boolean {
232
288
  }
233
289
 
234
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
+
235
302
  export function observeWorkspace(host: WorkspaceHost): WorkspaceObservation {
236
303
  const cwd = host.cwd;
237
304
  const ghAvailable = commandAvailable(host, "gh");
@@ -268,14 +335,17 @@ export function observeWorkspace(host: WorkspaceHost): WorkspaceObservation {
268
335
  const viewed = host.run("gh", ["repo", "view", `${ownerGuess}/${DEFAULT_REPOSITORY_NAME}`, "--json", "name"]);
269
336
  if (viewed.status === 0) remoteDefaultHub = { owner: ownerGuess, repository: DEFAULT_REPOSITORY_NAME };
270
337
  }
271
- // Skip the registry read when the tree already pins Advisor in some bucket:
272
- // adopt leaves an existing pin alone, so no live version is needed to plan it.
273
- const manifestPinsAdvisor = hasAdvisorPin(readJson(host, join(cwd, "package.json")));
274
- if (!manifestPinsAdvisor) {
275
- const viewedAdvisor = host.run("npm", ["view", ADVISOR_PACKAGE, "version"]);
276
- const version = viewedAdvisor.stdout.trim();
277
- if (viewedAdvisor.status === 0 && /^\d+\.\d+\.\d+$/.test(version)) advisorVersion = version;
278
- }
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));
279
349
 
280
350
  const cwdObservation: CwdObservation = {
281
351
  absolutePath: cwd,
@@ -283,9 +353,10 @@ export function observeWorkspace(host: WorkspaceHost): WorkspaceObservation {
283
353
  git,
284
354
  ...(githubOwner === undefined ? {} : { githubOwner }),
285
355
  ...(githubRepository === undefined ? {} : { githubRepository }),
286
- ...(readHub(host, cwd) === undefined ? {} : { hub: readHub(host, cwd) }),
356
+ ...(hubLocation.document === undefined ? {} : { hub: hubLocation.document }),
357
+ ...(hubLocation.migration === "none" ? {} : { hubMigration: hubLocation.migration }),
287
358
  looksLikeFoundry: looksLikeFoundry(host, cwd),
288
- inventory: inspectInventory(host.readText(join(cwd, WORKSPACE_INVENTORY_REL))),
359
+ inventory: inspectInventory(inventoryRaw),
289
360
  };
290
361
 
291
362
  return {
@@ -355,7 +426,7 @@ function resolveAdoptInventory(
355
426
  if (!onDiskPopulated && !trimmed) {
356
427
  return refuse(
357
428
  "violated",
358
- "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",
359
430
  );
360
431
  }
361
432
  if (!trimmed) return {};
@@ -391,6 +462,12 @@ export function planWorkspace(
391
462
  options: { inventoryPath?: string } = {},
392
463
  ): WorkspaceDecision {
393
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
+ }
394
471
  const envOwner = observation.envOwner ?? (host.env.CLOSSYS_OWNER?.trim() || undefined);
395
472
  if (
396
473
  envOwner !== undefined &&
@@ -418,6 +495,8 @@ export function planWorkspace(
418
495
  repository: repoNameFromSlug(cwd.hub.repository, DEFAULT_REPOSITORY_NAME),
419
496
  directory: cwd.absolutePath,
420
497
  clone: false,
498
+ ...(observation.advisorVersion === undefined ? {} : { advisorVersion: observation.advisorVersion }),
499
+ ...(cwd.hubMigration === "legacy" ? { migrateFrom: "legacy" as const } : {}),
421
500
  };
422
501
  }
423
502
  if (cwd.git && cwd.githubOwner && cwd.githubRepository) {
@@ -454,6 +533,7 @@ export function planWorkspace(
454
533
  repository: observation.remoteDefaultHub.repository,
455
534
  directory: cwd.absolutePath,
456
535
  clone: true,
536
+ ...(observation.advisorVersion === undefined ? {} : { advisorVersion: observation.advisorVersion }),
457
537
  };
458
538
  }
459
539
  if (!observation.ghAvailable) {
@@ -510,12 +590,18 @@ function clossysNames(bucket: unknown, extra: Set<string>): string | undefined {
510
590
  return advisor;
511
591
  }
512
592
 
513
- /** 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
+ */
514
599
  function mergeAdvisorPin(
515
600
  host: WorkspaceHost,
516
601
  directory: string,
517
602
  skeletonRoot: string,
518
603
  advisorVersion: string,
604
+ owner: string,
519
605
  repository: string,
520
606
  ): void {
521
607
  const path = join(directory, "package.json");
@@ -523,7 +609,7 @@ function mergeAdvisorPin(
523
609
  if (raw === null) {
524
610
  const skeleton = host.readText(join(skeletonRoot, "package.json"));
525
611
  if (skeleton === null) throw new Error("missing skeleton package.json");
526
- writeSkeletonFile(host, directory, "package.json", substitute(skeleton, { owner: repository, repository, advisorVersion }));
612
+ writeSkeletonFile(host, directory, "package.json", substitute(skeleton, { owner, repository, advisorVersion }));
527
613
  return;
528
614
  }
529
615
  let manifest: Record<string, unknown>;
@@ -534,12 +620,21 @@ function mergeAdvisorPin(
534
620
  } catch {
535
621
  throw new Error("existing package.json is unreadable JSON");
536
622
  }
537
- const extra = new Set<string>();
538
- const pinnedSomewhere = DEPENDENCY_BUCKETS.some((bucket) => clossysNames(manifest[bucket], extra) !== undefined);
539
- 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
+ }
540
632
  const devDependencies = isRecord(manifest.devDependencies) ? { ...manifest.devDependencies } : {};
541
633
  devDependencies[ADVISOR_PACKAGE] = advisorVersion;
542
634
  manifest.devDependencies = devDependencies;
635
+ if (repository === DEFAULT_REPOSITORY_NAME) {
636
+ manifest.name = `@${owner}/${repository}`;
637
+ }
543
638
  host.writeText(path, `${JSON.stringify(manifest, null, 2)}\n`);
544
639
  }
545
640
 
@@ -615,7 +710,7 @@ function adoptHubFiles(host: WorkspaceHost, skeletonRoot: string, plan: Workspac
615
710
  const ignore = host.readText(join(skeletonRoot, ".gitignore"));
616
711
  if (ignore !== null) writeSkeletonFile(host, plan.directory, ".gitignore", ignore);
617
712
  }
618
- mergeAdvisorPin(host, plan.directory, skeletonRoot, plan.advisorVersion, plan.repository);
713
+ mergeAdvisorPin(host, plan.directory, skeletonRoot, plan.advisorVersion, plan.owner, plan.repository);
619
714
  }
620
715
 
621
716
  function requireZero(result: CommandResult, label: string): void {
@@ -630,7 +725,14 @@ function requireZero(result: CommandResult, label: string): void {
630
725
  * registry version, marking a pin older than live as a stale-pin finding and a
631
726
  * degraded report.
632
727
  */
633
- 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 {
634
736
  const extra = new Set<string>();
635
737
  const pins: Partial<Record<DependencyBucket, string>> = {};
636
738
  const manifestRaw = host.readText(join(directory, "package.json"));
@@ -657,6 +759,11 @@ export function reportHubHealth(host: WorkspaceHost, directory: string, liveAdvi
657
759
  ? [{ bucket: bucket as DependencyBucket, pinned, grade: "stale", note: `pinned ${pinned} is older than live ${liveAdvisorVersion}` }]
658
760
  : [];
659
761
  });
762
+ const skillsManifest = summarizeSkillsManifest(
763
+ parseSkillManifest(host.readText(join(directory, SKILLS_MANIFEST_REL))),
764
+ liveLauncherVersion,
765
+ retiredThisRun,
766
+ );
660
767
  return {
661
768
  marker: readHub(host, directory) === undefined ? "missing" : "present",
662
769
  inventory: inspectInventory(host.readText(join(directory, WORKSPACE_INVENTORY_REL))),
@@ -670,7 +777,15 @@ export function reportHubHealth(host: WorkspaceHost, directory: string, liveAdvi
670
777
  dualPin: Object.values(pins).filter((value) => value !== undefined).length > 1,
671
778
  extraClossys: [...extra].sort(),
672
779
  pinFindings,
673
- 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,
674
789
  };
675
790
  }
676
791
 
@@ -705,7 +820,28 @@ export function formatHubHealth(report: HubHealthReport): string {
705
820
  for (const skip of report.skillComposition.rosterSkipped ?? []) {
706
821
  skillParts.push(`skill roster skipped (${skip.inventoryId}): ${skip.note}`);
707
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
+ }
708
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}`;
709
845
  return [
710
846
  `hub marker: ${report.marker}`,
711
847
  `inventory: ${inventory}`,
@@ -714,6 +850,10 @@ export function formatHubHealth(report: HubHealthReport): string {
714
850
  `extra @clossys/*: ${extra}`,
715
851
  `pin findings: ${findingLine}`,
716
852
  `degraded: ${report.degraded ? "yes" : "no"}`,
853
+ ...(migrationLine === undefined ? [] : [migrationLine]),
854
+ ...(linkedHostsLine === undefined ? [] : [linkedHostsLine]),
855
+ ...(inventoryDriftLine === undefined ? [] : [inventoryDriftLine]),
856
+ ...(skillsManifestLine === undefined ? [] : [skillsManifestLine]),
717
857
  ...(skillParts.length === 0 ? [] : skillParts),
718
858
  `health: ${JSON.stringify(report)}`,
719
859
  ].join("\n");
@@ -724,11 +864,19 @@ function withHealth(
724
864
  directory: string,
725
865
  headline: string,
726
866
  liveAdvisorVersion?: string,
727
- skillComposition?: SkillCompositionResult,
867
+ skillComposition?: SkillCompositionResult & { linkedHosts?: readonly DiscoveredHost[] },
868
+ liveLauncherVersion?: string,
869
+ migration?: HubHealthReport["migration"],
870
+ inventoryDrift?: HubHealthReport["inventoryDrift"],
728
871
  ): WorkspaceApplyResult {
872
+ const base = reportHubHealth(host, directory, liveAdvisorVersion, liveLauncherVersion, skillComposition?.retired ?? [], migration);
873
+ const rosterSkipped = skillComposition?.rosterSkipped ?? [];
729
874
  const health: HubHealthReport = {
730
- ...reportHubHealth(host, directory, liveAdvisorVersion),
875
+ ...base,
731
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,
732
880
  };
733
881
  return {
734
882
  state: "satisfied",
@@ -862,39 +1010,153 @@ function resolveSisterCloneTargets(
862
1010
  return { targets, skipped };
863
1011
  }
864
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
+
865
1062
  function hubRosterId(host: WorkspaceHost, hubDirectory: string, hubOwner: string, hubRepository: string): string {
866
1063
  const document = readHub(host, hubDirectory);
867
1064
  if (document !== undefined) return document.repository;
868
1065
  return `${hubOwner}/${hubRepository}`;
869
1066
  }
870
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
+
871
1130
  function composeSkillRoster(
872
1131
  host: WorkspaceHost,
873
1132
  hubDirectory: string,
874
1133
  hubOwner: string,
875
1134
  hubRepository: string,
876
- options: { launcherPackageRoot: string; skillCatalogueRoot?: string },
1135
+ options: { launcherPackageRoot: string; skillCatalogueRoot?: string; contractPath?: string },
877
1136
  ): SkillCompositionResult & {
878
1137
  readonly rosterTargets: readonly string[];
879
1138
  readonly rosterSkipped: readonly { readonly inventoryId: string; readonly note: string }[];
1139
+ readonly linkedHosts: readonly DiscoveredHost[];
880
1140
  } {
881
- const hubSkill = composeSkills(host, hubDirectory, {
1141
+ const composeOptions = {
882
1142
  launcherPackageRoot: options.launcherPackageRoot,
883
1143
  ...(options.skillCatalogueRoot === undefined ? {} : { skillCatalogueRoot: options.skillCatalogueRoot }),
884
- });
1144
+ ...(options.contractPath === undefined ? {} : { contractPath: options.contractPath }),
1145
+ };
1146
+ const linkedHosts = recordLinkedHosts(host, hubDirectory);
1147
+ const hubSkill = composeSkills(host, hubDirectory, composeOptions);
885
1148
  writeConsumerAgentsIfNeeded(host, hubDirectory);
1149
+ writeClossysReadme(host, hubDirectory);
886
1150
  const hubId = hubRosterId(host, hubDirectory, hubOwner, hubRepository);
887
1151
  const rosterTargets: string[] = [hubId];
888
1152
  const { targets, skipped } = resolveSisterCloneTargets(host, hubDirectory, hubOwner);
889
1153
  for (const target of targets) {
890
- composeSkills(host, target.directory, {
891
- launcherPackageRoot: options.launcherPackageRoot,
892
- ...(options.skillCatalogueRoot === undefined ? {} : { skillCatalogueRoot: options.skillCatalogueRoot }),
893
- });
1154
+ recordLinkedHosts(host, target.directory);
1155
+ composeSkills(host, target.directory, composeOptions);
894
1156
  writeSisterConsumerAgentsIfNeeded(host, target.directory);
895
1157
  rosterTargets.push(target.inventoryId);
896
1158
  }
897
- return { ...hubSkill, rosterTargets, rosterSkipped: skipped };
1159
+ return { ...hubSkill, rosterTargets, rosterSkipped: skipped, linkedHosts };
898
1160
  }
899
1161
 
900
1162
  function finishHubApply(
@@ -906,12 +1168,40 @@ function finishHubApply(
906
1168
  hubRepository: string,
907
1169
  liveAdvisorVersion?: string,
908
1170
  skillCatalogueRoot?: string,
1171
+ contractPath?: string,
1172
+ liveLauncherVersion?: string,
1173
+ migration?: HubHealthReport["migration"],
909
1174
  ): WorkspaceApplyResult {
910
1175
  const skillComposition = composeSkillRoster(host, directory, hubOwner, hubRepository, {
911
1176
  launcherPackageRoot,
912
1177
  ...(skillCatalogueRoot === undefined ? {} : { skillCatalogueRoot }),
1178
+ ...(contractPath === undefined ? {} : { contractPath }),
913
1179
  });
914
- return withHealth(host, directory, headline, liveAdvisorVersion, skillComposition);
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);
1185
+ }
1186
+
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 };
915
1205
  }
916
1206
 
917
1207
  /** Applies a create, resume, or adopt plan through the host. Resume refreshes composed skills and stale AGENTS.md guidance. */
@@ -923,6 +1213,8 @@ export function applyWorkspacePlan(
923
1213
  ): WorkspaceApplyResult {
924
1214
  const launcherPackageRoot = options.launcherPackageRoot ?? resolve(skeletonRoot, "..");
925
1215
  const skillCatalogueRoot = options.skillCatalogueRoot;
1216
+ const contractPath = options.contractPath;
1217
+ const liveLauncherVersion = options.liveLauncherVersion;
926
1218
  if (plan.action === "resume") {
927
1219
  if (plan.clone) {
928
1220
  requireZero(
@@ -930,6 +1222,7 @@ export function applyWorkspacePlan(
930
1222
  "gh repo clone",
931
1223
  );
932
1224
  }
1225
+ const migration = plan.migrateFrom === "legacy" ? migrateLegacyHubState(host, plan.directory) : undefined;
933
1226
  return finishHubApply(
934
1227
  host,
935
1228
  plan.directory,
@@ -937,8 +1230,11 @@ export function applyWorkspacePlan(
937
1230
  launcherPackageRoot,
938
1231
  plan.owner,
939
1232
  plan.repository,
940
- undefined,
1233
+ plan.advisorVersion,
941
1234
  skillCatalogueRoot,
1235
+ contractPath,
1236
+ liveLauncherVersion,
1237
+ migration,
942
1238
  );
943
1239
  }
944
1240
  if (plan.action === "create") {
@@ -960,6 +1256,8 @@ export function applyWorkspacePlan(
960
1256
  plan.repository,
961
1257
  plan.advisorVersion,
962
1258
  skillCatalogueRoot,
1259
+ contractPath,
1260
+ liveLauncherVersion,
963
1261
  );
964
1262
  }
965
1263
  adoptHubFiles(host, skeletonRoot, plan);
@@ -972,6 +1270,8 @@ export function applyWorkspacePlan(
972
1270
  plan.repository,
973
1271
  plan.advisorVersion,
974
1272
  skillCatalogueRoot,
1273
+ contractPath,
1274
+ liveLauncherVersion,
975
1275
  );
976
1276
  }
977
1277
 
@@ -0,0 +1,33 @@
1
+ #!/usr/bin/env node
2
+ import { isDirectInvocation } from "./cli.js";
3
+ import { createNodeHost } from "./host.js";
4
+ import { renderDoctorReport, runDoctorChecks } from "./doctor.js";
5
+
6
+ export const DOCTOR_USAGE = `Usage: launcher-doctor
7
+
8
+ Read-only. Checks the prerequisites a client needs before the hub exists:
9
+ git, the GitHub command-line tool, whether you are signed in, Node.js, and
10
+ npm. Reports the first thing that is missing, in plain language, with the
11
+ next action to take -- never a dump of everything at once.
12
+
13
+ Exit codes: 0 = ready, 2 = at least one prerequisite is missing.`;
14
+
15
+ export function main(argv: readonly string[]): number {
16
+ if (argv.length === 1 && (argv[0] === "--help" || argv[0] === "-h")) {
17
+ console.log(DOCTOR_USAGE);
18
+ return 0;
19
+ }
20
+ if (argv.length !== 0) {
21
+ console.error("launcher-doctor: takes no arguments");
22
+ return 2;
23
+ }
24
+ const host = createNodeHost();
25
+ const report = runDoctorChecks(host);
26
+ console.log(renderDoctorReport(report));
27
+ return report.allSatisfied ? 0 : 2;
28
+ }
29
+
30
+ function run(): void {
31
+ process.exitCode = main(process.argv.slice(2));
32
+ }
33
+ if (isDirectInvocation(import.meta.url, process.argv[1])) run();