@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.
- package/CHANGELOG.md +37 -0
- package/README.md +49 -9
- package/dist/check-cli.d.ts.map +1 -1
- package/dist/check-cli.js +9 -1
- package/dist/check-cli.js.map +1 -1
- package/dist/cli.d.ts +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +14 -6
- package/dist/cli.js.map +1 -1
- package/dist/core.d.ts +28 -5
- package/dist/core.d.ts.map +1 -1
- package/dist/core.js +389 -32
- package/dist/core.js.map +1 -1
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +16 -1
- package/dist/host.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/skills.d.ts +20 -0
- package/dist/skills.d.ts.map +1 -0
- package/dist/skills.js +106 -0
- package/dist/skills.js.map +1 -0
- package/dist/types.d.ts +50 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +3 -2
- package/skeleton/README.md +11 -21
- package/skill-catalogue/advisor/SKILL.md +50 -0
- package/skill-catalogue/architect/SKILL.md +42 -0
- package/skill-catalogue/bouncer/SKILL.md +43 -0
- package/skill-catalogue/builder/SKILL.md +42 -0
- package/skill-catalogue/butler/SKILL.md +43 -0
- package/skill-catalogue/controller/SKILL.md +42 -0
- package/skill-catalogue/designer/SKILL.md +50 -0
- package/skill-catalogue/giver/SKILL.md +43 -0
- package/skill-catalogue/influencer/SKILL.md +43 -0
- package/skill-catalogue/inspector/SKILL.md +42 -0
- package/skill-catalogue/integrator/SKILL.md +42 -0
- package/skill-catalogue/keeper/SKILL.md +43 -0
- package/skill-catalogue/launcher/SKILL.md +53 -0
- package/skill-catalogue/locksmith/SKILL.md +42 -0
- package/skill-catalogue/messenger/SKILL.md +43 -0
- package/skill-catalogue/observer/SKILL.md +42 -0
- package/skill-catalogue/publisher/SKILL.md +50 -0
- package/skill-catalogue/starter/SKILL.md +48 -0
- package/skill-catalogue/strategist/SKILL.md +51 -0
- package/skill-catalogue/writer/SKILL.md +51 -0
- package/src/check-cli.ts +9 -1
- package/src/cli.ts +13 -6
- package/src/core.ts +442 -28
- package/src/host.ts +14 -1
- package/src/index.ts +10 -0
- package/src/skills.ts +120 -0
- 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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
368
|
-
|
|
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 ("
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
435
|
-
|
|
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:
|
|
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
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
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 =
|
|
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
|
-
/**
|
|
482
|
-
|
|
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
|
|
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
|
|
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
|
|
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");
|