@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/dist/core.js CHANGED
@@ -1,10 +1,23 @@
1
1
  import { dirname, join, resolve, sep } from "node:path";
2
2
  import { fileURLToPath } from "node:url";
3
- import { composeSkills } from "./skills.js";
3
+ import { composeSkills, SKILLS_MANIFEST_REL } from "./skills.js";
4
+ import { parseSkillManifest, summarizeSkillsManifest } from "./manifest.js";
5
+ import { detectLinkedHosts, serializeHostRecord, HOSTS_REL } from "./hosts.js";
6
+ import { reportInventoryDrift } from "./inventory-adoption.js";
4
7
  export const DEFAULT_REPOSITORY_NAME = "workspace";
5
- export const WORKSPACE_MARKER_REL = ".clossys/workspace.json";
6
- export const WORKSPACE_INVENTORY_REL = ".clossys/inventory.json";
8
+ /** The one visible, per-repository Clossys folder (#1171). Every role's output lives under it. */
9
+ export const CLOSSYS_DIR_REL = "clossys";
10
+ /** 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. */
11
+ export const STATE_DIR_REL = join(CLOSSYS_DIR_REL, ".state");
12
+ export const WORKSPACE_MARKER_REL = join(STATE_DIR_REL, "workspace.json");
13
+ export const WORKSPACE_INVENTORY_REL = join(STATE_DIR_REL, "inventory.json");
14
+ export const CLOSSYS_README_REL = join(CLOSSYS_DIR_REL, "README.md");
15
+ /** Pre-#1171 machine-state directory. Resume migrates it automatically; see `locateHub`. */
16
+ export const LEGACY_STATE_DIR_REL = ".clossys";
17
+ export const LEGACY_WORKSPACE_MARKER_REL = join(LEGACY_STATE_DIR_REL, "workspace.json");
18
+ export const LEGACY_WORKSPACE_INVENTORY_REL = join(LEGACY_STATE_DIR_REL, "inventory.json");
7
19
  export const ADVISOR_PACKAGE = "@clossys/advisor";
