@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/dist/core.js CHANGED
@@ -1,9 +1,23 @@
1
1
  import { dirname, join, resolve, sep } from "node:path";
2
2
  import { fileURLToPath } from "node:url";
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";
3
7
  export const DEFAULT_REPOSITORY_NAME = "workspace";
4
- export const WORKSPACE_MARKER_REL = ".clossys/workspace.json";
5
- 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");
6
19
  export const ADVISOR_PACKAGE = "@clossys/advisor";
20
+ export const LAUNCHER_PACKAGE = "@clossys/launcher";
7
21
  const DEPENDENCY_BUCKETS = [
8
22
  "dependencies",
9
23
  "devDependencies",
@@ -22,10 +36,52 @@ const SKELETON_FILES = [
22
36
  /** Written at generate time so this package never ships a nested AGENTS.md. */
23
37
  export const CONSUMER_AGENTS_MD = `# Account workspace
24
38
 
39
+ This folder is the account hub for Foundry packages.
40
+
41
+ After \`npx @clossys/launcher\`, the same \`@clossys-*\` team is composed in
42
+ every inventoried checkout beside this hub. Talk with \`@clossys-advisor\` and
43
+ \`@clossys-<package>\` here or in a product repository. A missing \`@\` mention
44
+ is not how we signal incompatibility — \`@clossys-advisor\` is the hiring check.
45
+
46
+ Run \`npx @clossys/launcher\` again for hub health and to refresh voices on
47
+ clones next to the hub, not as how you talk to packages.
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
+
53
+ Advisor is read-only until the sponsor approves a next action.
54
+ `;
55
+ /** Canned guidance for inventoried product checkouts (not the hub). */
56
+ export const SISTER_CONSUMER_AGENTS_MD = `# Product repository
57
+
58
+ This repository is part of the same account engagement. The same
59
+ \`@clossys-<package>\` team is here for intro and questions;
60
+ \`@clossys-advisor\` decides hiring and compatibility. This folder is not the
61
+ hub — engines are hired per repository, not dumped here.
62
+ `;
63
+ /** Previous generate-time guidance; used to refresh stale hub AGENTS.md on resume. */
64
+ export const LEGACY_CONSUMER_AGENTS_MD = `# Account workspace
65
+
25
66
  This repository is the account hub for Foundry packages. It inventories
26
67
  where packages are installed and coordinates engagement. It is not a
27
68
  product application and does not need the whole catalogue installed here.
28
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
+
29
85
  Open this folder in your coding agent. Advisor is read-only until you
30
86
  approve a next action.
31
87
 
@@ -122,8 +178,8 @@ export function isHubDocument(value) {
122
178
  return false;
123
179
  return true;
124
180
  }
125
- function readHub(host, directory) {
126
- const raw = host.readText(join(directory, WORKSPACE_MARKER_REL));
181
+ function readHubAt(host, path) {
182
+ const raw = host.readText(path);
127
183
  if (raw === null)
128
184
  return undefined;
129
185
  try {
@@ -134,7 +190,29 @@ function readHub(host, directory) {
134
190
  return undefined;
135
191
  }
136
192
  }
137
- /** 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. */
138
216
  export function inspectInventory(raw) {
139
217
  if (raw === null)
140
218
  return { status: "missing", count: 0 };
@@ -188,6 +266,16 @@ export function hasAdvisorPin(manifest) {
188
266
  return DEPENDENCY_BUCKETS.some((bucket) => clossysNames(manifest[bucket], new Set()) !== undefined);
189
267
  }
190
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
+ }
191
279
  export function observeWorkspace(host) {
192
280
  const cwd = host.cwd;
193
281
  const ghAvailable = commandAvailable(host, "gh");
@@ -227,24 +315,26 @@ export function observeWorkspace(host) {
227
315
  if (viewed.status === 0)
228
316
  remoteDefaultHub = { owner: ownerGuess, repository: DEFAULT_REPOSITORY_NAME };
229
317
  }
230
- // Skip the registry read when the tree already pins Advisor in some bucket:
231
- // adopt leaves an existing pin alone, so no live version is needed to plan it.
232
- const manifestPinsAdvisor = hasAdvisorPin(readJson(host, join(cwd, "package.json")));
233
- if (!manifestPinsAdvisor) {
234
- const viewedAdvisor = host.run("npm", ["view", ADVISOR_PACKAGE, "version"]);
235
- const version = viewedAdvisor.stdout.trim();
236
- if (viewedAdvisor.status === 0 && /^\d+\.\d+\.\d+$/.test(version))
237
- advisorVersion = version;
238
- }
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));
239
328
  const cwdObservation = {
240
329
  absolutePath: cwd,
241
330
  empty: isEffectivelyEmpty(entries),
242
331
  git,
243
332
  ...(githubOwner === undefined ? {} : { githubOwner }),
244
333
  ...(githubRepository === undefined ? {} : { githubRepository }),
245
- ...(readHub(host, cwd) === undefined ? {} : { hub: readHub(host, cwd) }),
334
+ ...(hubLocation.document === undefined ? {} : { hub: hubLocation.document }),
335
+ ...(hubLocation.migration === "none" ? {} : { hubMigration: hubLocation.migration }),
246
336
  looksLikeFoundry: looksLikeFoundry(host, cwd),
247
- inventory: inspectInventory(host.readText(join(cwd, WORKSPACE_INVENTORY_REL))),
337
+ inventory: inspectInventory(inventoryRaw),
248
338
  };
249
339
  return {
250
340
  cwd: cwdObservation,
@@ -278,7 +368,7 @@ function resolveOwner(observation, host) {
278
368
  * Decides create, resume, or adopt from a cwd observation.
279
369
  * Appointing means: run this from the GitHub repository that should own the hub.
280
370
  */
281
- function readInventoryRepositories(host, source, label) {
371
+ export function readInventoryRepositories(host, source, label) {
282
372
  const raw = host.readText(source);
283
373
  if (raw === null)
284
374
  return [];
@@ -308,7 +398,7 @@ function resolveAdoptInventory(host, cwd, inventoryPath) {
308
398
  const trimmed = inventoryPath?.trim();
309
399
  const onDiskPopulated = cwd.inventory?.status === "populated";
310
400
  if (!onDiskPopulated && !trimmed) {
311
- 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");
312
402
  }
313
403
  if (!trimmed)
314
404
  return {};
@@ -337,6 +427,9 @@ function resolveAdoptInventory(host, cwd, inventoryPath) {
337
427
  }
338
428
  export function planWorkspace(observation, host, options = {}) {
339
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
+ }
340
433
  const envOwner = observation.envOwner ?? (host.env.CLOSSYS_OWNER?.trim() || undefined);
341
434
  if (envOwner !== undefined &&
342
435
  !cwd.looksLikeFoundry &&
@@ -356,6 +449,8 @@ export function planWorkspace(observation, host, options = {}) {
356
449
  repository: repoNameFromSlug(cwd.hub.repository, DEFAULT_REPOSITORY_NAME),
357
450
  directory: cwd.absolutePath,
358
451
  clone: false,
452
+ ...(observation.advisorVersion === undefined ? {} : { advisorVersion: observation.advisorVersion }),
453
+ ...(cwd.hubMigration === "legacy" ? { migrateFrom: "legacy" } : {}),
359
454
  };
360
455
  }
361
456
  if (cwd.git && cwd.githubOwner && cwd.githubRepository) {
@@ -391,6 +486,7 @@ export function planWorkspace(observation, host, options = {}) {
391
486
  repository: observation.remoteDefaultHub.repository,
392
487
  directory: cwd.absolutePath,
393
488
  clone: true,
489
+ ...(observation.advisorVersion === undefined ? {} : { advisorVersion: observation.advisorVersion }),
394
490
  };
395
491
  }
396
492
  if (!observation.ghAvailable) {
@@ -411,7 +507,7 @@ function containedPath(root, relativePath) {
411
507
  const resolved = resolve(root, relativePath);
412
508
  const prefix = root.endsWith(sep) ? root : `${root}${sep}`;
413
509
  if (resolved !== root && !resolved.startsWith(prefix)) {
414
- throw new Error(`refusing to write outside the hub directory: ${relativePath}`);
510
+ throw new Error(`refusing to write outside the workspace directory: ${relativePath}`);
415
511
  }
416
512
  return resolved;
417
513
  }
@@ -443,15 +539,20 @@ function clossysNames(bucket, extra) {
443
539
  }
444
540
  return advisor;
445
541
  }
446
- /** Leaves an existing Advisor pin in whichever bucket it already occupies. */
447
- 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) {
448
549
  const path = join(directory, "package.json");
449
550
  const raw = host.readText(path);
450
551
  if (raw === null) {
451
552
  const skeleton = host.readText(join(skeletonRoot, "package.json"));
452
553
  if (skeleton === null)
453
554
  throw new Error("missing skeleton package.json");
454
- writeSkeletonFile(host, directory, "package.json", substitute(skeleton, { owner: repository, repository, advisorVersion }));
555
+ writeSkeletonFile(host, directory, "package.json", substitute(skeleton, { owner, repository, advisorVersion }));
455
556
  return;
456
557
  }
457
558
  let manifest;
@@ -464,13 +565,25 @@ function mergeAdvisorPin(host, directory, skeletonRoot, advisorVersion, reposito
464
565
  catch {
465
566
  throw new Error("existing package.json is unreadable JSON");
466
567
  }
467
- const extra = new Set();
468
- const pinnedSomewhere = DEPENDENCY_BUCKETS.some((bucket) => clossysNames(manifest[bucket], extra) !== undefined);
469
- if (pinnedSomewhere)
470
- 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
+ }
471
581
  const devDependencies = isRecord(manifest.devDependencies) ? { ...manifest.devDependencies } : {};
472
582
  devDependencies[ADVISOR_PACKAGE] = advisorVersion;
473
583
  manifest.devDependencies = devDependencies;
584
+ if (repository === DEFAULT_REPOSITORY_NAME) {
585
+ manifest.name = `@${owner}/${repository}`;
586
+ }
474
587
  host.writeText(path, `${JSON.stringify(manifest, null, 2)}\n`);
475
588
  }
476
589
  function copySkeleton(host, skeletonRoot, plan) {
@@ -526,6 +639,9 @@ function adoptHubFiles(host, skeletonRoot, plan) {
526
639
  if (host.readText(join(plan.directory, "AGENTS.md")) === null) {
527
640
  writeSkeletonFile(host, plan.directory, "AGENTS.md", CONSUMER_AGENTS_MD);
528
641
  }
642
+ else {
643
+ writeConsumerAgentsIfNeeded(host, plan.directory);
644
+ }
529
645
  if (host.readText(join(plan.directory, "CLAUDE.md")) === null) {
530
646
  writeSkeletonFile(host, plan.directory, "CLAUDE.md", CONSUMER_CLAUDE_MD);
531
647
  }
@@ -539,7 +655,7 @@ function adoptHubFiles(host, skeletonRoot, plan) {
539
655
  if (ignore !== null)
540
656
  writeSkeletonFile(host, plan.directory, ".gitignore", ignore);
541
657
  }
542
- mergeAdvisorPin(host, plan.directory, skeletonRoot, plan.advisorVersion, plan.repository);
658
+ mergeAdvisorPin(host, plan.directory, skeletonRoot, plan.advisorVersion, plan.owner, plan.repository);
543
659
  }
544
660
  function requireZero(result, label) {
545
661
  if (result.status !== 0) {
@@ -552,7 +668,7 @@ function requireZero(result, label) {
552
668
  * registry version, marking a pin older than live as a stale-pin finding and a
553
669
  * degraded report.
554
670
  */
555
- export function reportHubHealth(host, directory, liveAdvisorVersion) {
671
+ export function reportHubHealth(host, directory, liveAdvisorVersion, liveLauncherVersion, retiredThisRun = [], migration) {
556
672
  const extra = new Set();
557
673
  const pins = {};
558
674
  const manifestRaw = host.readText(join(directory, "package.json"));
@@ -582,6 +698,7 @@ export function reportHubHealth(host, directory, liveAdvisorVersion) {
582
698
  ? [{ bucket: bucket, pinned, grade: "stale", note: `pinned ${pinned} is older than live ${liveAdvisorVersion}` }]
583
699
  : [];
584
700
  });
701
+ const skillsManifest = summarizeSkillsManifest(parseSkillManifest(host.readText(join(directory, SKILLS_MANIFEST_REL))), liveLauncherVersion, retiredThisRun);
585
702
  return {
586
703
  marker: readHub(host, directory) === undefined ? "missing" : "present",
587
704
  inventory: inspectInventory(host.readText(join(directory, WORKSPACE_INVENTORY_REL))),
@@ -595,7 +712,14 @@ export function reportHubHealth(host, directory, liveAdvisorVersion) {
595
712
  dualPin: Object.values(pins).filter((value) => value !== undefined).length > 1,
596
713
  extraClossys: [...extra].sort(),
597
714
  pinFindings,
598
- 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,
599
723
  };
600
724
  }
601
725
  export function formatHubHealth(report) {
@@ -611,6 +735,38 @@ export function formatHubHealth(report) {
611
735
  const inventory = report.inventory.status === "populated" ? `populated (${report.inventory.count})` : report.inventory.status;
612
736
  const findings = report.pinFindings.map((finding) => finding.note !== undefined ? `${finding.bucket} ${finding.note}` : `${finding.bucket} ${finding.grade}`);
613
737
  const findingLine = findings.length === 0 ? "none" : findings.join("; ");
738
+ const skillParts = [];
739
+ if (report.skillComposition !== undefined) {
740
+ skillParts.push(report.skillComposition.composed.length === 0
741
+ ? "skills composed: none"
742
+ : `skills composed: ${report.skillComposition.composed.map((name) => `clossys-${name}`).join(", ")}`);
743
+ for (const skip of report.skillComposition.skipped) {
744
+ skillParts.push(`skill skipped (${skip.packageDir}): ${skip.note}`);
745
+ }
746
+ if (report.skillComposition.rosterTargets !== undefined && report.skillComposition.rosterTargets.length > 0) {
747
+ skillParts.push(`skill roster written: ${report.skillComposition.rosterTargets.join(", ")}`);
748
+ }
749
+ for (const skip of report.skillComposition.rosterSkipped ?? []) {
750
+ skillParts.push(`skill roster skipped (${skip.inventoryId}): ${skip.note}`);
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
+ }
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}`;
614
770
  return [
615
771
  `hub marker: ${report.marker}`,
616
772
  `inventory: ${inventory}`,
@@ -619,9 +775,30 @@ export function formatHubHealth(report) {
619
775
  `extra @clossys/*: ${extra}`,
620
776
  `pin findings: ${findingLine}`,
621
777
  `degraded: ${report.degraded ? "yes" : "no"}`,
778
+ ...(migrationLine === undefined ? [] : [migrationLine]),
779
+ ...(linkedHostsLine === undefined ? [] : [linkedHostsLine]),
780
+ ...(inventoryDriftLine === undefined ? [] : [inventoryDriftLine]),
781
+ ...(skillsManifestLine === undefined ? [] : [skillsManifestLine]),
782
+ ...(skillParts.length === 0 ? [] : skillParts),
622
783
  `health: ${JSON.stringify(report)}`,
623
784
  ].join("\n");
624
785
  }
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 ?? [];
789
+ const health = {
790
+ ...base,
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,
795
+ };
796
+ return {
797
+ state: "satisfied",
798
+ message: `${headline}\n${formatHubHealth(health)}`,
799
+ health,
800
+ };
801
+ }
625
802
  /**
626
803
  * Read-only inventory validation: runs `gh repo view --json name` for each
627
804
  * repository id, batched. Tolerates a missing or failing `gh` by skipping with
@@ -656,29 +833,270 @@ export function checkInventoryEntries(host, directory) {
656
833
  }
657
834
  return { entries, skipped: false };
658
835
  }
659
- function withHealth(host, directory, headline, liveAdvisorVersion) {
660
- const health = reportHubHealth(host, directory, liveAdvisorVersion);
661
- return {
662
- state: "satisfied",
663
- message: `${headline}\n${formatHubHealth(health)}`,
664
- health,
836
+ function shouldRefreshConsumerAgents(existing) {
837
+ if (existing === null)
838
+ return true;
839
+ if (existing === CONSUMER_AGENTS_MD)
840
+ return false;
841
+ if (existing === LEGACY_CONSUMER_AGENTS_MD)
842
+ return true;
843
+ if (existing.includes("Run `npx @clossys/launcher` again to resume"))
844
+ return true;
845
+ return false;
846
+ }
847
+ function writeConsumerAgentsIfNeeded(host, directory) {
848
+ const existing = host.readText(join(directory, "AGENTS.md"));
849
+ if (!shouldRefreshConsumerAgents(existing))
850
+ return;
851
+ writeSkeletonFile(host, directory, "AGENTS.md", CONSUMER_AGENTS_MD);
852
+ }
853
+ function writeSisterConsumerAgentsIfNeeded(host, directory) {
854
+ const existing = host.readText(join(directory, "AGENTS.md"));
855
+ if (existing !== null && existing.trim() !== "" && existing !== SISTER_CONSUMER_AGENTS_MD)
856
+ return;
857
+ writeSkeletonFile(host, directory, "AGENTS.md", SISTER_CONSUMER_AGENTS_MD);
858
+ }
859
+ const CLONE_NOT_BESIDE_HUB_NOTE = "clone not next to the hub; voices appear here after this repository is cloned beside the hub and launcher resumes";
860
+ function parseInventoryRepositoryId(id, hubOwner) {
861
+ const trimmed = id.trim();
862
+ if (trimmed === "")
863
+ return null;
864
+ if (trimmed.includes("/")) {
865
+ const slash = trimmed.indexOf("/");
866
+ const owner = trimmed.slice(0, slash);
867
+ const repository = trimmed.slice(slash + 1);
868
+ if (!OWNER.test(owner) || !REPO.test(repository))
869
+ return null;
870
+ return { owner, repository };
871
+ }
872
+ if (!REPO.test(trimmed))
873
+ return null;
874
+ return { owner: hubOwner, repository: trimmed };
875
+ }
876
+ function resolveSisterCloneTargets(host, hubDirectory, hubOwner) {
877
+ const parent = dirname(resolve(hubDirectory));
878
+ const hubResolved = resolve(hubDirectory);
879
+ const skipped = [];
880
+ const targets = [];
881
+ const inventoryPath = join(hubDirectory, WORKSPACE_INVENTORY_REL);
882
+ let inventoryIds;
883
+ try {
884
+ inventoryIds = readInventoryRepositories(host, inventoryPath, "the hub inventory");
885
+ }
886
+ catch {
887
+ return { targets: [], skipped: [] };
888
+ }
889
+ for (const id of inventoryIds) {
890
+ if (id === "")
891
+ continue;
892
+ const parsed = parseInventoryRepositoryId(id, hubOwner);
893
+ if (parsed === null) {
894
+ skipped.push({ inventoryId: id, note: "inventory id is not a valid repository slug" });
895
+ continue;
896
+ }
897
+ if (parsed.owner !== hubOwner) {
898
+ skipped.push({ inventoryId: id, note: "other account; not this roster" });
899
+ continue;
900
+ }
901
+ const candidate = join(parent, parsed.repository);
902
+ const candidateResolved = resolve(candidate);
903
+ if (candidateResolved === hubResolved)
904
+ continue;
905
+ if (!host.exists(candidate) || !host.isDirectory(candidate)) {
906
+ skipped.push({ inventoryId: id, note: CLONE_NOT_BESIDE_HUB_NOTE });
907
+ continue;
908
+ }
909
+ if (looksLikeFoundry(host, candidate)) {
910
+ skipped.push({ inventoryId: id, note: "foundry supplier tree; skills are not written here" });
911
+ continue;
912
+ }
913
+ const originResult = host.run("git", ["remote", "get-url", "origin"], { cwd: candidate });
914
+ const originUrl = originResult.status === 0 ? originResult.stdout.trim() : "";
915
+ const remote = originUrl === "" ? null : parseGitHubRemote(originUrl);
916
+ if (remote === null || remote.owner !== parsed.owner || remote.repository !== parsed.repository) {
917
+ skipped.push({ inventoryId: id, note: "git origin does not match inventory id" });
918
+ continue;
919
+ }
920
+ targets.push({ inventoryId: id, directory: candidate });
921
+ }
922
+ return { targets, skipped };
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
+ }
963
+ function hubRosterId(host, hubDirectory, hubOwner, hubRepository) {
964
+ const document = readHub(host, hubDirectory);
965
+ if (document !== undefined)
966
+ return document.repository;
967
+ return `${hubOwner}/${hubRepository}`;
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
+ }
1024
+ function composeSkillRoster(host, hubDirectory, hubOwner, hubRepository, options) {
1025
+ const composeOptions = {
1026
+ launcherPackageRoot: options.launcherPackageRoot,
1027
+ ...(options.skillCatalogueRoot === undefined ? {} : { skillCatalogueRoot: options.skillCatalogueRoot }),
1028
+ ...(options.contractPath === undefined ? {} : { contractPath: options.contractPath }),
665
1029
  };
1030
+ const linkedHosts = recordLinkedHosts(host, hubDirectory);
1031
+ const hubSkill = composeSkills(host, hubDirectory, composeOptions);
1032
+ writeConsumerAgentsIfNeeded(host, hubDirectory);
1033
+ writeClossysReadme(host, hubDirectory);
1034
+ const hubId = hubRosterId(host, hubDirectory, hubOwner, hubRepository);
1035
+ const rosterTargets = [hubId];
1036
+ const { targets, skipped } = resolveSisterCloneTargets(host, hubDirectory, hubOwner);
1037
+ for (const target of targets) {
1038
+ recordLinkedHosts(host, target.directory);
1039
+ composeSkills(host, target.directory, composeOptions);
1040
+ writeSisterConsumerAgentsIfNeeded(host, target.directory);
1041
+ rosterTargets.push(target.inventoryId);
1042
+ }
1043
+ return { ...hubSkill, rosterTargets, rosterSkipped: skipped, linkedHosts };
1044
+ }
1045
+ function finishHubApply(host, directory, headline, launcherPackageRoot, hubOwner, hubRepository, liveAdvisorVersion, skillCatalogueRoot, contractPath, liveLauncherVersion, migration) {
1046
+ const skillComposition = composeSkillRoster(host, directory, hubOwner, hubRepository, {
1047
+ launcherPackageRoot,
1048
+ ...(skillCatalogueRoot === undefined ? {} : { skillCatalogueRoot }),
1049
+ ...(contractPath === undefined ? {} : { contractPath }),
1050
+ });
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);
666
1056
  }
667
- /** Applies a create, resume, or adopt plan through the host. Resume does not write. */
668
- export function applyWorkspacePlan(host, plan, skeletonRoot) {
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 };
1076
+ }
1077
+ /** Applies a create, resume, or adopt plan through the host. Resume refreshes composed skills and stale AGENTS.md guidance. */
1078
+ export function applyWorkspacePlan(host, plan, skeletonRoot, options = {}) {
1079
+ const launcherPackageRoot = options.launcherPackageRoot ?? resolve(skeletonRoot, "..");
1080
+ const skillCatalogueRoot = options.skillCatalogueRoot;
1081
+ const contractPath = options.contractPath;
1082
+ const liveLauncherVersion = options.liveLauncherVersion;
669
1083
  if (plan.action === "resume") {
670
1084
  if (plan.clone) {
671
1085
  requireZero(host.run("gh", ["repo", "clone", `${plan.owner}/${plan.repository}`, plan.directory]), "gh repo clone");
672
1086
  }
673
- return withHealth(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.`);
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);
674
1089
  }
675
1090
  if (plan.action === "create") {
676
1091
  copySkeleton(host, skeletonRoot, plan);
677
1092
  requireZero(host.run("gh", ["repo", "create", `${plan.owner}/${plan.repository}`, "--private", "--source", plan.directory, "--remote", "origin", "--push"], { cwd: plan.directory }), "gh repo create");
678
- return withHealth(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.`, plan.advisorVersion);
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);
679
1094
  }
680
1095
  adoptHubFiles(host, skeletonRoot, plan);
681
- return withHealth(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.`, plan.advisorVersion);
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);
1097
+ }
1098
+ export function launcherPackageRootFromModule(moduleUrl) {
1099
+ return resolve(dirname(fileURLToPath(moduleUrl)), "..");
682
1100
  }
683
1101
  export function skeletonRootFromModule(moduleUrl) {
684
1102
  return resolve(dirname(fileURLToPath(moduleUrl)), "..", "skeleton");