@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/dist/core.js CHANGED
@@ -1,9 +1,16 @@
1
1
  import { dirname, join, resolve, sep } from "node:path";
2
2
  import { fileURLToPath } from "node:url";
3
+ import { composeSkills } from "./skills.js";
3
4
  export const DEFAULT_REPOSITORY_NAME = "workspace";
4
5
  export const WORKSPACE_MARKER_REL = ".clossys/workspace.json";
5
6
  export const WORKSPACE_INVENTORY_REL = ".clossys/inventory.json";
6
7
  export const ADVISOR_PACKAGE = "@clossys/advisor";
8
+ const DEPENDENCY_BUCKETS = [
9
+ "dependencies",
10
+ "devDependencies",
11
+ "optionalDependencies",
12
+ "peerDependencies",
13
+ ];
7
14
  const OWNER = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/;
8
15
  const REPO = /^[A-Za-z0-9._-]+$/;
9
16
  const SKELETON_FILES = [
@@ -16,6 +23,29 @@ const SKELETON_FILES = [
16
23
  /** Written at generate time so this package never ships a nested AGENTS.md. */
17
24
  export const CONSUMER_AGENTS_MD = `# Account workspace
18
25
 
26
+ This folder is the account hub for Foundry packages.
27
+
28
+ After \`npx @clossys/launcher\`, the same \`@clossys-*\` team is composed in
29
+ every inventoried checkout beside this hub. Talk with \`@clossys-advisor\` and
30
+ \`@clossys-<package>\` here or in a product repository. A missing \`@\` mention
31
+ is not how we signal incompatibility — \`@clossys-advisor\` is the hiring check.
32
+
33
+ Run \`npx @clossys/launcher\` again for hub health and to refresh voices on
34
+ clones next to the hub, not as how you talk to packages.
35
+
36
+ Advisor is read-only until the sponsor approves a next action.
37
+ `;
38
+ /** Canned guidance for inventoried product checkouts (not the hub). */
39
+ export const SISTER_CONSUMER_AGENTS_MD = `# Product repository
40
+
41
+ This repository is part of the same account engagement. The same
42
+ \`@clossys-<package>\` team is here for intro and questions;
43
+ \`@clossys-advisor\` decides hiring and compatibility. This folder is not the
44
+ hub — engines are hired per repository, not dumped here.
45
+ `;
46
+ /** Previous generate-time guidance; used to refresh stale hub AGENTS.md on resume. */
47
+ export const LEGACY_CONSUMER_AGENTS_MD = `# Account workspace
48
+
19
49
  This repository is the account hub for Foundry packages. It inventories
20
50
  where packages are installed and coordinates engagement. It is not a
21
51
  product application and does not need the whole catalogue installed here.
@@ -70,6 +100,37 @@ export function parseGitHubRemote(url) {
70
100
  return null;
71
101
  }
72
102
  }
103
+ function readJson(host, path) {
104
+ const raw = host.readText(path);
105
+ if (raw === null)
106
+ return undefined;
107
+ try {
108
+ return JSON.parse(raw);
109
+ }
110
+ catch {
111
+ return undefined;
112
+ }
113
+ }
114
+ /** Tiny semver-ish compare of `a` versus `b`: -1 older, 0 equal, 1 newer. Unparseable versions are indeterminate (null). */
115
+ function compareVersions(a, b) {
116
+ const parse = (version) => {
117
+ const match = version.trim().match(/^v?(\d+)\.(\d+)\.(\d+)$/);
118
+ return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null;
119
+ };
120
+ const left = parse(a);
121
+ const right = parse(b);
122
+ if (!left || !right)
123
+ return null;
124
+ for (let index = 0; index < 3; index += 1) {
125
+ const l = left[index] ?? 0;
126
+ const r = right[index] ?? 0;
127
+ if (l < r)
128
+ return -1;
129
+ if (l > r)
130
+ return 1;
131
+ }
132
+ return 0;
133
+ }
73
134
  /** True when the marker is a v1 account-hub document. */
74
135
  export function isHubDocument(value) {
75
136
  if (!isRecord(value) || value.schemaVersion !== 1 || value.kind !== "account-hub")
@@ -144,6 +205,12 @@ function commandAvailable(host, command) {
144
205
  const result = host.run(command, ["--version"]);
145
206
  return result.status === 0;
146
207
  }
208
+ /** True when the tree already pins Advisor in some bucket, so adopt would not need a new version. */
209
+ export function hasAdvisorPin(manifest) {
210
+ if (!isRecord(manifest))
211
+ return false;
212
+ return DEPENDENCY_BUCKETS.some((bucket) => clossysNames(manifest[bucket], new Set()) !== undefined);
213
+ }
147
214
  /** Collects GitHub owner, cwd shape, default-hub presence, and the public Advisor pin. */
148
215
  export function observeWorkspace(host) {
149
216
  const cwd = host.cwd;
@@ -184,10 +251,15 @@ export function observeWorkspace(host) {
184
251
  if (viewed.status === 0)
185
252
  remoteDefaultHub = { owner: ownerGuess, repository: DEFAULT_REPOSITORY_NAME };
186
253
  }
187
- const viewedAdvisor = host.run("npm", ["view", ADVISOR_PACKAGE, "version"]);
188
- const version = viewedAdvisor.stdout.trim();
189
- if (viewedAdvisor.status === 0 && /^\d+\.\d+\.\d+$/.test(version))
190
- advisorVersion = version;
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
+ }
191
263
  const cwdObservation = {
192
264
  absolutePath: cwd,
193
265
  empty: isEffectivelyEmpty(entries),
@@ -230,22 +302,74 @@ function resolveOwner(observation, host) {
230
302
  * Decides create, resume, or adopt from a cwd observation.
231
303
  * Appointing means: run this from the GitHub repository that should own the hub.
232
304
  */
305
+ export function readInventoryRepositories(host, source, label) {
306
+ const raw = host.readText(source);
307
+ if (raw === null)
308
+ return [];
309
+ try {
310
+ const parsed = JSON.parse(raw);
311
+ if (!isRecord(parsed) || !Array.isArray(parsed.repositories))
312
+ return [];
313
+ return parsed.repositories.map((entry) => {
314
+ if (isRecord(entry) && isText(entry.id))
315
+ return entry.id.trim();
316
+ return "";
317
+ });
318
+ }
319
+ catch {
320
+ throw new Error(`${label} is not readable inventory JSON (schemaVersion 1, repositories array)`);
321
+ }
322
+ }
323
+ /**
324
+ * Resolves which repositories the adopt plan inventories. When the on-disk hub
325
+ * inventory is populated AND --inventory is supplied, the two are merged by id:
326
+ * on-disk entries keep their order, ids not already on disk are appended in
327
+ * supplied-file order, and the first occurrence of an id wins; the plan carries
328
+ * the merged ids for applyWorkspacePlan to write. When both are empty or
329
+ * missing, adopt refuses rather than appointing an inventory-less hub.
330
+ */
233
331
  function resolveAdoptInventory(host, cwd, inventoryPath) {
234
- if (cwd.inventory?.status === "populated")
235
- return {};
236
332
  const trimmed = inventoryPath?.trim();
237
- if (!trimmed) {
333
+ const onDiskPopulated = cwd.inventory?.status === "populated";
334
+ if (!onDiskPopulated && !trimmed) {
238
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");
239
336
  }
337
+ if (!trimmed)
338
+ return {};
240
339
  const resolved = resolve(cwd.absolutePath, trimmed);
241
340
  const imported = inspectInventory(host.readText(resolved));
242
341
  if (imported.status !== "populated") {
243
342
  return refuse("violated", "--inventory must point at a populated inventory document (schemaVersion 1, nonempty repositories)");
244
343
  }
245
- return { inventorySource: resolved };
344
+ if (!onDiskPopulated)
345
+ return { inventorySource: resolved };
346
+ const merged = [];
347
+ const seen = new Set();
348
+ for (const id of readInventoryRepositories(host, join(cwd.absolutePath, WORKSPACE_INVENTORY_REL), "the on-disk hub inventory")) {
349
+ if (id !== "" && !seen.has(id)) {
350
+ seen.add(id);
351
+ merged.push(id);
352
+ }
353
+ }
354
+ for (const id of readInventoryRepositories(host, resolved, "--inventory")) {
355
+ if (id !== "" && !seen.has(id)) {
356
+ seen.add(id);
357
+ merged.push(id);
358
+ }
359
+ }
360
+ return { mergedInventoryIds: merged };
246
361
  }
247
362
  export function planWorkspace(observation, host, options = {}) {
248
363
  const { cwd } = observation;
364
+ const envOwner = observation.envOwner ?? (host.env.CLOSSYS_OWNER?.trim() || undefined);
365
+ if (envOwner !== undefined &&
366
+ !cwd.looksLikeFoundry &&
367
+ cwd.hub === undefined &&
368
+ cwd.git &&
369
+ cwd.githubOwner !== undefined &&
370
+ cwd.githubOwner !== envOwner) {
371
+ return refuse("violated", `CLOSSYS_OWNER is "${envOwner}" but the github.com origin here is owned by "${cwd.githubOwner}"; refusing to appoint a marker for the wrong account`);
372
+ }
249
373
  if (cwd.looksLikeFoundry) {
250
374
  return refuse("violated", "refusing to scaffold a hub inside the Foundry supplier tree; run from the GitHub repository you want to appoint, or from an empty directory");
251
375
  }
@@ -272,6 +396,7 @@ export function planWorkspace(observation, host, options = {}) {
272
396
  directory: cwd.absolutePath,
273
397
  advisorVersion: observation.advisorVersion,
274
398
  ...(imported.inventorySource === undefined ? {} : { inventorySource: imported.inventorySource }),
399
+ ...(imported.mergedInventoryIds === undefined ? {} : { mergedInventoryIds: imported.mergedInventoryIds }),
275
400
  };
276
401
  }
277
402
  if (cwd.git) {
@@ -310,7 +435,7 @@ function containedPath(root, relativePath) {
310
435
  const resolved = resolve(root, relativePath);
311
436
  const prefix = root.endsWith(sep) ? root : `${root}${sep}`;
312
437
  if (resolved !== root && !resolved.startsWith(prefix)) {
313
- throw new Error(`refusing to write outside the hub directory: ${relativePath}`);
438
+ throw new Error(`refusing to write outside the workspace directory: ${relativePath}`);
314
439
  }
315
440
  return resolved;
316
441
  }
@@ -364,9 +489,8 @@ function mergeAdvisorPin(host, directory, skeletonRoot, advisorVersion, reposito
364
489
  throw new Error("existing package.json is unreadable JSON");
365
490
  }
366
491
  const extra = new Set();
367
- const inDependencies = clossysNames(manifest.dependencies, extra);
368
- const inDevDependencies = clossysNames(manifest.devDependencies, extra);
369
- if (inDependencies !== undefined || inDevDependencies !== undefined)
492
+ const pinnedSomewhere = DEPENDENCY_BUCKETS.some((bucket) => clossysNames(manifest[bucket], extra) !== undefined);
493
+ if (pinnedSomewhere)
370
494
  return;
371
495
  const devDependencies = isRecord(manifest.devDependencies) ? { ...manifest.devDependencies } : {};
372
496
  devDependencies[ADVISOR_PACKAGE] = advisorVersion;
@@ -383,7 +507,28 @@ function copySkeleton(host, skeletonRoot, plan) {
383
507
  writeSkeletonFile(host, plan.directory, "AGENTS.md", CONSUMER_AGENTS_MD);
384
508
  writeSkeletonFile(host, plan.directory, "CLAUDE.md", CONSUMER_CLAUDE_MD);
385
509
  }
510
+ /** Refuses adopt when the working tree has uncommitted changes; names a non-github.com remote host. */
511
+ function assertCleanTree(host, directory) {
512
+ const status = host.run("git", ["status", "--porcelain"], { cwd: directory });
513
+ if (status.status === 0 && status.stdout.trim() !== "") {
514
+ const origin = host.run("git", ["remote", "get-url", "origin"], { cwd: directory }).stdout.trim();
515
+ let where = "this checkout";
516
+ if (origin !== "") {
517
+ try {
518
+ const parsed = new URL(origin);
519
+ if (parsed.protocol === "ssh:" || parsed.protocol === "https:" || parsed.protocol === "http:") {
520
+ where = `the repository on ${parsed.hostname}`;
521
+ }
522
+ }
523
+ catch {
524
+ /* ssh form or unparsable; keep the generic host */
525
+ }
526
+ }
527
+ throw new Error(`${where} has uncommitted changes (git status --porcelain is nonempty); commit or stash them before appointing it as the account hub`);
528
+ }
529
+ }
386
530
  function adoptHubFiles(host, skeletonRoot, plan) {
531
+ assertCleanTree(host, plan.directory);
387
532
  const marker = {
388
533
  schemaVersion: 1,
389
534
  kind: "account-hub",
@@ -391,7 +536,11 @@ function adoptHubFiles(host, skeletonRoot, plan) {
391
536
  repository: `${plan.owner}/${plan.repository}`,
392
537
  };
393
538
  writeSkeletonFile(host, plan.directory, WORKSPACE_MARKER_REL, `${JSON.stringify(marker, null, 2)}\n`);
394
- if ("inventorySource" in plan && typeof plan.inventorySource === "string") {
539
+ if ("mergedInventoryIds" in plan && Array.isArray(plan.mergedInventoryIds)) {
540
+ const document = { schemaVersion: 1, repositories: plan.mergedInventoryIds.map((id) => ({ id })) };
541
+ writeSkeletonFile(host, plan.directory, WORKSPACE_INVENTORY_REL, `${JSON.stringify(document, null, 2)}\n`);
542
+ }
543
+ else if ("inventorySource" in plan && typeof plan.inventorySource === "string") {
395
544
  const raw = host.readText(plan.inventorySource);
396
545
  if (raw === null || inspectInventory(raw).status !== "populated") {
397
546
  throw new Error("inventory source is not a populated inventory document");
@@ -401,6 +550,9 @@ function adoptHubFiles(host, skeletonRoot, plan) {
401
550
  if (host.readText(join(plan.directory, "AGENTS.md")) === null) {
402
551
  writeSkeletonFile(host, plan.directory, "AGENTS.md", CONSUMER_AGENTS_MD);
403
552
  }
553
+ else {
554
+ writeConsumerAgentsIfNeeded(host, plan.directory);
555
+ }
404
556
  if (host.readText(join(plan.directory, "CLAUDE.md")) === null) {
405
557
  writeSkeletonFile(host, plan.directory, "CLAUDE.md", CONSUMER_CLAUDE_MD);
406
558
  }
@@ -421,78 +573,283 @@ function requireZero(result, label) {
421
573
  throw new Error(`${label} failed (${result.status}): ${result.stderr.trim() || result.stdout.trim() || "no output"}`);
422
574
  }
423
575
  }
424
- /** Read-only pin and inventory report. Does not install or uninstall. */
576
+ /**
577
+ * Read-only pin and inventory report. Does not install or uninstall. Scans all
578
+ * four dependency buckets; grades each pinned advisor version against the live
579
+ * registry version, marking a pin older than live as a stale-pin finding and a
580
+ * degraded report.
581
+ */
425
582
  export function reportHubHealth(host, directory, liveAdvisorVersion) {
426
583
  const extra = new Set();
427
- let dependencies;
428
- let devDependencies;
584
+ const pins = {};
429
585
  const manifestRaw = host.readText(join(directory, "package.json"));
430
586
  if (manifestRaw !== null) {
431
587
  try {
432
588
  const parsed = JSON.parse(manifestRaw);
433
589
  if (isRecord(parsed)) {
434
- dependencies = clossysNames(parsed.dependencies, extra);
435
- devDependencies = clossysNames(parsed.devDependencies, extra);
590
+ for (const bucket of DEPENDENCY_BUCKETS) {
591
+ const pin = clossysNames(parsed[bucket], extra);
592
+ if (pin !== undefined)
593
+ pins[bucket] = pin;
594
+ }
436
595
  }
437
596
  }
438
597
  catch {
439
598
  /* unreadable manifest is reported as missing pins */
440
599
  }
441
600
  }
601
+ const pinFindings = Object.entries(pins).flatMap(([bucket, pinned]) => {
602
+ if (liveAdvisorVersion === undefined)
603
+ return [];
604
+ const comparison = compareVersions(pinned, liveAdvisorVersion);
605
+ if (comparison === null) {
606
+ return [{ bucket: bucket, pinned, grade: "indeterminate", note: `cannot compare ${pinned} with live ${liveAdvisorVersion}` }];
607
+ }
608
+ return comparison < 0
609
+ ? [{ bucket: bucket, pinned, grade: "stale", note: `pinned ${pinned} is older than live ${liveAdvisorVersion}` }]
610
+ : [];
611
+ });
442
612
  return {
443
613
  marker: readHub(host, directory) === undefined ? "missing" : "present",
444
614
  inventory: inspectInventory(host.readText(join(directory, WORKSPACE_INVENTORY_REL))),
445
615
  advisorPin: {
446
- ...(dependencies === undefined ? {} : { dependencies }),
447
- ...(devDependencies === undefined ? {} : { devDependencies }),
616
+ ...(pins.dependencies === undefined ? {} : { dependencies: pins.dependencies }),
617
+ ...(pins.devDependencies === undefined ? {} : { devDependencies: pins.devDependencies }),
618
+ ...(pins.optionalDependencies === undefined ? {} : { optionalDependencies: pins.optionalDependencies }),
619
+ ...(pins.peerDependencies === undefined ? {} : { peerDependencies: pins.peerDependencies }),
448
620
  ...(liveAdvisorVersion === undefined ? {} : { live: liveAdvisorVersion }),
449
621
  },
450
- dualPin: dependencies !== undefined && devDependencies !== undefined,
622
+ dualPin: Object.values(pins).filter((value) => value !== undefined).length > 1,
451
623
  extraClossys: [...extra].sort(),
624
+ pinFindings,
625
+ degraded: pinFindings.some((finding) => finding.grade === "stale"),
452
626
  };
453
627
  }
454
628
  export function formatHubHealth(report) {
455
629
  const pinParts = [];
456
- if (report.advisorPin.dependencies !== undefined)
457
- pinParts.push(`dependencies ${report.advisorPin.dependencies}`);
458
- if (report.advisorPin.devDependencies !== undefined)
459
- pinParts.push(`devDependencies ${report.advisorPin.devDependencies}`);
630
+ for (const bucket of DEPENDENCY_BUCKETS) {
631
+ const pinned = report.advisorPin[bucket];
632
+ if (pinned !== undefined)
633
+ pinParts.push(`${bucket} ${pinned}`);
634
+ }
460
635
  const pin = pinParts.length === 0 ? "missing" : pinParts.join(" and ");
461
636
  const live = report.advisorPin.live === undefined ? "" : `; live ${report.advisorPin.live}`;
462
637
  const extra = report.extraClossys.length === 0 ? "none" : report.extraClossys.join(", ");
463
638
  const inventory = report.inventory.status === "populated" ? `populated (${report.inventory.count})` : report.inventory.status;
639
+ const findings = report.pinFindings.map((finding) => finding.note !== undefined ? `${finding.bucket} ${finding.note}` : `${finding.bucket} ${finding.grade}`);
640
+ const findingLine = findings.length === 0 ? "none" : findings.join("; ");
641
+ const skillParts = [];
642
+ if (report.skillComposition !== undefined) {
643
+ skillParts.push(report.skillComposition.composed.length === 0
644
+ ? "skills composed: none"
645
+ : `skills composed: ${report.skillComposition.composed.map((name) => `clossys-${name}`).join(", ")}`);
646
+ for (const skip of report.skillComposition.skipped) {
647
+ skillParts.push(`skill skipped (${skip.packageDir}): ${skip.note}`);
648
+ }
649
+ if (report.skillComposition.rosterTargets !== undefined && report.skillComposition.rosterTargets.length > 0) {
650
+ skillParts.push(`skill roster written: ${report.skillComposition.rosterTargets.join(", ")}`);
651
+ }
652
+ for (const skip of report.skillComposition.rosterSkipped ?? []) {
653
+ skillParts.push(`skill roster skipped (${skip.inventoryId}): ${skip.note}`);
654
+ }
655
+ }
464
656
  return [
465
657
  `hub marker: ${report.marker}`,
466
658
  `inventory: ${inventory}`,
467
659
  `advisor pin: ${pin}${live}`,
468
660
  `dual pin: ${report.dualPin ? "yes" : "no"}`,
469
661
  `extra @clossys/*: ${extra}`,
662
+ `pin findings: ${findingLine}`,
663
+ `degraded: ${report.degraded ? "yes" : "no"}`,
664
+ ...(skillParts.length === 0 ? [] : skillParts),
470
665
  `health: ${JSON.stringify(report)}`,
471
666
  ].join("\n");
472
667
  }
473
- function withHealth(host, directory, headline, liveAdvisorVersion) {
474
- const health = reportHubHealth(host, directory, liveAdvisorVersion);
668
+ function withHealth(host, directory, headline, liveAdvisorVersion, skillComposition) {
669
+ const health = {
670
+ ...reportHubHealth(host, directory, liveAdvisorVersion),
671
+ ...(skillComposition === undefined ? {} : { skillComposition }),
672
+ };
475
673
  return {
476
674
  state: "satisfied",
477
675
  message: `${headline}\n${formatHubHealth(health)}`,
478
676
  health,
479
677
  };
480
678
  }
481
- /** Applies a create, resume, or adopt plan through the host. Resume does not write. */
482
- export function applyWorkspacePlan(host, plan, skeletonRoot) {
679
+ /**
680
+ * Read-only inventory validation: runs `gh repo view --json name` for each
681
+ * repository id, batched. Tolerates a missing or failing `gh` by skipping with
682
+ * a note; marks ids whose check fails as unknown. Never mutates the inventory.
683
+ */
684
+ export function checkInventoryEntries(host, directory) {
685
+ const raw = host.readText(join(directory, WORKSPACE_INVENTORY_REL));
686
+ const observation = inspectInventory(raw);
687
+ if (observation.status !== "populated") {
688
+ return { entries: [], skipped: true, note: `inventory is ${observation.status}; nothing to validate` };
689
+ }
690
+ const available = commandAvailable(host, "gh");
691
+ if (!available) {
692
+ return { entries: [], skipped: true, note: "`gh` is unavailable; inventory ids were not validated" };
693
+ }
694
+ const ids = readInventoryRepositories(host, join(directory, WORKSPACE_INVENTORY_REL), "the hub inventory").filter((id) => id !== "");
695
+ const batch = 20;
696
+ const entries = [];
697
+ for (let index = 0; index < ids.length; index += batch) {
698
+ for (const id of ids.slice(index, index + batch)) {
699
+ const viewed = host.run("gh", ["repo", "view", id, "--json", "name"]);
700
+ if (viewed.status === 0) {
701
+ entries.push({ id, known: true });
702
+ }
703
+ else if (viewed.status === 127 || (viewed.status === null && viewed.stderr.includes("ENOENT"))) {
704
+ entries.push({ id, known: null, note: "`gh` unavailable; not checked" });
705
+ }
706
+ else {
707
+ entries.push({ id, known: false, note: "`gh repo view` failed; the repository may not exist" });
708
+ }
709
+ }
710
+ }
711
+ return { entries, skipped: false };
712
+ }
713
+ function shouldRefreshConsumerAgents(existing) {
714
+ if (existing === null)
715
+ return true;
716
+ if (existing === CONSUMER_AGENTS_MD)
717
+ return false;
718
+ if (existing === LEGACY_CONSUMER_AGENTS_MD)
719
+ return true;
720
+ if (existing.includes("Run `npx @clossys/launcher` again to resume"))
721
+ return true;
722
+ return false;
723
+ }
724
+ function writeConsumerAgentsIfNeeded(host, directory) {
725
+ const existing = host.readText(join(directory, "AGENTS.md"));
726
+ if (!shouldRefreshConsumerAgents(existing))
727
+ return;
728
+ writeSkeletonFile(host, directory, "AGENTS.md", CONSUMER_AGENTS_MD);
729
+ }
730
+ function writeSisterConsumerAgentsIfNeeded(host, directory) {
731
+ const existing = host.readText(join(directory, "AGENTS.md"));
732
+ if (existing !== null && existing.trim() !== "" && existing !== SISTER_CONSUMER_AGENTS_MD)
733
+ return;
734
+ writeSkeletonFile(host, directory, "AGENTS.md", SISTER_CONSUMER_AGENTS_MD);
735
+ }
736
+ 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";
737
+ function parseInventoryRepositoryId(id, hubOwner) {
738
+ const trimmed = id.trim();
739
+ if (trimmed === "")
740
+ return null;
741
+ if (trimmed.includes("/")) {
742
+ const slash = trimmed.indexOf("/");
743
+ const owner = trimmed.slice(0, slash);
744
+ const repository = trimmed.slice(slash + 1);
745
+ if (!OWNER.test(owner) || !REPO.test(repository))
746
+ return null;
747
+ return { owner, repository };
748
+ }
749
+ if (!REPO.test(trimmed))
750
+ return null;
751
+ return { owner: hubOwner, repository: trimmed };
752
+ }
753
+ function resolveSisterCloneTargets(host, hubDirectory, hubOwner) {
754
+ const parent = dirname(resolve(hubDirectory));
755
+ const hubResolved = resolve(hubDirectory);
756
+ const skipped = [];
757
+ const targets = [];
758
+ const inventoryPath = join(hubDirectory, WORKSPACE_INVENTORY_REL);
759
+ let inventoryIds;
760
+ try {
761
+ inventoryIds = readInventoryRepositories(host, inventoryPath, "the hub inventory");
762
+ }
763
+ catch {
764
+ return { targets: [], skipped: [] };
765
+ }
766
+ for (const id of inventoryIds) {
767
+ if (id === "")
768
+ continue;
769
+ const parsed = parseInventoryRepositoryId(id, hubOwner);
770
+ if (parsed === null) {
771
+ skipped.push({ inventoryId: id, note: "inventory id is not a valid repository slug" });
772
+ continue;
773
+ }
774
+ if (parsed.owner !== hubOwner) {
775
+ skipped.push({ inventoryId: id, note: "other account; not this roster" });
776
+ continue;
777
+ }
778
+ const candidate = join(parent, parsed.repository);
779
+ const candidateResolved = resolve(candidate);
780
+ if (candidateResolved === hubResolved)
781
+ continue;
782
+ if (!host.exists(candidate) || !host.isDirectory(candidate)) {
783
+ skipped.push({ inventoryId: id, note: CLONE_NOT_BESIDE_HUB_NOTE });
784
+ continue;
785
+ }
786
+ if (looksLikeFoundry(host, candidate)) {
787
+ skipped.push({ inventoryId: id, note: "foundry supplier tree; skills are not written here" });
788
+ continue;
789
+ }
790
+ const originResult = host.run("git", ["remote", "get-url", "origin"], { cwd: candidate });
791
+ const originUrl = originResult.status === 0 ? originResult.stdout.trim() : "";
792
+ const remote = originUrl === "" ? null : parseGitHubRemote(originUrl);
793
+ if (remote === null || remote.owner !== parsed.owner || remote.repository !== parsed.repository) {
794
+ skipped.push({ inventoryId: id, note: "git origin does not match inventory id" });
795
+ continue;
796
+ }
797
+ targets.push({ inventoryId: id, directory: candidate });
798
+ }
799
+ return { targets, skipped };
800
+ }
801
+ function hubRosterId(host, hubDirectory, hubOwner, hubRepository) {
802
+ const document = readHub(host, hubDirectory);
803
+ if (document !== undefined)
804
+ return document.repository;
805
+ return `${hubOwner}/${hubRepository}`;
806
+ }
807
+ function composeSkillRoster(host, hubDirectory, hubOwner, hubRepository, options) {
808
+ const hubSkill = composeSkills(host, hubDirectory, {
809
+ launcherPackageRoot: options.launcherPackageRoot,
810
+ ...(options.skillCatalogueRoot === undefined ? {} : { skillCatalogueRoot: options.skillCatalogueRoot }),
811
+ });
812
+ writeConsumerAgentsIfNeeded(host, hubDirectory);
813
+ const hubId = hubRosterId(host, hubDirectory, hubOwner, hubRepository);
814
+ const rosterTargets = [hubId];
815
+ const { targets, skipped } = resolveSisterCloneTargets(host, hubDirectory, hubOwner);
816
+ for (const target of targets) {
817
+ composeSkills(host, target.directory, {
818
+ launcherPackageRoot: options.launcherPackageRoot,
819
+ ...(options.skillCatalogueRoot === undefined ? {} : { skillCatalogueRoot: options.skillCatalogueRoot }),
820
+ });
821
+ writeSisterConsumerAgentsIfNeeded(host, target.directory);
822
+ rosterTargets.push(target.inventoryId);
823
+ }
824
+ return { ...hubSkill, rosterTargets, rosterSkipped: skipped };
825
+ }
826
+ function finishHubApply(host, directory, headline, launcherPackageRoot, hubOwner, hubRepository, liveAdvisorVersion, skillCatalogueRoot) {
827
+ const skillComposition = composeSkillRoster(host, directory, hubOwner, hubRepository, {
828
+ launcherPackageRoot,
829
+ ...(skillCatalogueRoot === undefined ? {} : { skillCatalogueRoot }),
830
+ });
831
+ return withHealth(host, directory, headline, liveAdvisorVersion, skillComposition);
832
+ }
833
+ /** Applies a create, resume, or adopt plan through the host. Resume refreshes composed skills and stale AGENTS.md guidance. */
834
+ export function applyWorkspacePlan(host, plan, skeletonRoot, options = {}) {
835
+ const launcherPackageRoot = options.launcherPackageRoot ?? resolve(skeletonRoot, "..");
836
+ const skillCatalogueRoot = options.skillCatalogueRoot;
483
837
  if (plan.action === "resume") {
484
838
  if (plan.clone) {
485
839
  requireZero(host.run("gh", ["repo", "clone", `${plan.owner}/${plan.repository}`, plan.directory]), "gh repo clone");
486
840
  }
487
- 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.`);
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);
488
842
  }
489
843
  if (plan.action === "create") {
490
844
  copySkeleton(host, skeletonRoot, plan);
491
845
  requireZero(host.run("gh", ["repo", "create", `${plan.owner}/${plan.repository}`, "--private", "--source", plan.directory, "--remote", "origin", "--push"], { cwd: plan.directory }), "gh repo create");
492
- 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);
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);
493
847
  }
494
848
  adoptHubFiles(host, skeletonRoot, plan);
495
- 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);
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);
850
+ }
851
+ export function launcherPackageRootFromModule(moduleUrl) {
852
+ return resolve(dirname(fileURLToPath(moduleUrl)), "..");
496
853
  }
497
854
  export function skeletonRootFromModule(moduleUrl) {
498
855
  return resolve(dirname(fileURLToPath(moduleUrl)), "..", "skeleton");