20
+ export const LAUNCHER_PACKAGE = "@clossys/launcher";
8
21
  const DEPENDENCY_BUCKETS = [
9
22
  "dependencies",
10
23
  "devDependencies",
@@ -33,6 +46,10 @@ is not how we signal incompatibility — \`@clossys-advisor\` is the hiring chec
33
46
  Run \`npx @clossys/launcher\` again for hub health and to refresh voices on
34
47
  clones next to the hub, not as how you talk to packages.
35
48
 
49
+ The person in this folder is a founder, not an engineer. Speak like a
50
+ person. Do not dump machine identifiers, JSON, hashes, or grant fields
51
+ unless they ask.
52
+
36
53
  Advisor is read-only until the sponsor approves a next action.
37
54
  `;
38
55
  /** Canned guidance for inventoried product checkouts (not the hub). */
@@ -50,6 +67,21 @@ This repository is the account hub for Foundry packages. It inventories
50
67
  where packages are installed and coordinates engagement. It is not a
51
68
  product application and does not need the whole catalogue installed here.
52
69
 
70
+ The person in this folder is a founder, not an engineer. Speak like a
71
+ person. Do not dump machine identifiers, JSON, hashes, or grant fields
72
+ unless they ask.
73
+
74
+ When there is a next step, say only:
75
+
76
+ 1. Where we are (one sentence).
77
+ 2. What you should do next (one sentence).
78
+ 3. What we will not do until you say yes.
79
+ 4. Whether anything will be saved to git (usually no).
80
+
81
+ Wait for a plain yes before changing files. "Approved" in chat is
82
+ permission for that one step only. It is not a lasting grant and it
83
+ does not become a commit unless someone later saves a file.
84
+
53
85
  Open this folder in your coding agent. Advisor is read-only until you
54
86
  approve a next action.
55
87
 
@@ -146,8 +178,8 @@ export function isHubDocument(value) {
146
178
  return false;
147
179
  return true;
148
180
  }
149
- function readHub(host, directory) {
150
- const raw = host.readText(join(directory, WORKSPACE_MARKER_REL));
181
+ function readHubAt(host, path) {
182
+ const raw = host.readText(path);
151
183
  if (raw === null)
152
184
  return undefined;
153
185
  try {
@@ -158,7 +190,29 @@ function readHub(host, directory) {
158
190
  return undefined;
159
191
  }
160
192
  }
161
- /** Classifies a generated hub inventory (packed template skeleton/.clossys/inventory.json; the generated path does not ship) without inventing repositories. */
193
+ /**
194
+ * Locates the hub marker across the `.clossys/` -> `clossys/.state/`
195
+ * migration (#1171). `clean`: only the current path has a marker. `legacy`:
196
+ * only the old path does; resume migrates it (see `migrateLegacyHubState`).
197
+ * `indeterminate`: both paths carry a parseable marker; launcher never
198
+ * merges them silently, so `planWorkspace` refuses instead. `none`: neither
199
+ * path has one.
200
+ */
201
+ function locateHub(host, directory) {
202
+ const current = readHubAt(host, join(directory, WORKSPACE_MARKER_REL));
203
+ const legacy = readHubAt(host, join(directory, LEGACY_WORKSPACE_MARKER_REL));
204
+ if (current !== undefined && legacy !== undefined)
205
+ return { migration: "indeterminate" };
206
+ if (current !== undefined)
207
+ return { document: current, migration: "clean" };
208
+ if (legacy !== undefined)
209
+ return { document: legacy, migration: "legacy" };
210
+ return { migration: "none" };
211
+ }
212
+ function readHub(host, directory) {
213
+ return locateHub(host, directory).document;
214
+ }
215
+ /** Classifies a generated hub inventory (packed template skeleton/clossys/.state/inventory.json; the generated path does not ship) without inventing repositories. */
162
216
  export function inspectInventory(raw) {
163
217
  if (raw === null)
164
218
  return { status: "missing", count: 0 };
@@ -212,6 +266,16 @@ export function hasAdvisorPin(manifest) {
212
266
  return DEPENDENCY_BUCKETS.some((bucket) => clossysNames(manifest[bucket], new Set()) !== undefined);
213
267
  }
214
268
  /** Collects GitHub owner, cwd shape, default-hub presence, and the public Advisor pin. */
269
+ /**
270
+ * Reads the public `@clossys/launcher` registry version, used only to grade
271
+ * catalogue-sourced skill staleness in the health report (#1183). A missing
272
+ * or unparseable read leaves staleness ungraded rather than refusing.
273
+ */
274
+ export function readLiveLauncherVersion(host) {
275
+ const viewed = host.run("npm", ["view", LAUNCHER_PACKAGE, "version"]);
276
+ const version = viewed.stdout.trim();
277
+ return viewed.status === 0 && /^\d+\.\d+\.\d+$/.test(version) ? version : undefined;
278
+ }
215
279
  export function observeWorkspace(host) {
216
280
  const cwd = host.cwd;
217
281
  const ghAvailable = commandAvailable(host, "gh");
@@ -251,24 +315,26 @@ export function observeWorkspace(host) {
251
315
  if (viewed.status === 0)
252
316
  remoteDefaultHub = { owner: ownerGuess, repository: DEFAULT_REPOSITORY_NAME };
253
317
  }
254
- // Skip the registry read when the tree already pins Advisor in some bucket:
255
- // adopt leaves an existing pin alone, so no live version is needed to plan it.
256
- const manifestPinsAdvisor = hasAdvisorPin(readJson(host, join(cwd, "package.json")));
257
- if (!manifestPinsAdvisor) {
258
- const viewedAdvisor = host.run("npm", ["view", ADVISOR_PACKAGE, "version"]);
259
- const version = viewedAdvisor.stdout.trim();
260
- if (viewedAdvisor.status === 0 && /^\d+\.\d+\.\d+$/.test(version))
261
- advisorVersion = version;
262
- }
318
+ const viewedAdvisor = host.run("npm", ["view", ADVISOR_PACKAGE, "version"]);
319
+ const version = viewedAdvisor.stdout.trim();
320
+ if (viewedAdvisor.status === 0 && /^\d+\.\d+\.\d+$/.test(version))
321
+ advisorVersion = version;
322
+ const hubLocation = locateHub(host, cwd);
323
+ // While only the legacy `.clossys/` marker exists, its sibling inventory is
324
+ // the one resume will migrate; read from there so planning sees it too.
325
+ const inventoryRaw = hubLocation.migration === "legacy"
326
+ ? host.readText(join(cwd, LEGACY_WORKSPACE_INVENTORY_REL))
327
+ : host.readText(join(cwd, WORKSPACE_INVENTORY_REL));
263
328
  const cwdObservation = {
264
329
  absolutePath: cwd,
265
330
  empty: isEffectivelyEmpty(entries),
266
331
  git,
267
332
  ...(githubOwner === undefined ? {} : { githubOwner }),
268
333
  ...(githubRepository === undefined ? {} : { githubRepository }),
269
- ...(readHub(host, cwd) === undefined ? {} : { hub: readHub(host, cwd) }),
334
+ ...(hubLocation.document === undefined ? {} : { hub: hubLocation.document }),
335
+ ...(hubLocation.migration === "none" ? {} : { hubMigration: hubLocation.migration }),
270
336
  looksLikeFoundry: looksLikeFoundry(host, cwd),
271
- inventory: inspectInventory(host.readText(join(cwd, WORKSPACE_INVENTORY_REL))),
337
+ inventory: inspectInventory(inventoryRaw),
272
338
  };
273
339
  return {
274
340
  cwd: cwdObservation,
@@ -332,7 +398,7 @@ function resolveAdoptInventory(host, cwd, inventoryPath) {
332
398
  const trimmed = inventoryPath?.trim();
333
399
  const onDiskPopulated = cwd.inventory?.status === "populated";
334
400
  if (!onDiskPopulated && !trimmed) {
335
- return refuse("violated", "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");
401
+ return refuse("violated", "appointing requires a populated generated hub inventory (packed template skeleton/clossys/.state/inventory.json; the generated path does not ship), or --inventory <path> to a populated inventory document");
336
402
  }
337
403
  if (!trimmed)
338
404
  return {};
@@ -361,6 +427,9 @@ function resolveAdoptInventory(host, cwd, inventoryPath) {
361
427
  }
362
428
  export function planWorkspace(observation, host, options = {}) {
363
429
  const { cwd } = observation;
430
+ if (cwd.hubMigration === "indeterminate") {
431
+ return refuse("indeterminate", `both ${WORKSPACE_MARKER_REL} and the legacy ${LEGACY_WORKSPACE_MARKER_REL} are present; launcher never merges them silently -- remove one before resuming`);
432
+ }
364
433
  const envOwner = observation.envOwner ?? (host.env.CLOSSYS_OWNER?.trim() || undefined);
365
434
  if (envOwner !== undefined &&
366
435
  !cwd.looksLikeFoundry &&
@@ -380,6 +449,8 @@ export function planWorkspace(observation, host, options = {}) {
380
449
  repository: repoNameFromSlug(cwd.hub.repository, DEFAULT_REPOSITORY_NAME),
381
450
  directory: cwd.absolutePath,
382
451
  clone: false,
452
+ ...(observation.advisorVersion === undefined ? {} : { advisorVersion: observation.advisorVersion }),
453
+ ...(cwd.hubMigration === "legacy" ? { migrateFrom: "legacy" } : {}),
383
454
  };
384
455
  }
385
456
  if (cwd.git && cwd.githubOwner && cwd.githubRepository) {
@@ -415,6 +486,7 @@ export function planWorkspace(observation, host, options = {}) {
415
486
  repository: observation.remoteDefaultHub.repository,
416
487
  directory: cwd.absolutePath,
417
488
  clone: true,
489
+ ...(observation.advisorVersion === undefined ? {} : { advisorVersion: observation.advisorVersion }),
418
490
  };
419
491
  }
420
492
  if (!observation.ghAvailable) {
@@ -467,15 +539,20 @@ function clossysNames(bucket, extra) {
467
539
  }
468
540
  return advisor;
469
541
  }
470
- /** Leaves an existing Advisor pin in whichever bucket it already occupies. */
471
- function mergeAdvisorPin(host, directory, skeletonRoot, advisorVersion, repository) {
542
+ /**
543
+ * Pins live Advisor in `devDependencies` only. Relocates a pin left in any
544
+ * other bucket and overwrites a frozen version. Does not touch other
545
+ * `@clossys/*` names. A dedicated `{owner}/workspace` hub is named
546
+ * `@owner/workspace`.
547
+ */
548
+ function mergeAdvisorPin(host, directory, skeletonRoot, advisorVersion, owner, repository) {
472
549
  const path = join(directory, "package.json");
473
550
  const raw = host.readText(path);
474
551
  if (raw === null) {
475
552
  const skeleton = host.readText(join(skeletonRoot, "package.json"));
476
553
  if (skeleton === null)
477
554
  throw new Error("missing skeleton package.json");
478
- writeSkeletonFile(host, directory, "package.json", substitute(skeleton, { owner: repository, repository, advisorVersion }));
555
+ writeSkeletonFile(host, directory, "package.json", substitute(skeleton, { owner, repository, advisorVersion }));
479
556
  return;
480
557
  }
481
558
  let manifest;
@@ -488,13 +565,25 @@ function mergeAdvisorPin(host, directory, skeletonRoot, advisorVersion, reposito
488
565
  catch {
489
566
  throw new Error("existing package.json is unreadable JSON");
490
567
  }
491
- const extra = new Set();
492
- const pinnedSomewhere = DEPENDENCY_BUCKETS.some((bucket) => clossysNames(manifest[bucket], extra) !== undefined);
493
- if (pinnedSomewhere)
494
- return;
568
+ for (const bucket of DEPENDENCY_BUCKETS) {
569
+ if (bucket === "devDependencies")
570
+ continue;
571
+ const current = manifest[bucket];
572
+ if (!isRecord(current) || !(ADVISOR_PACKAGE in current))
573
+ continue;
574
+ const next = { ...current };
575
+ delete next[ADVISOR_PACKAGE];
576
+ if (Object.keys(next).length === 0)
577
+ delete manifest[bucket];
578
+ else
579
+ manifest[bucket] = next;
580
+ }
495
581
  const devDependencies = isRecord(manifest.devDependencies) ? { ...manifest.devDependencies } : {};
496
582
  devDependencies[ADVISOR_PACKAGE] = advisorVersion;
497
583
  manifest.devDependencies = devDependencies;
584
+ if (repository === DEFAULT_REPOSITORY_NAME) {
585
+ manifest.name = `@${owner}/${repository}`;
586
+ }
498
587
  host.writeText(path, `${JSON.stringify(manifest, null, 2)}\n`);
499
588
  }
500
589
  function copySkeleton(host, skeletonRoot, plan) {
@@ -566,7 +655,7 @@ function adoptHubFiles(host, skeletonRoot, plan) {
566
655
  if (ignore !== null)
567
656
  writeSkeletonFile(host, plan.directory, ".gitignore", ignore);
568
657
  }
569
- mergeAdvisorPin(host, plan.directory, skeletonRoot, plan.advisorVersion, plan.repository);
658
+ mergeAdvisorPin(host, plan.directory, skeletonRoot, plan.advisorVersion, plan.owner, plan.repository);
570
659
  }
571
660
  function requireZero(result, label) {
572
661
  if (result.status !== 0) {
@@ -579,7 +668,7 @@ function requireZero(result, label) {
579
668
  * registry version, marking a pin older than live as a stale-pin finding and a
580
669
  * degraded report.
581
670
  */
582
- export function reportHubHealth(host, directory, liveAdvisorVersion) {
671
+ export function reportHubHealth(host, directory, liveAdvisorVersion, liveLauncherVersion, retiredThisRun = [], migration) {
583
672
  const extra = new Set();
584
673
  const pins = {};
585
674
  const manifestRaw = host.readText(join(directory, "package.json"));
@@ -609,6 +698,7 @@ export function reportHubHealth(host, directory, liveAdvisorVersion) {
609
698
  ? [{ bucket: bucket, pinned, grade: "stale", note: `pinned ${pinned} is older than live ${liveAdvisorVersion}` }]
610
699
  : [];
611
700
  });
701
+ const skillsManifest = summarizeSkillsManifest(parseSkillManifest(host.readText(join(directory, SKILLS_MANIFEST_REL))), liveLauncherVersion, retiredThisRun);
612
702
  return {
613
703
  marker: readHub(host, directory) === undefined ? "missing" : "present",
614
704
  inventory: inspectInventory(host.readText(join(directory, WORKSPACE_INVENTORY_REL))),
@@ -622,7 +712,14 @@ export function reportHubHealth(host, directory, liveAdvisorVersion) {
622
712
  dualPin: Object.values(pins).filter((value) => value !== undefined).length > 1,
623
713
  extraClossys: [...extra].sort(),
624
714
  pinFindings,
625
- degraded: pinFindings.some((finding) => finding.grade === "stale"),
715
+ degraded: pinFindings.some((finding) => finding.grade === "stale") ||
716
+ Object.values(pins).filter((value) => value !== undefined).length > 1 ||
717
+ pins.devDependencies === undefined ||
718
+ pins.dependencies !== undefined ||
719
+ pins.optionalDependencies !== undefined ||
720
+ pins.peerDependencies !== undefined,
721
+ ...(migration === undefined ? {} : { migration }),
722
+ skillsManifest,
626
723
  };
627
724
  }
628
725
  export function formatHubHealth(report) {
@@ -652,7 +749,24 @@ export function formatHubHealth(report) {
652
749
  for (const skip of report.skillComposition.rosterSkipped ?? []) {
653
750
  skillParts.push(`skill roster skipped (${skip.inventoryId}): ${skip.note}`);
654
751
  }
752
+ if (report.skillComposition.retired !== undefined && report.skillComposition.retired.length > 0) {
753
+ skillParts.push(`skills retired: ${report.skillComposition.retired.map((name) => `clossys-${name}`).join(", ")}`);
754
+ }
655
755
  }
756
+ const skillsManifestLine = report.skillsManifest === undefined
757
+ ? undefined
758
+ : report.skillsManifest.status === "missing"
759
+ ? "skills manifest: missing"
760
+ : `skills: ${report.skillsManifest.stale} out of date, ${report.skillsManifest.retired} retired (${report.skillsManifest.total} composed)`;
761
+ const migrationLine = report.migration === undefined ? undefined : `migration: moved hub state from ${report.migration.from} to ${report.migration.to}`;
762
+ const linkedHostsLine = report.linkedHosts === undefined
763
+ ? undefined
764
+ : `linked hosts: ${report.linkedHosts.length === 0 ? "none detected" : report.linkedHosts.join(", ")}`;
765
+ const inventoryDriftLine = report.inventoryDrift === undefined
766
+ ? undefined
767
+ : report.inventoryDrift.status === "indeterminate"
768
+ ? `inventory drift: indeterminate${report.inventoryDrift.note === undefined ? "" : ` -- ${report.inventoryDrift.note}`}`
769
+ : `inventory drift: external-only ${report.inventoryDrift.externalOnly.length}, launcher-only ${report.inventoryDrift.launcherOnly.length}, agreeing ${report.inventoryDrift.agreeing.length}`;
656
770
  return [
657
771
  `hub marker: ${report.marker}`,
658
772
  `inventory: ${inventory}`,
@@ -661,14 +775,23 @@ export function formatHubHealth(report) {
661
775
  `extra @clossys/*: ${extra}`,
662
776
  `pin findings: ${findingLine}`,
663
777
  `degraded: ${report.degraded ? "yes" : "no"}`,
778
+ ...(migrationLine === undefined ? [] : [migrationLine]),
779
+ ...(linkedHostsLine === undefined ? [] : [linkedHostsLine]),
780
+ ...(inventoryDriftLine === undefined ? [] : [inventoryDriftLine]),
781
+ ...(skillsManifestLine === undefined ? [] : [skillsManifestLine]),
664
782
  ...(skillParts.length === 0 ? [] : skillParts),
665
783
  `health: ${JSON.stringify(report)}`,
666
784
  ].join("\n");
667
785
  }
668
- function withHealth(host, directory, headline, liveAdvisorVersion, skillComposition) {
786
+ function withHealth(host, directory, headline, liveAdvisorVersion, skillComposition, liveLauncherVersion, migration, inventoryDrift) {
787
+ const base = reportHubHealth(host, directory, liveAdvisorVersion, liveLauncherVersion, skillComposition?.retired ?? [], migration);
788
+ const rosterSkipped = skillComposition?.rosterSkipped ?? [];
669
789
  const health = {
670
- ...reportHubHealth(host, directory, liveAdvisorVersion),
790
+ ...base,
671
791
  ...(skillComposition === undefined ? {} : { skillComposition }),
792
+ ...(skillComposition?.linkedHosts === undefined ? {} : { linkedHosts: skillComposition.linkedHosts }),
793
+ ...(inventoryDrift === undefined || inventoryDrift.status === "no-external-source" ? {} : { inventoryDrift }),
794
+ degraded: base.degraded || rosterSkipped.length > 0,
672
795
  };
673
796
  return {
674
797
  state: "satisfied",
@@ -798,55 +921,179 @@ function resolveSisterCloneTargets(host, hubDirectory, hubOwner) {
798
921
  }
799
922
  return { targets, skipped };
800
923
  }
924
+ /**
925
+ * Explicit, approved action (#1179, the #1045 pattern): clones every
926
+ * inventoried repository that resolveSisterCloneTargets's own skip pass
927
+ * identified as "just needs a clone" (CLONE_NOT_BESIDE_HUB_NOTE), and only
928
+ * those -- every other skip reason (wrong account, foundry supplier tree,
929
+ * origin mismatch, invalid slug) is left exactly as skipped, never
930
+ * attempted. Never called from resume's default path; only from the
931
+ * --clone-missing flag. Reverses the launcher README's own no-clone
932
+ * default for exactly this one approved action.
933
+ */
934
+ export function cloneMissingInventoryRepositories(host, hubDirectory, hubOwner) {
935
+ const { skipped } = resolveSisterCloneTargets(host, hubDirectory, hubOwner);
936
+ const parent = dirname(resolve(hubDirectory));
937
+ const outcomes = [];
938
+ for (const skip of skipped) {
939
+ if (skip.note !== CLONE_NOT_BESIDE_HUB_NOTE) {
940
+ outcomes.push({ inventoryId: skip.inventoryId, result: "skipped-other-reason", note: skip.note });
941
+ continue;
942
+ }
943
+ const parsed = parseInventoryRepositoryId(skip.inventoryId, hubOwner);
944
+ if (parsed === null) {
945
+ outcomes.push({ inventoryId: skip.inventoryId, result: "failed", note: "inventory id is not a valid repository slug" });
946
+ continue;
947
+ }
948
+ const siblingPath = join(parent, parsed.repository);
949
+ const result = host.run("gh", ["repo", "clone", `${hubOwner}/${parsed.repository}`, siblingPath]);
950
+ if (result.status === 0) {
951
+ outcomes.push({ inventoryId: skip.inventoryId, result: "cloned", note: `cloned to ${siblingPath}` });
952
+ }
953
+ else {
954
+ outcomes.push({
955
+ inventoryId: skip.inventoryId,
956
+ result: "failed",
957
+ note: `gh repo clone exited ${result.status ?? "null"}: ${result.stderr.trim() || "no stderr"}`,
958
+ });
959
+ }
960
+ }
961
+ return outcomes;
962
+ }
801
963
  function hubRosterId(host, hubDirectory, hubOwner, hubRepository) {
802
964
  const document = readHub(host, hubDirectory);
803
965
  if (document !== undefined)
804
966
  return document.repository;
805
967
  return `${hubOwner}/${hubRepository}`;
806
968
  }
969
+ /**
970
+ * Regenerates the generated `README.md` at the root of `clossys/`: an index of which `clossys/<role>/`
971
+ * folders are active here, and what `.state/` holds. Written on every apply
972
+ * so it never drifts from what is actually on disk (#1171).
973
+ */
974
+ function writeClossysReadme(host, directory) {
975
+ const root = join(directory, CLOSSYS_DIR_REL);
976
+ const roles = host
977
+ .isDirectory(root)
978
+ ? host
979
+ .readDir(root)
980
+ .filter((name) => name !== ".state" && name !== "README.md" && host.isDirectory(join(root, name)))
981
+ .sort((a, b) => a.localeCompare(b))
982
+ : [];
983
+ const hasBrief = host.exists(join(root, "brief.json"));
984
+ const lines = [
985
+ "# clossys/",
986
+ "",
987
+ "Generated by `@clossys/launcher`. This file is rewritten on every launcher",
988
+ `run to reflect what is active here; do not edit it by hand. Last generated: ${host.now()}.`,
989
+ "",
990
+ "## Engagement brief",
991
+ "",
992
+ ...(hasBrief
993
+ ? ["`clossys/brief.json` — why each role is staffed here, its goals, handoffs, and sequence (owner: @clossys/advisor)."]
994
+ : ["No engagement brief yet. `@clossys-advisor` writes `clossys/brief.json` once a plan is approved."]),
995
+ "",
996
+ "## Active roles",
997
+ "",
998
+ ...(roles.length === 0
999
+ ? ["No role folder is active here yet."]
1000
+ : roles.map((role) => `- \`clossys/${role}/\` — @clossys-${role}`)),
1001
+ "",
1002
+ "## Machine state",
1003
+ "",
1004
+ "`clossys/.state/` holds machine files only: the hub marker, the inventory,",
1005
+ "and the skills manifest. It is visible so it is easy to find, but it is not",
1006
+ "a place a person edits by hand.",
1007
+ "",
1008
+ ];
1009
+ writeSkeletonFile(host, directory, CLOSSYS_README_REL, lines.join("\n"));
1010
+ }
1011
+ /**
1012
+ * Reads which coding-agent hosts already had skill discovery linked in
1013
+ * `directory` BEFORE this call, then records that snapshot to
1014
+ * `clossys/.state/hosts.json` (#1180). Deliberately called ahead of
1015
+ * `composeSkills`, which unconditionally stamps discovery links for every
1016
+ * host once it runs -- reading afterward would report "all hosts" on every
1017
+ * apply and make the record meaningless.
1018
+ */
1019
+ function recordLinkedHosts(host, directory) {
1020
+ const linkedHosts = detectLinkedHosts(host, directory);
1021
+ writeSkeletonFile(host, directory, HOSTS_REL, serializeHostRecord({ schemaVersion: 1, linkedHosts, recordedAt: host.now() }));
1022
+ return linkedHosts;
1023
+ }
807
1024
  function composeSkillRoster(host, hubDirectory, hubOwner, hubRepository, options) {
808
- const hubSkill = composeSkills(host, hubDirectory, {
1025
+ const composeOptions = {
809
1026
  launcherPackageRoot: options.launcherPackageRoot,
810
1027
  ...(options.skillCatalogueRoot === undefined ? {} : { skillCatalogueRoot: options.skillCatalogueRoot }),
811
- });
1028
+ ...(options.contractPath === undefined ? {} : { contractPath: options.contractPath }),
1029
+ };
1030
+ const linkedHosts = recordLinkedHosts(host, hubDirectory);
1031
+ const hubSkill = composeSkills(host, hubDirectory, composeOptions);
812
1032
  writeConsumerAgentsIfNeeded(host, hubDirectory);
1033
+ writeClossysReadme(host, hubDirectory);
813
1034
  const hubId = hubRosterId(host, hubDirectory, hubOwner, hubRepository);
814
1035
  const rosterTargets = [hubId];
815
1036
  const { targets, skipped } = resolveSisterCloneTargets(host, hubDirectory, hubOwner);
816
1037
  for (const target of targets) {
817
- composeSkills(host, target.directory, {
818
- launcherPackageRoot: options.launcherPackageRoot,
819
- ...(options.skillCatalogueRoot === undefined ? {} : { skillCatalogueRoot: options.skillCatalogueRoot }),
820
- });
1038
+ recordLinkedHosts(host, target.directory);
1039
+ composeSkills(host, target.directory, composeOptions);
821
1040
  writeSisterConsumerAgentsIfNeeded(host, target.directory);
822
1041
  rosterTargets.push(target.inventoryId);
823
1042
  }
824
- return { ...hubSkill, rosterTargets, rosterSkipped: skipped };
1043
+ return { ...hubSkill, rosterTargets, rosterSkipped: skipped, linkedHosts };
825
1044
  }
826
- function finishHubApply(host, directory, headline, launcherPackageRoot, hubOwner, hubRepository, liveAdvisorVersion, skillCatalogueRoot) {
1045
+ function finishHubApply(host, directory, headline, launcherPackageRoot, hubOwner, hubRepository, liveAdvisorVersion, skillCatalogueRoot, contractPath, liveLauncherVersion, migration) {
827
1046
  const skillComposition = composeSkillRoster(host, directory, hubOwner, hubRepository, {
828
1047
  launcherPackageRoot,
829
1048
  ...(skillCatalogueRoot === undefined ? {} : { skillCatalogueRoot }),
1049
+ ...(contractPath === undefined ? {} : { contractPath }),
830
1050
  });
831
- return withHealth(host, directory, headline, liveAdvisorVersion, skillComposition);
1051
+ // #1216: when the hub marker declares an external inventory, report drift against
1052
+ // it on every apply (create's fresh marker never declares one, so this is a no-op there).
1053
+ const hubDocument = readHub(host, directory);
1054
+ const inventoryDrift = reportInventoryDrift(host, directory, hubDocument?.externalInventory, WORKSPACE_INVENTORY_REL);
1055
+ return withHealth(host, directory, headline, liveAdvisorVersion, skillComposition, liveLauncherVersion, migration, inventoryDrift);
1056
+ }
1057
+ /**
1058
+ * Migrates a legacy `.clossys/` hub marker (and its sibling inventory, when
1059
+ * present) to `clossys/.state/`, then removes the old directory. Called only
1060
+ * when `locateHub` found the marker at the legacy path and nowhere else
1061
+ * (`plan.migrateFrom === "legacy"`); a hub with markers at both paths is
1062
+ * refused by `planWorkspace` before apply ever runs, so this never merges
1063
+ * two hub states.
1064
+ */
1065
+ function migrateLegacyHubState(host, directory) {
1066
+ const markerRaw = host.readText(join(directory, LEGACY_WORKSPACE_MARKER_REL));
1067
+ if (markerRaw === null)
1068
+ return undefined;
1069
+ writeSkeletonFile(host, directory, WORKSPACE_MARKER_REL, markerRaw.endsWith("\n") ? markerRaw : `${markerRaw}\n`);
1070
+ const inventoryRaw = host.readText(join(directory, LEGACY_WORKSPACE_INVENTORY_REL));
1071
+ if (inventoryRaw !== null) {
1072
+ writeSkeletonFile(host, directory, WORKSPACE_INVENTORY_REL, inventoryRaw.endsWith("\n") ? inventoryRaw : `${inventoryRaw}\n`);
1073
+ }
1074
+ host.remove(join(directory, LEGACY_STATE_DIR_REL));
1075
+ return { status: "migrated", from: LEGACY_STATE_DIR_REL, to: STATE_DIR_REL };
832
1076
  }
833
1077
  /** Applies a create, resume, or adopt plan through the host. Resume refreshes composed skills and stale AGENTS.md guidance. */
834
1078
  export function applyWorkspacePlan(host, plan, skeletonRoot, options = {}) {
835
1079
  const launcherPackageRoot = options.launcherPackageRoot ?? resolve(skeletonRoot, "..");
836
1080
  const skillCatalogueRoot = options.skillCatalogueRoot;
1081
+ const contractPath = options.contractPath;
1082
+ const liveLauncherVersion = options.liveLauncherVersion;
837
1083
  if (plan.action === "resume") {
838
1084
  if (plan.clone) {
839
1085
  requireZero(host.run("gh", ["repo", "clone", `${plan.owner}/${plan.repository}`, plan.directory]), "gh repo clone");
840
1086
  }
841
- return finishHubApply(host, plan.directory, `resumed ${plan.owner}/${plan.repository} as the account hub\nOpen this folder in your coding agent. Advisor stays read-only until you approve a next action.`, launcherPackageRoot, plan.owner, plan.repository, undefined, skillCatalogueRoot);
1087
+ const migration = plan.migrateFrom === "legacy" ? migrateLegacyHubState(host, plan.directory) : undefined;
1088
+ return finishHubApply(host, plan.directory, `resumed ${plan.owner}/${plan.repository} as the account hub\nOpen this folder in your coding agent. Advisor stays read-only until you approve a next action.`, launcherPackageRoot, plan.owner, plan.repository, plan.advisorVersion, skillCatalogueRoot, contractPath, liveLauncherVersion, migration);
842
1089
  }
843
1090
  if (plan.action === "create") {
844
1091
  copySkeleton(host, skeletonRoot, plan);
845
1092
  requireZero(host.run("gh", ["repo", "create", `${plan.owner}/${plan.repository}`, "--private", "--source", plan.directory, "--remote", "origin", "--push"], { cwd: plan.directory }), "gh repo create");
846
- return finishHubApply(host, plan.directory, `created ${plan.owner}/${plan.repository} as the account hub\nOpen this folder in your coding agent. Advisor stays read-only until you approve a next action.`, launcherPackageRoot, plan.owner, plan.repository, plan.advisorVersion, skillCatalogueRoot);
1093
+ return finishHubApply(host, plan.directory, `created ${plan.owner}/${plan.repository} as the account hub\nOpen this folder in your coding agent. Advisor stays read-only until you approve a next action.`, launcherPackageRoot, plan.owner, plan.repository, plan.advisorVersion, skillCatalogueRoot, contractPath, liveLauncherVersion);
847
1094
  }
848
1095
  adoptHubFiles(host, skeletonRoot, plan);
849
- return finishHubApply(host, plan.directory, `appointed ${plan.owner}/${plan.repository} as the account hub\nExisting project files were kept. This hub inventories engagement; it does not install the catalogue into the repo.`, launcherPackageRoot, plan.owner, plan.repository, plan.advisorVersion, skillCatalogueRoot);
1096
+ return finishHubApply(host, plan.directory, `appointed ${plan.owner}/${plan.repository} as the account hub\nExisting project files were kept. This hub inventories engagement; it does not install the catalogue into the repo.`, launcherPackageRoot, plan.owner, plan.repository, plan.advisorVersion, skillCatalogueRoot, contractPath, liveLauncherVersion);
850
1097
  }
851
1098
  export function launcherPackageRootFromModule(moduleUrl) {
852
1099
  return resolve(dirname(fileURLToPath(moduleUrl)), "..");