@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/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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
408
|
-
|
|
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 ("
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
475
|
-
|
|
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:
|
|
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
|
-
|
|
497
|
-
|
|
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 =
|
|
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
|
-
/**
|
|
528
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
}
|