@clossys/launcher 0.1.1 → 0.1.5

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 (55) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +49 -9
  3. package/dist/check-cli.d.ts.map +1 -1
  4. package/dist/check-cli.js +9 -1
  5. package/dist/check-cli.js.map +1 -1
  6. package/dist/cli.d.ts +1 -1
  7. package/dist/cli.d.ts.map +1 -1
  8. package/dist/cli.js +14 -6
  9. package/dist/cli.js.map +1 -1
  10. package/dist/core.d.ts +28 -5
  11. package/dist/core.d.ts.map +1 -1
  12. package/dist/core.js +389 -32
  13. package/dist/core.js.map +1 -1
  14. package/dist/host.d.ts.map +1 -1
  15. package/dist/host.js +16 -1
  16. package/dist/host.js.map +1 -1
  17. package/dist/index.d.ts +2 -2
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +1 -1
  20. package/dist/index.js.map +1 -1
  21. package/dist/skills.d.ts +20 -0
  22. package/dist/skills.d.ts.map +1 -0
  23. package/dist/skills.js +106 -0
  24. package/dist/skills.js.map +1 -0
  25. package/dist/types.d.ts +50 -0
  26. package/dist/types.d.ts.map +1 -1
  27. package/package.json +3 -2
  28. package/skeleton/README.md +11 -21
  29. package/skill-catalogue/advisor/SKILL.md +50 -0
  30. package/skill-catalogue/architect/SKILL.md +42 -0
  31. package/skill-catalogue/bouncer/SKILL.md +43 -0
  32. package/skill-catalogue/builder/SKILL.md +42 -0
  33. package/skill-catalogue/butler/SKILL.md +43 -0
  34. package/skill-catalogue/controller/SKILL.md +42 -0
  35. package/skill-catalogue/designer/SKILL.md +50 -0
  36. package/skill-catalogue/giver/SKILL.md +43 -0
  37. package/skill-catalogue/influencer/SKILL.md +43 -0
  38. package/skill-catalogue/inspector/SKILL.md +42 -0
  39. package/skill-catalogue/integrator/SKILL.md +42 -0
  40. package/skill-catalogue/keeper/SKILL.md +43 -0
  41. package/skill-catalogue/launcher/SKILL.md +53 -0
  42. package/skill-catalogue/locksmith/SKILL.md +42 -0
  43. package/skill-catalogue/messenger/SKILL.md +43 -0
  44. package/skill-catalogue/observer/SKILL.md +42 -0
  45. package/skill-catalogue/publisher/SKILL.md +50 -0
  46. package/skill-catalogue/starter/SKILL.md +48 -0
  47. package/skill-catalogue/strategist/SKILL.md +51 -0
  48. package/skill-catalogue/writer/SKILL.md +51 -0
  49. package/src/check-cli.ts +9 -1
  50. package/src/cli.ts +13 -6
  51. package/src/core.ts +442 -28
  52. package/src/host.ts +14 -1
  53. package/src/index.ts +10 -0
  54. package/src/skills.ts +120 -0
  55. package/src/types.ts +50 -0
package/src/core.ts CHANGED
@@ -1,11 +1,16 @@
1
1
  import { dirname, join, resolve, sep } from "node:path";
2
2
  import { fileURLToPath } from "node:url";
3
3
  import type {
4
+ ApplyWorkspaceOptions,
4
5
  CommandResult,
5
6
  CwdObservation,
7
+ DependencyBucket,
6
8
  HubDocument,
7
9
  HubHealthReport,
8
10
  InventoryObservation,
11
+ InventoryValidationEntry,
12
+ InventoryValidationReport,
13
+ PinFinding,
9
14
  WorkspaceApplyResult,
10
15
  WorkspaceDecision,
11
16
  WorkspaceHost,
@@ -14,12 +19,20 @@ import type {
14
19
  WorkspacePlanCreate,
15
20
  WorkspaceRefusal,
16
21
  } from "./types.js";
22
+ import { composeSkills, type SkillCompositionResult } from "./skills.js";
17
23
 
18
24
  export const DEFAULT_REPOSITORY_NAME = "workspace";
19
25
  export const WORKSPACE_MARKER_REL = ".clossys/workspace.json";
20
26
  export const WORKSPACE_INVENTORY_REL = ".clossys/inventory.json";
21
27
  export const ADVISOR_PACKAGE = "@clossys/advisor";
22
28
 
29
+ const DEPENDENCY_BUCKETS: readonly DependencyBucket[] = [
30
+ "dependencies",
31
+ "devDependencies",
32
+ "optionalDependencies",
33
+ "peerDependencies",
34
+ ];
35
+
23
36
  const OWNER = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/;
24
37
  const REPO = /^[A-Za-z0-9._-]+$/;
25
38
  const SKELETON_FILES = [
@@ -33,6 +46,31 @@ const SKELETON_FILES = [
33
46
  /** Written at generate time so this package never ships a nested AGENTS.md. */
34
47
  export const CONSUMER_AGENTS_MD = `# Account workspace
35
48
 
49
+ This folder is the account hub for Foundry packages.
50
+
51
+ After \`npx @clossys/launcher\`, the same \`@clossys-*\` team is composed in
52
+ every inventoried checkout beside this hub. Talk with \`@clossys-advisor\` and
53
+ \`@clossys-<package>\` here or in a product repository. A missing \`@\` mention
54
+ is not how we signal incompatibility — \`@clossys-advisor\` is the hiring check.
55
+
56
+ Run \`npx @clossys/launcher\` again for hub health and to refresh voices on
57
+ clones next to the hub, not as how you talk to packages.
58
+
59
+ Advisor is read-only until the sponsor approves a next action.
60
+ `;
61
+
62
+ /** Canned guidance for inventoried product checkouts (not the hub). */
63
+ export const SISTER_CONSUMER_AGENTS_MD = `# Product repository
64
+
65
+ This repository is part of the same account engagement. The same
66
+ \`@clossys-<package>\` team is here for intro and questions;
67
+ \`@clossys-advisor\` decides hiring and compatibility. This folder is not the
68
+ hub — engines are hired per repository, not dumped here.
69
+ `;
70
+
71
+ /** Previous generate-time guidance; used to refresh stale hub AGENTS.md on resume. */
72
+ export const LEGACY_CONSUMER_AGENTS_MD = `# Account workspace
73
+
36
74
  This repository is the account hub for Foundry packages. It inventories
37
75
  where packages are installed and coordinates engagement. It is not a
38
76
  product application and does not need the whole catalogue installed here.
@@ -89,6 +127,34 @@ export function parseGitHubRemote(url: string): { owner: string; repository: str
89
127
  }
90
128
  }
91
129
 
130
+ function readJson(host: WorkspaceHost, path: string): unknown {
131
+ const raw = host.readText(path);
132
+ if (raw === null) return undefined;
133
+ try {
134
+ return JSON.parse(raw) as unknown;
135
+ } catch {
136
+ return undefined;
137
+ }
138
+ }
139
+
140
+ /** Tiny semver-ish compare of `a` versus `b`: -1 older, 0 equal, 1 newer. Unparseable versions are indeterminate (null). */
141
+ function compareVersions(a: string, b: string): -1 | 0 | 1 | null {
142
+ const parse = (version: string): readonly number[] | null => {
143
+ const match = version.trim().match(/^v?(\d+)\.(\d+)\.(\d+)$/);
144
+ return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null;
145
+ };
146
+ const left = parse(a);
147
+ const right = parse(b);
148
+ if (!left || !right) return null;
149
+ for (let index = 0; index < 3; index += 1) {
150
+ const l = left[index] ?? 0;
151
+ const r = right[index] ?? 0;
152
+ if (l < r) return -1;
153
+ if (l > r) return 1;
154
+ }
155
+ return 0;
156
+ }
157
+
92
158
  /** True when the marker is a v1 account-hub document. */
93
159
  export function isHubDocument(value: unknown): value is HubDocument {
94
160
  if (!isRecord(value) || value.schemaVersion !== 1 || value.kind !== "account-hub") return false;
@@ -159,6 +225,12 @@ function commandAvailable(host: WorkspaceHost, command: string): boolean {
159
225
  return result.status === 0;
160
226
  }
161
227
 
228
+ /** True when the tree already pins Advisor in some bucket, so adopt would not need a new version. */
229
+ export function hasAdvisorPin(manifest: unknown): boolean {
230
+ if (!isRecord(manifest)) return false;
231
+ return DEPENDENCY_BUCKETS.some((bucket) => clossysNames(manifest[bucket], new Set()) !== undefined);
232
+ }
233
+
162
234
  /** Collects GitHub owner, cwd shape, default-hub presence, and the public Advisor pin. */
163
235
  export function observeWorkspace(host: WorkspaceHost): WorkspaceObservation {
164
236
  const cwd = host.cwd;
@@ -196,9 +268,14 @@ export function observeWorkspace(host: WorkspaceHost): WorkspaceObservation {
196
268
  const viewed = host.run("gh", ["repo", "view", `${ownerGuess}/${DEFAULT_REPOSITORY_NAME}`, "--json", "name"]);
197
269
  if (viewed.status === 0) remoteDefaultHub = { owner: ownerGuess, repository: DEFAULT_REPOSITORY_NAME };
198
270
  }
199
- const viewedAdvisor = host.run("npm", ["view", ADVISOR_PACKAGE, "version"]);
200
- const version = viewedAdvisor.stdout.trim();
201
- if (viewedAdvisor.status === 0 && /^\d+\.\d+\.\d+$/.test(version)) advisorVersion = version;
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
+ }
202
279
 
203
280
  const cwdObservation: CwdObservation = {
204
281
  absolutePath: cwd,
@@ -245,19 +322,43 @@ function resolveOwner(observation: WorkspaceObservation, host: WorkspaceHost): {
245
322
  * Decides create, resume, or adopt from a cwd observation.
246
323
  * Appointing means: run this from the GitHub repository that should own the hub.
247
324
  */
325
+ export function readInventoryRepositories(host: WorkspaceHost, source: string, label: string): readonly string[] {
326
+ const raw = host.readText(source);
327
+ if (raw === null) return [];
328
+ try {
329
+ const parsed: unknown = JSON.parse(raw);
330
+ if (!isRecord(parsed) || !Array.isArray(parsed.repositories)) return [];
331
+ return parsed.repositories.map((entry) => {
332
+ if (isRecord(entry) && isText(entry.id)) return entry.id.trim();
333
+ return "";
334
+ });
335
+ } catch {
336
+ throw new Error(`${label} is not readable inventory JSON (schemaVersion 1, repositories array)`);
337
+ }
338
+ }
339
+
340
+ /**
341
+ * Resolves which repositories the adopt plan inventories. When the on-disk hub
342
+ * inventory is populated AND --inventory is supplied, the two are merged by id:
343
+ * on-disk entries keep their order, ids not already on disk are appended in
344
+ * supplied-file order, and the first occurrence of an id wins; the plan carries
345
+ * the merged ids for applyWorkspacePlan to write. When both are empty or
346
+ * missing, adopt refuses rather than appointing an inventory-less hub.
347
+ */
248
348
  function resolveAdoptInventory(
249
349
  host: WorkspaceHost,
250
350
  cwd: CwdObservation,
251
351
  inventoryPath: string | undefined,
252
- ): { inventorySource?: string } | WorkspaceRefusal {
253
- if (cwd.inventory?.status === "populated") return {};
352
+ ): { inventorySource?: string; mergedInventoryIds?: readonly string[] } | WorkspaceRefusal {
254
353
  const trimmed = inventoryPath?.trim();
255
- if (!trimmed) {
354
+ const onDiskPopulated = cwd.inventory?.status === "populated";
355
+ if (!onDiskPopulated && !trimmed) {
256
356
  return refuse(
257
357
  "violated",
258
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",
259
359
  );
260
360
  }
361
+ if (!trimmed) return {};
261
362
  const resolved = resolve(cwd.absolutePath, trimmed);
262
363
  const imported = inspectInventory(host.readText(resolved));
263
364
  if (imported.status !== "populated") {
@@ -266,7 +367,22 @@ function resolveAdoptInventory(
266
367
  "--inventory must point at a populated inventory document (schemaVersion 1, nonempty repositories)",
267
368
  );
268
369
  }
269
- return { inventorySource: resolved };
370
+ if (!onDiskPopulated) return { inventorySource: resolved };
371
+ const merged: string[] = [];
372
+ const seen = new Set<string>();
373
+ for (const id of readInventoryRepositories(host, join(cwd.absolutePath, WORKSPACE_INVENTORY_REL), "the on-disk hub inventory")) {
374
+ if (id !== "" && !seen.has(id)) {
375
+ seen.add(id);
376
+ merged.push(id);
377
+ }
378
+ }
379
+ for (const id of readInventoryRepositories(host, resolved, "--inventory")) {
380
+ if (id !== "" && !seen.has(id)) {
381
+ seen.add(id);
382
+ merged.push(id);
383
+ }
384
+ }
385
+ return { mergedInventoryIds: merged };
270
386
  }
271
387
 
272
388
  export function planWorkspace(
@@ -275,6 +391,20 @@ export function planWorkspace(
275
391
  options: { inventoryPath?: string } = {},
276
392
  ): WorkspaceDecision {
277
393
  const { cwd } = observation;
394
+ const envOwner = observation.envOwner ?? (host.env.CLOSSYS_OWNER?.trim() || undefined);
395
+ if (
396
+ envOwner !== undefined &&
397
+ !cwd.looksLikeFoundry &&
398
+ cwd.hub === undefined &&
399
+ cwd.git &&
400
+ cwd.githubOwner !== undefined &&
401
+ cwd.githubOwner !== envOwner
402
+ ) {
403
+ return refuse(
404
+ "violated",
405
+ `CLOSSYS_OWNER is "${envOwner}" but the github.com origin here is owned by "${cwd.githubOwner}"; refusing to appoint a marker for the wrong account`,
406
+ );
407
+ }
278
408
  if (cwd.looksLikeFoundry) {
279
409
  return refuse(
280
410
  "violated",
@@ -303,6 +433,7 @@ export function planWorkspace(
303
433
  directory: cwd.absolutePath,
304
434
  advisorVersion: observation.advisorVersion,
305
435
  ...(imported.inventorySource === undefined ? {} : { inventorySource: imported.inventorySource }),
436
+ ...(imported.mergedInventoryIds === undefined ? {} : { mergedInventoryIds: imported.mergedInventoryIds }),
306
437
  };
307
438
  }
308
439
  if (cwd.git) {
@@ -344,7 +475,7 @@ function containedPath(root: string, relativePath: string): string {
344
475
  const resolved = resolve(root, relativePath);
345
476
  const prefix = root.endsWith(sep) ? root : `${root}${sep}`;
346
477
  if (resolved !== root && !resolved.startsWith(prefix)) {
347
- throw new Error(`refusing to write outside the hub directory: ${relativePath}`);
478
+ throw new Error(`refusing to write outside the workspace directory: ${relativePath}`);
348
479
  }
349
480
  return resolved;
350
481
  }
@@ -404,9 +535,8 @@ function mergeAdvisorPin(
404
535
  throw new Error("existing package.json is unreadable JSON");
405
536
  }
406
537
  const extra = new Set<string>();
407
- const inDependencies = clossysNames(manifest.dependencies, extra);
408
- const inDevDependencies = clossysNames(manifest.devDependencies, extra);
409
- if (inDependencies !== undefined || inDevDependencies !== undefined) return;
538
+ const pinnedSomewhere = DEPENDENCY_BUCKETS.some((bucket) => clossysNames(manifest[bucket], extra) !== undefined);
539
+ if (pinnedSomewhere) return;
410
540
  const devDependencies = isRecord(manifest.devDependencies) ? { ...manifest.devDependencies } : {};
411
541
  devDependencies[ADVISOR_PACKAGE] = advisorVersion;
412
542
  manifest.devDependencies = devDependencies;
@@ -423,7 +553,30 @@ function copySkeleton(host: WorkspaceHost, skeletonRoot: string, plan: Workspace
423
553
  writeSkeletonFile(host, plan.directory, "CLAUDE.md", CONSUMER_CLAUDE_MD);
424
554
  }
425
555
 
556
+ /** Refuses adopt when the working tree has uncommitted changes; names a non-github.com remote host. */
557
+ function assertCleanTree(host: WorkspaceHost, directory: string): void {
558
+ const status = host.run("git", ["status", "--porcelain"], { cwd: directory });
559
+ if (status.status === 0 && status.stdout.trim() !== "") {
560
+ const origin = host.run("git", ["remote", "get-url", "origin"], { cwd: directory }).stdout.trim();
561
+ let where = "this checkout";
562
+ if (origin !== "") {
563
+ try {
564
+ const parsed = new URL(origin);
565
+ if (parsed.protocol === "ssh:" || parsed.protocol === "https:" || parsed.protocol === "http:") {
566
+ where = `the repository on ${parsed.hostname}`;
567
+ }
568
+ } catch {
569
+ /* ssh form or unparsable; keep the generic host */
570
+ }
571
+ }
572
+ throw new Error(
573
+ `${where} has uncommitted changes (git status --porcelain is nonempty); commit or stash them before appointing it as the account hub`,
574
+ );
575
+ }
576
+ }
577
+
426
578
  function adoptHubFiles(host: WorkspaceHost, skeletonRoot: string, plan: WorkspacePlan & { advisorVersion: string }): void {
579
+ assertCleanTree(host, plan.directory);
427
580
  const marker = {
428
581
  schemaVersion: 1,
429
582
  kind: "account-hub",
@@ -431,7 +584,15 @@ function adoptHubFiles(host: WorkspaceHost, skeletonRoot: string, plan: Workspac
431
584
  repository: `${plan.owner}/${plan.repository}`,
432
585
  };
433
586
  writeSkeletonFile(host, plan.directory, WORKSPACE_MARKER_REL, `${JSON.stringify(marker, null, 2)}\n`);
434
- if ("inventorySource" in plan && typeof plan.inventorySource === "string") {
587
+ if ("mergedInventoryIds" in plan && Array.isArray(plan.mergedInventoryIds)) {
588
+ const document = { schemaVersion: 1, repositories: plan.mergedInventoryIds.map((id) => ({ id })) };
589
+ writeSkeletonFile(
590
+ host,
591
+ plan.directory,
592
+ WORKSPACE_INVENTORY_REL,
593
+ `${JSON.stringify(document, null, 2)}\n`,
594
+ );
595
+ } else if ("inventorySource" in plan && typeof plan.inventorySource === "string") {
435
596
  const raw = host.readText(plan.inventorySource);
436
597
  if (raw === null || inspectInventory(raw).status !== "populated") {
437
598
  throw new Error("inventory source is not a populated inventory document");
@@ -440,6 +601,8 @@ function adoptHubFiles(host: WorkspaceHost, skeletonRoot: string, plan: Workspac
440
601
  }
441
602
  if (host.readText(join(plan.directory, "AGENTS.md")) === null) {
442
603
  writeSkeletonFile(host, plan.directory, "AGENTS.md", CONSUMER_AGENTS_MD);
604
+ } else {
605
+ writeConsumerAgentsIfNeeded(host, plan.directory);
443
606
  }
444
607
  if (host.readText(join(plan.directory, "CLAUDE.md")) === null) {
445
608
  writeSkeletonFile(host, plan.directory, "CLAUDE.md", CONSUMER_CLAUDE_MD);
@@ -461,51 +624,97 @@ function requireZero(result: CommandResult, label: string): void {
461
624
  }
462
625
  }
463
626
 
464
- /** Read-only pin and inventory report. Does not install or uninstall. */
627
+ /**
628
+ * Read-only pin and inventory report. Does not install or uninstall. Scans all
629
+ * four dependency buckets; grades each pinned advisor version against the live
630
+ * registry version, marking a pin older than live as a stale-pin finding and a
631
+ * degraded report.
632
+ */
465
633
  export function reportHubHealth(host: WorkspaceHost, directory: string, liveAdvisorVersion?: string): HubHealthReport {
466
634
  const extra = new Set<string>();
467
- let dependencies: string | undefined;
468
- let devDependencies: string | undefined;
635
+ const pins: Partial<Record<DependencyBucket, string>> = {};
469
636
  const manifestRaw = host.readText(join(directory, "package.json"));
470
637
  if (manifestRaw !== null) {
471
638
  try {
472
639
  const parsed: unknown = JSON.parse(manifestRaw);
473
640
  if (isRecord(parsed)) {
474
- dependencies = clossysNames(parsed.dependencies, extra);
475
- devDependencies = clossysNames(parsed.devDependencies, extra);
641
+ for (const bucket of DEPENDENCY_BUCKETS) {
642
+ const pin = clossysNames(parsed[bucket], extra);
643
+ if (pin !== undefined) pins[bucket] = pin;
644
+ }
476
645
  }
477
646
  } catch {
478
647
  /* unreadable manifest is reported as missing pins */
479
648
  }
480
649
  }
650
+ const pinFindings: PinFinding[] = Object.entries(pins).flatMap(([bucket, pinned]): PinFinding[] => {
651
+ if (liveAdvisorVersion === undefined) return [];
652
+ const comparison = compareVersions(pinned, liveAdvisorVersion);
653
+ if (comparison === null) {
654
+ return [{ bucket: bucket as DependencyBucket, pinned, grade: "indeterminate", note: `cannot compare ${pinned} with live ${liveAdvisorVersion}` }];
655
+ }
656
+ return comparison < 0
657
+ ? [{ bucket: bucket as DependencyBucket, pinned, grade: "stale", note: `pinned ${pinned} is older than live ${liveAdvisorVersion}` }]
658
+ : [];
659
+ });
481
660
  return {
482
661
  marker: readHub(host, directory) === undefined ? "missing" : "present",
483
662
  inventory: inspectInventory(host.readText(join(directory, WORKSPACE_INVENTORY_REL))),
484
663
  advisorPin: {
485
- ...(dependencies === undefined ? {} : { dependencies }),
486
- ...(devDependencies === undefined ? {} : { devDependencies }),
664
+ ...(pins.dependencies === undefined ? {} : { dependencies: pins.dependencies }),
665
+ ...(pins.devDependencies === undefined ? {} : { devDependencies: pins.devDependencies }),
666
+ ...(pins.optionalDependencies === undefined ? {} : { optionalDependencies: pins.optionalDependencies }),
667
+ ...(pins.peerDependencies === undefined ? {} : { peerDependencies: pins.peerDependencies }),
487
668
  ...(liveAdvisorVersion === undefined ? {} : { live: liveAdvisorVersion }),
488
669
  },
489
- dualPin: dependencies !== undefined && devDependencies !== undefined,
670
+ dualPin: Object.values(pins).filter((value) => value !== undefined).length > 1,
490
671
  extraClossys: [...extra].sort(),
672
+ pinFindings,
673
+ degraded: pinFindings.some((finding) => finding.grade === "stale"),
491
674
  };
492
675
  }
493
676
 
494
677
  export function formatHubHealth(report: HubHealthReport): string {
495
678
  const pinParts: string[] = [];
496
- if (report.advisorPin.dependencies !== undefined) pinParts.push(`dependencies ${report.advisorPin.dependencies}`);
497
- if (report.advisorPin.devDependencies !== undefined) pinParts.push(`devDependencies ${report.advisorPin.devDependencies}`);
679
+ for (const bucket of DEPENDENCY_BUCKETS) {
680
+ const pinned = report.advisorPin[bucket];
681
+ if (pinned !== undefined) pinParts.push(`${bucket} ${pinned}`);
682
+ }
498
683
  const pin = pinParts.length === 0 ? "missing" : pinParts.join(" and ");
499
684
  const live = report.advisorPin.live === undefined ? "" : `; live ${report.advisorPin.live}`;
500
685
  const extra = report.extraClossys.length === 0 ? "none" : report.extraClossys.join(", ");
501
686
  const inventory =
502
687
  report.inventory.status === "populated" ? `populated (${report.inventory.count})` : report.inventory.status;
688
+ const findings = report.pinFindings.map((finding) =>
689
+ finding.note !== undefined ? `${finding.bucket} ${finding.note}` : `${finding.bucket} ${finding.grade}`,
690
+ );
691
+ const findingLine = findings.length === 0 ? "none" : findings.join("; ");
692
+ const skillParts: string[] = [];
693
+ if (report.skillComposition !== undefined) {
694
+ skillParts.push(
695
+ report.skillComposition.composed.length === 0
696
+ ? "skills composed: none"
697
+ : `skills composed: ${report.skillComposition.composed.map((name) => `clossys-${name}`).join(", ")}`,
698
+ );
699
+ for (const skip of report.skillComposition.skipped) {
700
+ skillParts.push(`skill skipped (${skip.packageDir}): ${skip.note}`);
701
+ }
702
+ if (report.skillComposition.rosterTargets !== undefined && report.skillComposition.rosterTargets.length > 0) {
703
+ skillParts.push(`skill roster written: ${report.skillComposition.rosterTargets.join(", ")}`);
704
+ }
705
+ for (const skip of report.skillComposition.rosterSkipped ?? []) {
706
+ skillParts.push(`skill roster skipped (${skip.inventoryId}): ${skip.note}`);
707
+ }
708
+ }
503
709
  return [
504
710
  `hub marker: ${report.marker}`,
505
711
  `inventory: ${inventory}`,
506
712
  `advisor pin: ${pin}${live}`,
507
713
  `dual pin: ${report.dualPin ? "yes" : "no"}`,
508
714
  `extra @clossys/*: ${extra}`,
715
+ `pin findings: ${findingLine}`,
716
+ `degraded: ${report.degraded ? "yes" : "no"}`,
717
+ ...(skillParts.length === 0 ? [] : skillParts),
509
718
  `health: ${JSON.stringify(report)}`,
510
719
  ].join("\n");
511
720
  }
@@ -515,8 +724,12 @@ function withHealth(
515
724
  directory: string,
516
725
  headline: string,
517
726
  liveAdvisorVersion?: string,
727
+ skillComposition?: SkillCompositionResult,
518
728
  ): WorkspaceApplyResult {
519
- const health = reportHubHealth(host, directory, liveAdvisorVersion);
729
+ const health: HubHealthReport = {
730
+ ...reportHubHealth(host, directory, liveAdvisorVersion),
731
+ ...(skillComposition === undefined ? {} : { skillComposition }),
732
+ };
520
733
  return {
521
734
  state: "satisfied",
522
735
  message: `${headline}\n${formatHubHealth(health)}`,
@@ -524,8 +737,192 @@ function withHealth(
524
737
  };
525
738
  }
526
739
 
527
- /** Applies a create, resume, or adopt plan through the host. Resume does not write. */
528
- export function applyWorkspacePlan(host: WorkspaceHost, plan: WorkspacePlan, skeletonRoot: string): WorkspaceApplyResult {
740
+ /**
741
+ * Read-only inventory validation: runs `gh repo view --json name` for each
742
+ * repository id, batched. Tolerates a missing or failing `gh` by skipping with
743
+ * a note; marks ids whose check fails as unknown. Never mutates the inventory.
744
+ */
745
+ export function checkInventoryEntries(host: WorkspaceHost, directory: string): InventoryValidationReport {
746
+ const raw = host.readText(join(directory, WORKSPACE_INVENTORY_REL));
747
+ const observation = inspectInventory(raw);
748
+ if (observation.status !== "populated") {
749
+ return { entries: [], skipped: true, note: `inventory is ${observation.status}; nothing to validate` };
750
+ }
751
+ const available = commandAvailable(host, "gh");
752
+ if (!available) {
753
+ return { entries: [], skipped: true, note: "`gh` is unavailable; inventory ids were not validated" };
754
+ }
755
+ const ids = readInventoryRepositories(host, join(directory, WORKSPACE_INVENTORY_REL), "the hub inventory").filter(
756
+ (id) => id !== "",
757
+ );
758
+ const batch = 20;
759
+ const entries: InventoryValidationEntry[] = [];
760
+ for (let index = 0; index < ids.length; index += batch) {
761
+ for (const id of ids.slice(index, index + batch)) {
762
+ const viewed = host.run("gh", ["repo", "view", id, "--json", "name"]);
763
+ if (viewed.status === 0) {
764
+ entries.push({ id, known: true });
765
+ } else if (viewed.status === 127 || (viewed.status === null && viewed.stderr.includes("ENOENT"))) {
766
+ entries.push({ id, known: null, note: "`gh` unavailable; not checked" });
767
+ } else {
768
+ entries.push({ id, known: false, note: "`gh repo view` failed; the repository may not exist" });
769
+ }
770
+ }
771
+ }
772
+ return { entries, skipped: false };
773
+ }
774
+
775
+ function shouldRefreshConsumerAgents(existing: string | null): boolean {
776
+ if (existing === null) return true;
777
+ if (existing === CONSUMER_AGENTS_MD) return false;
778
+ if (existing === LEGACY_CONSUMER_AGENTS_MD) return true;
779
+ if (existing.includes("Run `npx @clossys/launcher` again to resume")) return true;
780
+ return false;
781
+ }
782
+
783
+ function writeConsumerAgentsIfNeeded(host: WorkspaceHost, directory: string): void {
784
+ const existing = host.readText(join(directory, "AGENTS.md"));
785
+ if (!shouldRefreshConsumerAgents(existing)) return;
786
+ writeSkeletonFile(host, directory, "AGENTS.md", CONSUMER_AGENTS_MD);
787
+ }
788
+
789
+ function writeSisterConsumerAgentsIfNeeded(host: WorkspaceHost, directory: string): void {
790
+ const existing = host.readText(join(directory, "AGENTS.md"));
791
+ if (existing !== null && existing.trim() !== "" && existing !== SISTER_CONSUMER_AGENTS_MD) return;
792
+ writeSkeletonFile(host, directory, "AGENTS.md", SISTER_CONSUMER_AGENTS_MD);
793
+ }
794
+
795
+ const CLONE_NOT_BESIDE_HUB_NOTE =
796
+ "clone not next to the hub; voices appear here after this repository is cloned beside the hub and launcher resumes";
797
+
798
+ function parseInventoryRepositoryId(id: string, hubOwner: string): { owner: string; repository: string } | null {
799
+ const trimmed = id.trim();
800
+ if (trimmed === "") return null;
801
+ if (trimmed.includes("/")) {
802
+ const slash = trimmed.indexOf("/");
803
+ const owner = trimmed.slice(0, slash);
804
+ const repository = trimmed.slice(slash + 1);
805
+ if (!OWNER.test(owner) || !REPO.test(repository)) return null;
806
+ return { owner, repository };
807
+ }
808
+ if (!REPO.test(trimmed)) return null;
809
+ return { owner: hubOwner, repository: trimmed };
810
+ }
811
+
812
+ function resolveSisterCloneTargets(
813
+ host: WorkspaceHost,
814
+ hubDirectory: string,
815
+ hubOwner: string,
816
+ ): {
817
+ readonly targets: readonly { readonly inventoryId: string; readonly directory: string }[];
818
+ readonly skipped: readonly { readonly inventoryId: string; readonly note: string }[];
819
+ } {
820
+ const parent = dirname(resolve(hubDirectory));
821
+ const hubResolved = resolve(hubDirectory);
822
+ const skipped: { inventoryId: string; note: string }[] = [];
823
+ const targets: { inventoryId: string; directory: string }[] = [];
824
+ const inventoryPath = join(hubDirectory, WORKSPACE_INVENTORY_REL);
825
+ let inventoryIds: readonly string[];
826
+ try {
827
+ inventoryIds = readInventoryRepositories(host, inventoryPath, "the hub inventory");
828
+ } catch {
829
+ return { targets: [], skipped: [] };
830
+ }
831
+ for (const id of inventoryIds) {
832
+ if (id === "") continue;
833
+ const parsed = parseInventoryRepositoryId(id, hubOwner);
834
+ if (parsed === null) {
835
+ skipped.push({ inventoryId: id, note: "inventory id is not a valid repository slug" });
836
+ continue;
837
+ }
838
+ if (parsed.owner !== hubOwner) {
839
+ skipped.push({ inventoryId: id, note: "other account; not this roster" });
840
+ continue;
841
+ }
842
+ const candidate = join(parent, parsed.repository);
843
+ const candidateResolved = resolve(candidate);
844
+ if (candidateResolved === hubResolved) continue;
845
+ if (!host.exists(candidate) || !host.isDirectory(candidate)) {
846
+ skipped.push({ inventoryId: id, note: CLONE_NOT_BESIDE_HUB_NOTE });
847
+ continue;
848
+ }
849
+ if (looksLikeFoundry(host, candidate)) {
850
+ skipped.push({ inventoryId: id, note: "foundry supplier tree; skills are not written here" });
851
+ continue;
852
+ }
853
+ const originResult = host.run("git", ["remote", "get-url", "origin"], { cwd: candidate });
854
+ const originUrl = originResult.status === 0 ? originResult.stdout.trim() : "";
855
+ const remote = originUrl === "" ? null : parseGitHubRemote(originUrl);
856
+ if (remote === null || remote.owner !== parsed.owner || remote.repository !== parsed.repository) {
857
+ skipped.push({ inventoryId: id, note: "git origin does not match inventory id" });
858
+ continue;
859
+ }
860
+ targets.push({ inventoryId: id, directory: candidate });
861
+ }
862
+ return { targets, skipped };
863
+ }
864
+
865
+ function hubRosterId(host: WorkspaceHost, hubDirectory: string, hubOwner: string, hubRepository: string): string {
866
+ const document = readHub(host, hubDirectory);
867
+ if (document !== undefined) return document.repository;
868
+ return `${hubOwner}/${hubRepository}`;
869
+ }
870
+
871
+ function composeSkillRoster(
872
+ host: WorkspaceHost,
873
+ hubDirectory: string,
874
+ hubOwner: string,
875
+ hubRepository: string,
876
+ options: { launcherPackageRoot: string; skillCatalogueRoot?: string },
877
+ ): SkillCompositionResult & {
878
+ readonly rosterTargets: readonly string[];
879
+ readonly rosterSkipped: readonly { readonly inventoryId: string; readonly note: string }[];
880
+ } {
881
+ const hubSkill = composeSkills(host, hubDirectory, {
882
+ launcherPackageRoot: options.launcherPackageRoot,
883
+ ...(options.skillCatalogueRoot === undefined ? {} : { skillCatalogueRoot: options.skillCatalogueRoot }),
884
+ });
885
+ writeConsumerAgentsIfNeeded(host, hubDirectory);
886
+ const hubId = hubRosterId(host, hubDirectory, hubOwner, hubRepository);
887
+ const rosterTargets: string[] = [hubId];
888
+ const { targets, skipped } = resolveSisterCloneTargets(host, hubDirectory, hubOwner);
889
+ for (const target of targets) {
890
+ composeSkills(host, target.directory, {
891
+ launcherPackageRoot: options.launcherPackageRoot,
892
+ ...(options.skillCatalogueRoot === undefined ? {} : { skillCatalogueRoot: options.skillCatalogueRoot }),
893
+ });
894
+ writeSisterConsumerAgentsIfNeeded(host, target.directory);
895
+ rosterTargets.push(target.inventoryId);
896
+ }
897
+ return { ...hubSkill, rosterTargets, rosterSkipped: skipped };
898
+ }
899
+
900
+ function finishHubApply(
901
+ host: WorkspaceHost,
902
+ directory: string,
903
+ headline: string,
904
+ launcherPackageRoot: string,
905
+ hubOwner: string,
906
+ hubRepository: string,
907
+ liveAdvisorVersion?: string,
908
+ skillCatalogueRoot?: string,
909
+ ): WorkspaceApplyResult {
910
+ const skillComposition = composeSkillRoster(host, directory, hubOwner, hubRepository, {
911
+ launcherPackageRoot,
912
+ ...(skillCatalogueRoot === undefined ? {} : { skillCatalogueRoot }),
913
+ });
914
+ return withHealth(host, directory, headline, liveAdvisorVersion, skillComposition);
915
+ }
916
+
917
+ /** Applies a create, resume, or adopt plan through the host. Resume refreshes composed skills and stale AGENTS.md guidance. */
918
+ export function applyWorkspacePlan(
919
+ host: WorkspaceHost,
920
+ plan: WorkspacePlan,
921
+ skeletonRoot: string,
922
+ options: ApplyWorkspaceOptions = {},
923
+ ): WorkspaceApplyResult {
924
+ const launcherPackageRoot = options.launcherPackageRoot ?? resolve(skeletonRoot, "..");
925
+ const skillCatalogueRoot = options.skillCatalogueRoot;
529
926
  if (plan.action === "resume") {
530
927
  if (plan.clone) {
531
928
  requireZero(
@@ -533,10 +930,15 @@ export function applyWorkspacePlan(host: WorkspaceHost, plan: WorkspacePlan, ske
533
930
  "gh repo clone",
534
931
  );
535
932
  }
536
- return withHealth(
933
+ return finishHubApply(
537
934
  host,
538
935
  plan.directory,
539
936
  `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.`,
937
+ launcherPackageRoot,
938
+ plan.owner,
939
+ plan.repository,
940
+ undefined,
941
+ skillCatalogueRoot,
540
942
  );
541
943
  }
542
944
  if (plan.action === "create") {
@@ -549,22 +951,34 @@ export function applyWorkspacePlan(host: WorkspaceHost, plan: WorkspacePlan, ske
549
951
  ),
550
952
  "gh repo create",
551
953
  );
552
- return withHealth(
954
+ return finishHubApply(
553
955
  host,
554
956
  plan.directory,
555
957
  `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.`,
958
+ launcherPackageRoot,
959
+ plan.owner,
960
+ plan.repository,
556
961
  plan.advisorVersion,
962
+ skillCatalogueRoot,
557
963
  );
558
964
  }
559
965
  adoptHubFiles(host, skeletonRoot, plan);
560
- return withHealth(
966
+ return finishHubApply(
561
967
  host,
562
968
  plan.directory,
563
969
  `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.`,
970
+ launcherPackageRoot,
971
+ plan.owner,
972
+ plan.repository,
564
973
  plan.advisorVersion,
974
+ skillCatalogueRoot,
565
975
  );
566
976
  }
567
977
 
978
+ export function launcherPackageRootFromModule(moduleUrl: string): string {
979
+ return resolve(dirname(fileURLToPath(moduleUrl)), "..");
980
+ }
981
+
568
982
  export function skeletonRootFromModule(moduleUrl: string): string {
569
983
  return resolve(dirname(fileURLToPath(moduleUrl)), "..", "skeleton");
570
984
  }