@clossys/launcher 0.1.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +87 -0
- package/README.md +231 -19
- package/contracts/conversation-contract.md +40 -0
- package/dist/apply-plan-cli.d.ts +7 -0
- package/dist/apply-plan-cli.d.ts.map +1 -0
- package/dist/apply-plan-cli.js +96 -0
- package/dist/apply-plan-cli.js.map +1 -0
- package/dist/apply-plan.d.ts +79 -0
- package/dist/apply-plan.d.ts.map +1 -0
- package/dist/apply-plan.js +129 -0
- package/dist/apply-plan.js.map +1 -0
- package/dist/check-cli.d.ts.map +1 -1
- package/dist/check-cli.js +7 -0
- package/dist/check-cli.js.map +1 -1
- package/dist/cli.d.ts +2 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +43 -14
- package/dist/cli.js.map +1 -1
- package/dist/contract.d.ts +28 -0
- package/dist/contract.d.ts.map +1 -0
- package/dist/contract.js +78 -0
- package/dist/contract.js.map +1 -0
- package/dist/core.d.ts +50 -8
- package/dist/core.d.ts.map +1 -1
- package/dist/core.js +458 -40
- package/dist/core.js.map +1 -1
- package/dist/doctor-cli.d.ts +4 -0
- package/dist/doctor-cli.d.ts.map +1 -0
- package/dist/doctor-cli.js +32 -0
- package/dist/doctor-cli.js.map +1 -0
- package/dist/doctor.d.ts +28 -0
- package/dist/doctor.d.ts.map +1 -0
- package/dist/doctor.js +68 -0
- package/dist/doctor.js.map +1 -0
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +19 -1
- package/dist/host.js.map +1 -1
- package/dist/hosts.d.ts +14 -0
- package/dist/hosts.d.ts.map +1 -0
- package/dist/hosts.js +61 -0
- package/dist/hosts.js.map +1 -0
- package/dist/index.d.ts +15 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -1
- package/dist/index.js.map +1 -1
- package/dist/inventory-adoption.d.ts +20 -0
- package/dist/inventory-adoption.d.ts.map +1 -0
- package/dist/inventory-adoption.js +67 -0
- package/dist/inventory-adoption.js.map +1 -0
- package/dist/manifest.d.ts +20 -0
- package/dist/manifest.d.ts.map +1 -0
- package/dist/manifest.js +106 -0
- package/dist/manifest.js.map +1 -0
- package/dist/model-profile.d.ts +46 -0
- package/dist/model-profile.d.ts.map +1 -0
- package/dist/model-profile.js +98 -0
- package/dist/model-profile.js.map +1 -0
- package/dist/product-repository.d.ts +26 -0
- package/dist/product-repository.d.ts.map +1 -0
- package/dist/product-repository.js +49 -0
- package/dist/product-repository.js.map +1 -0
- package/dist/skills.d.ts +39 -0
- package/dist/skills.d.ts.map +1 -0
- package/dist/skills.js +197 -0
- package/dist/skills.js.map +1 -0
- package/dist/types.d.ts +78 -1
- package/dist/types.d.ts.map +1 -1
- package/model-profiles/claude-code.json +10 -0
- package/model-profiles/codex.json +10 -0
- package/model-profiles/cursor.json +10 -0
- package/package.json +11 -5
- package/skeleton/README.md +15 -20
- package/skeleton/package.json +1 -1
- package/skill/SKILL.md +53 -0
- package/skill-catalogue/advisor/SKILL.md +52 -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 +50 -0
- package/skill-catalogue/customer/SKILL.md +92 -0
- package/skill-catalogue/designer/SKILL.md +66 -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 +64 -0
- package/skill-catalogue/starter/SKILL.md +48 -0
- package/skill-catalogue/strategist/SKILL.md +85 -0
- package/skill-catalogue/writer/SKILL.md +56 -0
- package/src/apply-plan-cli.ts +94 -0
- package/src/apply-plan.ts +172 -0
- package/src/check-cli.ts +7 -0
- package/src/cli.ts +51 -13
- package/src/contract.ts +81 -0
- package/src/core.ts +555 -38
- package/src/doctor-cli.ts +33 -0
- package/src/doctor.ts +145 -0
- package/src/host.ts +17 -1
- package/src/hosts.ts +79 -0
- package/src/index.ts +36 -0
- package/src/inventory-adoption.ts +85 -0
- package/src/manifest.ts +103 -0
- package/src/model-profile.ts +148 -0
- package/src/product-repository.ts +73 -0
- package/src/skills.ts +225 -0
- package/src/types.ts +74 -1
- /package/skeleton/{.clossys → clossys/.state}/inventory.json +0 -0
- /package/skeleton/{.clossys → clossys/.state}/workspace.json +0 -0
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// Model guidance (#1219, Launcher side). Owner decision: packages declare
|
|
2
|
+
// WHAT a step demands (a reasoning tier), never model names. Three layers:
|
|
3
|
+
// 1. Step demands -- each package's own loop matrix (#1197, not built here).
|
|
4
|
+
// 2. Tier-to-model profile -- PER HOST, shipped by Launcher, dated and
|
|
5
|
+
// re-verified, so a model change reaches clients in one launcher
|
|
6
|
+
// release rather than a bump of every package.
|
|
7
|
+
// 3. User preference -- cost-conscious / balanced / max-quality, asked
|
|
8
|
+
// once by Advisor, stored in clossys/preferences.json (Advisor's file
|
|
9
|
+
// to write; this module only reads it to resolve a tier to a model).
|
|
10
|
+
//
|
|
11
|
+
// Floors are hard where the verdict depends on it (Customer's keep):
|
|
12
|
+
// resolveModelForTier never silently drops below a caller-supplied floor;
|
|
13
|
+
// it reports belowFloor so the composed skill can say so plainly.
|
|
14
|
+
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
|
|
17
|
+
export type ReasoningTier = "light" | "standard" | "deep";
|
|
18
|
+
export type BudgetPreference = "cost-conscious" | "balanced" | "max-quality";
|
|
19
|
+
export type SupportedHost = "claude-code" | "codex" | "cursor";
|
|
20
|
+
|
|
21
|
+
export interface HostTierMapping {
|
|
22
|
+
/** The model this host runs for this tier under a "balanced" preference. */
|
|
23
|
+
readonly balanced: string;
|
|
24
|
+
/** Cheaper substitute under "cost-conscious", when one exists for this tier. Absent means "balanced" is already the floor. */
|
|
25
|
+
readonly costConscious?: string;
|
|
26
|
+
/** Stronger substitute under "max-quality", when one exists for this tier. */
|
|
27
|
+
readonly maxQuality?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface HostModelProfile {
|
|
31
|
+
readonly schemaVersion: 1;
|
|
32
|
+
readonly host: SupportedHost;
|
|
33
|
+
/** ISO date this mapping was last verified against that host's real current models. */
|
|
34
|
+
readonly verifiedAt: string;
|
|
35
|
+
readonly tiers: Readonly<Record<ReasoningTier, HostTierMapping>>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface PreferencesDocument {
|
|
39
|
+
readonly schemaVersion: 1;
|
|
40
|
+
readonly budget: BudgetPreference;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface ModelResolution {
|
|
44
|
+
readonly tier: ReasoningTier;
|
|
45
|
+
readonly host: SupportedHost;
|
|
46
|
+
readonly model: string;
|
|
47
|
+
/** True when a hard floor tier was requested but this host/preference combination could not clear it -- reported, never silently substituted. */
|
|
48
|
+
readonly belowFloor: boolean;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
52
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const TIER_ORDER: readonly ReasoningTier[] = ["light", "standard", "deep"];
|
|
56
|
+
|
|
57
|
+
/** Parses clossys/preferences.json. Absent or malformed defaults to "balanced" -- a missing preference is not a floor violation. */
|
|
58
|
+
export function parsePreferences(raw: string | null): PreferencesDocument {
|
|
59
|
+
if (raw !== null) {
|
|
60
|
+
try {
|
|
61
|
+
const parsed: unknown = JSON.parse(raw);
|
|
62
|
+
if (
|
|
63
|
+
isRecord(parsed) &&
|
|
64
|
+
parsed.schemaVersion === 1 &&
|
|
65
|
+
(parsed.budget === "cost-conscious" || parsed.budget === "balanced" || parsed.budget === "max-quality")
|
|
66
|
+
) {
|
|
67
|
+
return { schemaVersion: 1, budget: parsed.budget };
|
|
68
|
+
}
|
|
69
|
+
} catch {
|
|
70
|
+
/* falls through to the default below */
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return { schemaVersion: 1, budget: "balanced" };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Resolves a step's demanded tier to a real model name for one host, under
|
|
78
|
+
* one budget preference, respecting an optional hard floor tier.
|
|
79
|
+
*/
|
|
80
|
+
export function resolveModelForTier(
|
|
81
|
+
profile: HostModelProfile,
|
|
82
|
+
tier: ReasoningTier,
|
|
83
|
+
budget: BudgetPreference,
|
|
84
|
+
floorTier?: ReasoningTier,
|
|
85
|
+
): ModelResolution {
|
|
86
|
+
const mapping = profile.tiers[tier];
|
|
87
|
+
const model =
|
|
88
|
+
budget === "cost-conscious"
|
|
89
|
+
? (mapping.costConscious ?? mapping.balanced)
|
|
90
|
+
: budget === "max-quality"
|
|
91
|
+
? (mapping.maxQuality ?? mapping.balanced)
|
|
92
|
+
: mapping.balanced;
|
|
93
|
+
const belowFloor = floorTier !== undefined && TIER_ORDER.indexOf(tier) < TIER_ORDER.indexOf(floorTier);
|
|
94
|
+
return { tier, host: profile.host, model, belowFloor };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function isTierMapping(value: unknown): value is HostTierMapping {
|
|
98
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
99
|
+
const record = value as Record<string, unknown>;
|
|
100
|
+
return (
|
|
101
|
+
typeof record.balanced === "string" &&
|
|
102
|
+
(record.costConscious === undefined || typeof record.costConscious === "string") &&
|
|
103
|
+
(record.maxQuality === undefined || typeof record.maxQuality === "string")
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Reads a packed `model-profiles/<host>.json` file from the launcher
|
|
109
|
+
* package root. Returns undefined -- never throws, never guesses a
|
|
110
|
+
* fallback profile -- when the file is absent or malformed, matching this
|
|
111
|
+
* package's read-only, fail-closed pattern for every other packed
|
|
112
|
+
* artifact (manifest.ts's parseSkillManifest is the sibling to model this
|
|
113
|
+
* on).
|
|
114
|
+
*/
|
|
115
|
+
export function readHostModelProfile(
|
|
116
|
+
readText: (path: string) => string | null,
|
|
117
|
+
launcherPackageRoot: string,
|
|
118
|
+
host: SupportedHost,
|
|
119
|
+
): HostModelProfile | undefined {
|
|
120
|
+
const raw = readText(join(launcherPackageRoot, "model-profiles", `${host}.json`));
|
|
121
|
+
if (raw === null) return undefined;
|
|
122
|
+
let parsed: unknown;
|
|
123
|
+
try {
|
|
124
|
+
parsed = JSON.parse(raw);
|
|
125
|
+
} catch {
|
|
126
|
+
return undefined;
|
|
127
|
+
}
|
|
128
|
+
if (
|
|
129
|
+
typeof parsed !== "object" ||
|
|
130
|
+
parsed === null ||
|
|
131
|
+
Array.isArray(parsed) ||
|
|
132
|
+
(parsed as Record<string, unknown>).schemaVersion !== 1 ||
|
|
133
|
+
(parsed as Record<string, unknown>).host !== host ||
|
|
134
|
+
typeof (parsed as Record<string, unknown>).verifiedAt !== "string"
|
|
135
|
+
) {
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
138
|
+
const tiersRaw = (parsed as Record<string, unknown>).tiers;
|
|
139
|
+
if (typeof tiersRaw !== "object" || tiersRaw === null || Array.isArray(tiersRaw)) return undefined;
|
|
140
|
+
const tiers = tiersRaw as Record<string, unknown>;
|
|
141
|
+
if (!isTierMapping(tiers.light) || !isTierMapping(tiers.standard) || !isTierMapping(tiers.deep)) return undefined;
|
|
142
|
+
return {
|
|
143
|
+
schemaVersion: 1,
|
|
144
|
+
host,
|
|
145
|
+
verifiedAt: (parsed as { verifiedAt: string }).verifiedAt,
|
|
146
|
+
tiers: { light: tiers.light, standard: tiers.standard, deep: tiers.deep },
|
|
147
|
+
};
|
|
148
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import type { WorkspaceHost } from "./types.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* One check from this repository's own docs/contracts/product-repository-layout.json `cloudSessionBootstrap` list (that contract file does not ship in the published package).
|
|
6
|
+
* Read-only: verifies a product repository has what a cloud agent session
|
|
7
|
+
* (browser plus GitHub only, no local setup) needs to install and run the
|
|
8
|
+
* team, without foundry's own heavy governance gates.
|
|
9
|
+
*/
|
|
10
|
+
export interface CloudBootstrapCheck {
|
|
11
|
+
readonly id: "package-manifest" | "agents-pointer" | "hub-marker";
|
|
12
|
+
readonly label: string;
|
|
13
|
+
readonly satisfied: boolean;
|
|
14
|
+
readonly note?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface CloudBootstrapReport {
|
|
18
|
+
readonly schemaVersion: 1;
|
|
19
|
+
readonly checks: readonly CloudBootstrapCheck[];
|
|
20
|
+
readonly ready: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const PACKAGE_JSON_REL = "package.json";
|
|
24
|
+
const PACKAGE_LOCK_REL = "package-lock.json";
|
|
25
|
+
const AGENTS_MD_REL = "AGENTS.md";
|
|
26
|
+
const HUB_MARKER_REL = join("clossys", ".state", "workspace.json");
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Runs the three product-repository-layout.json cloudSessionBootstrap checks
|
|
30
|
+
* against `directory`. Never mutates anything, never runs `npm ci` itself
|
|
31
|
+
* -- it checks that the manifest and lockfile are BOTH present, which is
|
|
32
|
+
* the precondition a real `npm ci` needs, not a substitute for running it.
|
|
33
|
+
*/
|
|
34
|
+
export function checkCloudSessionBootstrap(host: WorkspaceHost, directory: string): CloudBootstrapReport {
|
|
35
|
+
const hasManifest = host.exists(join(directory, PACKAGE_JSON_REL));
|
|
36
|
+
const hasLock = host.exists(join(directory, PACKAGE_LOCK_REL));
|
|
37
|
+
const manifestCheck: CloudBootstrapCheck = {
|
|
38
|
+
id: "package-manifest",
|
|
39
|
+
label: "package.json and package-lock.json are both present",
|
|
40
|
+
satisfied: hasManifest && hasLock,
|
|
41
|
+
...(hasManifest && hasLock
|
|
42
|
+
? {}
|
|
43
|
+
: {
|
|
44
|
+
note: !hasManifest && !hasLock
|
|
45
|
+
? "Neither package.json nor package-lock.json exists yet."
|
|
46
|
+
: !hasManifest
|
|
47
|
+
? "package-lock.json exists but package.json does not."
|
|
48
|
+
: "package.json exists but package-lock.json does not -- npm ci needs both.",
|
|
49
|
+
}),
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const agentsRaw = host.readText(join(directory, AGENTS_MD_REL));
|
|
53
|
+
const agentsPointsAtClossys = agentsRaw !== null && agentsRaw.includes("clossys/");
|
|
54
|
+
const agentsCheck: CloudBootstrapCheck = {
|
|
55
|
+
id: "agents-pointer",
|
|
56
|
+
label: "AGENTS.md exists at the repository root and points at clossys/",
|
|
57
|
+
satisfied: agentsPointsAtClossys,
|
|
58
|
+
...(agentsPointsAtClossys
|
|
59
|
+
? {}
|
|
60
|
+
: { note: agentsRaw === null ? "AGENTS.md does not exist at the repository root." : "AGENTS.md exists but does not mention clossys/." }),
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const hasHubMarker = host.exists(join(directory, HUB_MARKER_REL));
|
|
64
|
+
const markerCheck: CloudBootstrapCheck = {
|
|
65
|
+
id: "hub-marker",
|
|
66
|
+
label: "clossys/.state/workspace.json (the hub marker) is present",
|
|
67
|
+
satisfied: hasHubMarker,
|
|
68
|
+
...(hasHubMarker ? {} : { note: "clossys/.state/workspace.json does not exist yet." }),
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const checks = [manifestCheck, agentsCheck, markerCheck];
|
|
72
|
+
return { schemaVersion: 1, checks, ready: checks.every((check) => check.satisfied) };
|
|
73
|
+
}
|
package/src/skills.ts
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { dirname, join, resolve, sep } from "node:path";
|
|
2
|
+
import { extractContractBlock, injectContract } from "./contract.js";
|
|
3
|
+
import { parseSkillManifest, readInstalledVersion, serializeSkillManifest, sha256Hex } from "./manifest.js";
|
|
4
|
+
import type { SkillManifestEntry, WorkspaceHost } from "./types.js";
|
|
5
|
+
|
|
6
|
+
export interface SkillCompositionResult {
|
|
7
|
+
readonly composed: readonly string[];
|
|
8
|
+
readonly skipped: readonly { readonly packageDir: string; readonly note: string }[];
|
|
9
|
+
readonly rosterTargets?: readonly string[];
|
|
10
|
+
readonly rosterSkipped?: readonly { readonly inventoryId: string; readonly note: string }[];
|
|
11
|
+
/** Skills pruned this run: present in the directory's previous manifest, absent from this run's composed set. */
|
|
12
|
+
readonly retired: readonly string[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ComposeSkillsOptions {
|
|
16
|
+
readonly skillCatalogueRoot?: string;
|
|
17
|
+
readonly launcherPackageRoot: string;
|
|
18
|
+
/** Overrides where the packed conversation contract is read from (tests). */
|
|
19
|
+
readonly contractPath?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const DISCOVERY_PREFIXES = [".cursor/skills", ".claude/skills"] as const;
|
|
23
|
+
const AGENTS_SKILLS_REL = join(".agents", "skills");
|
|
24
|
+
export const SKILLS_MANIFEST_REL = join("clossys", ".state", "skills.json");
|
|
25
|
+
|
|
26
|
+
function containedPath(root: string, relativePath: string): string {
|
|
27
|
+
const resolved = resolve(root, relativePath);
|
|
28
|
+
const prefix = root.endsWith(sep) ? root : `${root}${sep}`;
|
|
29
|
+
if (resolved !== root && !resolved.startsWith(prefix)) {
|
|
30
|
+
throw new Error(`refusing to write outside the workspace directory: ${relativePath}`);
|
|
31
|
+
}
|
|
32
|
+
return resolved;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function skillSourceCandidates(packageDir: string, options: ComposeSkillsOptions): string[] {
|
|
36
|
+
const paths: string[] = [];
|
|
37
|
+
if (options.skillCatalogueRoot) {
|
|
38
|
+
paths.push(join(options.skillCatalogueRoot, packageDir, "SKILL.md"));
|
|
39
|
+
}
|
|
40
|
+
paths.push(join(options.launcherPackageRoot, "skill-catalogue", packageDir, "SKILL.md"));
|
|
41
|
+
paths.push(join(options.launcherPackageRoot, "..", packageDir, "skill", "SKILL.md"));
|
|
42
|
+
return paths;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function installedSkillPath(composeTargetDirectory: string, packageDir: string): string {
|
|
46
|
+
return join(composeTargetDirectory, "node_modules", "@clossys", packageDir, "skill", "SKILL.md");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function readSkillBody(
|
|
50
|
+
host: WorkspaceHost,
|
|
51
|
+
packageDir: string,
|
|
52
|
+
options: ComposeSkillsOptions,
|
|
53
|
+
composeTargetDirectory: string,
|
|
54
|
+
): { body: string; source: "installed" | "catalogue" } | null {
|
|
55
|
+
const installed = host.readText(installedSkillPath(composeTargetDirectory, packageDir));
|
|
56
|
+
if (installed !== null) return { body: installed, source: "installed" };
|
|
57
|
+
for (const path of skillSourceCandidates(packageDir, options)) {
|
|
58
|
+
const body = host.readText(path);
|
|
59
|
+
if (body !== null) return { body, source: "catalogue" };
|
|
60
|
+
}
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function contractSourceCandidates(options: ComposeSkillsOptions): string[] {
|
|
65
|
+
const paths: string[] = [];
|
|
66
|
+
if (options.contractPath) paths.push(options.contractPath);
|
|
67
|
+
paths.push(join(options.launcherPackageRoot, "contracts", "conversation-contract.md"));
|
|
68
|
+
// Monorepo fallback so tests and dev runs work without a prior `npm run build` pack step.
|
|
69
|
+
paths.push(join(options.launcherPackageRoot, "..", "..", "docs", "contracts", "conversation-contract.md"));
|
|
70
|
+
return paths;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Reads and extracts the shared conversation contract block. Returns null when no source resolves. */
|
|
74
|
+
function readContractBlock(host: WorkspaceHost, options: ComposeSkillsOptions): string | null {
|
|
75
|
+
for (const path of contractSourceCandidates(options)) {
|
|
76
|
+
const raw = host.readText(path);
|
|
77
|
+
if (raw === null) continue;
|
|
78
|
+
try {
|
|
79
|
+
return extractContractBlock(raw);
|
|
80
|
+
} catch {
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function readLauncherVersion(host: WorkspaceHost, options: ComposeSkillsOptions): string | undefined {
|
|
88
|
+
const raw = host.readText(join(options.launcherPackageRoot, "package.json"));
|
|
89
|
+
if (raw === null) return undefined;
|
|
90
|
+
try {
|
|
91
|
+
const parsed: unknown = JSON.parse(raw);
|
|
92
|
+
if (typeof parsed === "object" && parsed !== null && "version" in parsed) {
|
|
93
|
+
const version = (parsed as { version?: unknown }).version;
|
|
94
|
+
if (typeof version === "string" && version.trim() !== "") return version;
|
|
95
|
+
}
|
|
96
|
+
} catch {
|
|
97
|
+
/* unreadable manifest; no version */
|
|
98
|
+
}
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Package directory names that have a resolvable skill source in the catalogue or monorepo. */
|
|
103
|
+
export function listSkillPackageCandidates(host: WorkspaceHost, options: ComposeSkillsOptions): string[] {
|
|
104
|
+
const names = new Set<string>();
|
|
105
|
+
const considerRoot = (root: string | undefined): void => {
|
|
106
|
+
if (root === undefined || !host.isDirectory(root)) return;
|
|
107
|
+
for (const name of host.readDir(root)) {
|
|
108
|
+
if (name.startsWith(".")) continue;
|
|
109
|
+
const skillPath = join(root, name, "SKILL.md");
|
|
110
|
+
if (host.readText(skillPath) !== null) names.add(name);
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
considerRoot(options.skillCatalogueRoot);
|
|
114
|
+
considerRoot(join(options.launcherPackageRoot, "skill-catalogue"));
|
|
115
|
+
const packagesSibling = join(options.launcherPackageRoot, "..");
|
|
116
|
+
if (host.isDirectory(packagesSibling)) {
|
|
117
|
+
for (const name of host.readDir(packagesSibling)) {
|
|
118
|
+
if (name.startsWith(".")) continue;
|
|
119
|
+
const skillPath = join(packagesSibling, name, "skill", "SKILL.md");
|
|
120
|
+
if (host.readText(skillPath) !== null) names.add(name);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return [...names].sort((a, b) => a.localeCompare(b));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function writeAgentsSkill(host: WorkspaceHost, directory: string, packageDir: string, body: string): void {
|
|
127
|
+
const relativeDir = join(AGENTS_SKILLS_REL, `clossys-${packageDir}`);
|
|
128
|
+
const skillRel = join(relativeDir, "SKILL.md");
|
|
129
|
+
const target = containedPath(directory, skillRel);
|
|
130
|
+
host.mkdirp(dirname(target));
|
|
131
|
+
host.writeText(target, body.endsWith("\n") ? body : `${body}\n`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function writeDiscoveryLink(host: WorkspaceHost, directory: string, packageDir: string, skillBody: string): void {
|
|
135
|
+
const agentsDirRel = join(AGENTS_SKILLS_REL, `clossys-${packageDir}`);
|
|
136
|
+
const linkTarget = join("..", "..", agentsDirRel);
|
|
137
|
+
for (const prefix of DISCOVERY_PREFIXES) {
|
|
138
|
+
const prefixPath = containedPath(directory, prefix);
|
|
139
|
+
// A directory-symlink onto `.agents/skills` already exposes every composed
|
|
140
|
+
// skill. Writing a nested `.claude/skills/clossys-<pkg>` link follows that
|
|
141
|
+
// symlink and replaces the just-written SKILL.md with a circular link.
|
|
142
|
+
if (host.isSymlink(prefixPath)) continue;
|
|
143
|
+
const linkRel = join(prefix, `clossys-${packageDir}`);
|
|
144
|
+
const linkPath = containedPath(directory, linkRel);
|
|
145
|
+
host.mkdirp(dirname(linkPath));
|
|
146
|
+
try {
|
|
147
|
+
host.symlink(linkTarget, linkPath);
|
|
148
|
+
} catch {
|
|
149
|
+
const fallbackDir = containedPath(directory, join(linkRel, "SKILL.md"));
|
|
150
|
+
host.mkdirp(dirname(fallbackDir));
|
|
151
|
+
host.writeText(fallbackDir, skillBody.endsWith("\n") ? skillBody : `${skillBody}\n`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Removes a composed skill's `.agents/skills/` directory and its host discovery links. Missing paths are no-ops. */
|
|
157
|
+
function removeComposedSkill(host: WorkspaceHost, directory: string, packageDir: string): void {
|
|
158
|
+
host.remove(containedPath(directory, join(AGENTS_SKILLS_REL, `clossys-${packageDir}`)));
|
|
159
|
+
for (const prefix of DISCOVERY_PREFIXES) {
|
|
160
|
+
const prefixPath = containedPath(directory, prefix);
|
|
161
|
+
if (host.isSymlink(prefixPath)) continue; // shared view onto .agents/skills; already handled above
|
|
162
|
+
host.remove(containedPath(directory, join(prefix, `clossys-${packageDir}`)));
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Writes `.agents/skills/clossys-<pkg>/SKILL.md` and host discovery links for every
|
|
168
|
+
* catalogue package, with the shared conversation contract (#1182) injected in place
|
|
169
|
+
* of each skill's own `## How we work together` / `## One question at a time`
|
|
170
|
+
* sections. Missing sources are skipped with notes; the hub apply continues.
|
|
171
|
+
*
|
|
172
|
+
* Also writes `clossys/.state/skills.json` (#1183): source, version, and content
|
|
173
|
+
* digest per composed skill. A skill named in this directory's *previous* manifest
|
|
174
|
+
* but not composed this run is retired — its composed output is pruned and it is
|
|
175
|
+
* dropped from the new manifest. Never touches a skill this directory's manifest
|
|
176
|
+
* did not itself write.
|
|
177
|
+
*/
|
|
178
|
+
export function composeSkills(
|
|
179
|
+
host: WorkspaceHost,
|
|
180
|
+
directory: string,
|
|
181
|
+
options: ComposeSkillsOptions,
|
|
182
|
+
): SkillCompositionResult {
|
|
183
|
+
const composed: string[] = [];
|
|
184
|
+
const skipped: { packageDir: string; note: string }[] = [];
|
|
185
|
+
const manifestEntries: SkillManifestEntry[] = [];
|
|
186
|
+
const contractBlock = readContractBlock(host, options);
|
|
187
|
+
const launcherVersion = readLauncherVersion(host, options);
|
|
188
|
+
|
|
189
|
+
for (const packageDir of listSkillPackageCandidates(host, options)) {
|
|
190
|
+
const found = readSkillBody(host, packageDir, options, directory);
|
|
191
|
+
if (found === null) {
|
|
192
|
+
skipped.push({ packageDir, note: "skill source missing at apply time" });
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
const body = contractBlock === null ? found.body : injectContract(found.body, contractBlock);
|
|
196
|
+
writeAgentsSkill(host, directory, packageDir, body);
|
|
197
|
+
writeDiscoveryLink(host, directory, packageDir, body);
|
|
198
|
+
composed.push(packageDir);
|
|
199
|
+
const version = found.source === "installed" ? readInstalledVersion(host, directory, packageDir) : launcherVersion;
|
|
200
|
+
manifestEntries.push({
|
|
201
|
+
name: packageDir,
|
|
202
|
+
source: found.source,
|
|
203
|
+
sha256: sha256Hex(body.endsWith("\n") ? body : `${body}\n`),
|
|
204
|
+
...(version === undefined ? {} : { version }),
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const previousManifest = parseSkillManifest(host.readText(join(directory, SKILLS_MANIFEST_REL)));
|
|
209
|
+
const composedSet = new Set(composed);
|
|
210
|
+
const retired: string[] = [];
|
|
211
|
+
for (const entry of previousManifest?.skills ?? []) {
|
|
212
|
+
if (composedSet.has(entry.name)) continue;
|
|
213
|
+
removeComposedSkill(host, directory, entry.name);
|
|
214
|
+
retired.push(entry.name);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const manifestPath = containedPath(directory, SKILLS_MANIFEST_REL);
|
|
218
|
+
host.mkdirp(dirname(manifestPath));
|
|
219
|
+
host.writeText(
|
|
220
|
+
manifestPath,
|
|
221
|
+
serializeSkillManifest({ schemaVersion: 1, generatedAt: host.now(), skills: manifestEntries }),
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
return { composed, skipped, retired };
|
|
225
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import type { DiscoveredHost } from "./hosts.js";
|
|
2
|
+
import type { ExternalInventoryDeclaration, InventoryDriftReport } from "./inventory-adoption.js";
|
|
3
|
+
|
|
1
4
|
/** Ternary retained by the installed CLI. */
|
|
2
5
|
export type WorkspaceState = "satisfied" | "violated" | "indeterminate";
|
|
3
6
|
|
|
@@ -16,20 +19,59 @@ export interface WorkspaceHost {
|
|
|
16
19
|
now(): string;
|
|
17
20
|
exists(path: string): boolean;
|
|
18
21
|
isDirectory(path: string): boolean;
|
|
22
|
+
/** True when path exists and is a symlink (lstat; does not follow). Missing path is false. */
|
|
23
|
+
isSymlink(path: string): boolean;
|
|
19
24
|
readText(path: string): string | null;
|
|
20
25
|
writeText(path: string, contents: string): void;
|
|
21
26
|
mkdirp(path: string): void;
|
|
27
|
+
/** Creates a relative symlink at linkPath pointing at relativeTarget (directory link). */
|
|
28
|
+
symlink(relativeTarget: string, linkPath: string): void;
|
|
29
|
+
/** Recursively removes path. A missing path is a no-op, never a throw. */
|
|
30
|
+
remove(path: string): void;
|
|
22
31
|
readDir(path: string): string[];
|
|
23
32
|
run(command: string, args: readonly string[], options?: { cwd?: string }): CommandResult;
|
|
24
33
|
prompt(message: string, choices: readonly string[]): string | null;
|
|
25
34
|
}
|
|
26
35
|
|
|
27
|
-
/** On-disk hub marker generated on the consumer hub. Packed template: skeleton/.
|
|
36
|
+
/** On-disk hub marker generated on the consumer hub. Packed template: skeleton/clossys/.state/workspace.json. */
|
|
28
37
|
export interface HubDocument {
|
|
29
38
|
readonly schemaVersion: 1;
|
|
30
39
|
readonly kind: "account-hub";
|
|
31
40
|
readonly owner: string;
|
|
32
41
|
readonly repository: string;
|
|
42
|
+
/** Declares an existing external repository inventory as the source of truth (#1216). Hand-edited by the client; launcher never writes this field. */
|
|
43
|
+
readonly externalInventory?: ExternalInventoryDeclaration;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Where the hub marker was found relative to the `.clossys/` -> `clossys/.state/`
|
|
48
|
+
* migration (#1171): `clean` — only the current path. `legacy` — only the old
|
|
49
|
+
* `.clossys/` path; resume migrates it. `indeterminate` — both paths carry a
|
|
50
|
+
* parseable marker; launcher never merges them silently and refuses instead.
|
|
51
|
+
*/
|
|
52
|
+
export type HubMigrationState = "clean" | "legacy" | "indeterminate";
|
|
53
|
+
|
|
54
|
+
/** One skill recorded in `clossys/.state/skills.json` (#1183). */
|
|
55
|
+
export interface SkillManifestEntry {
|
|
56
|
+
readonly name: string;
|
|
57
|
+
readonly source: "installed" | "catalogue";
|
|
58
|
+
readonly version?: string;
|
|
59
|
+
readonly sha256: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The `clossys/.state/skills.json` document itself. */
|
|
63
|
+
export interface SkillManifestDocument {
|
|
64
|
+
readonly schemaVersion: 1;
|
|
65
|
+
readonly generatedAt: string;
|
|
66
|
+
readonly skills: readonly SkillManifestEntry[];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Read-only freshness summary derived from the skills manifest for the health report. */
|
|
70
|
+
export interface SkillsManifestSummary {
|
|
71
|
+
readonly status: "present" | "missing";
|
|
72
|
+
readonly total: number;
|
|
73
|
+
readonly stale: number;
|
|
74
|
+
readonly retired: number;
|
|
33
75
|
}
|
|
34
76
|
|
|
35
77
|
export interface InventoryObservation {
|
|
@@ -80,6 +122,31 @@ export interface HubHealthReport {
|
|
|
80
122
|
readonly extraClossys: readonly string[];
|
|
81
123
|
readonly pinFindings: readonly PinFinding[];
|
|
82
124
|
readonly degraded: boolean;
|
|
125
|
+
/** Coding-agent hosts this apply found already linked for skill discovery here, recorded before compose ran (#1180). Always present after apply. */
|
|
126
|
+
readonly linkedHosts?: readonly DiscoveredHost[];
|
|
127
|
+
/** Present only when the hub marker declares an external inventory (#1216) and there is something to say about it (i.e. not "no-external-source"). */
|
|
128
|
+
readonly inventoryDrift?: InventoryDriftReport;
|
|
129
|
+
readonly skillComposition?: {
|
|
130
|
+
readonly composed: readonly string[];
|
|
131
|
+
readonly skipped: readonly { readonly packageDir: string; readonly note: string }[];
|
|
132
|
+
readonly rosterTargets?: readonly string[];
|
|
133
|
+
readonly rosterSkipped?: readonly { readonly inventoryId: string; readonly note: string }[];
|
|
134
|
+
readonly retired?: readonly string[];
|
|
135
|
+
};
|
|
136
|
+
/** Present only on the run that performed the `.clossys/` -> `clossys/.state/` migration. */
|
|
137
|
+
readonly migration?: { readonly status: "migrated"; readonly from: string; readonly to: string };
|
|
138
|
+
/** Freshness summary derived from `clossys/.state/skills.json`, when present. */
|
|
139
|
+
readonly skillsManifest?: SkillsManifestSummary;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Optional paths for skill composition during apply. */
|
|
143
|
+
export interface ApplyWorkspaceOptions {
|
|
144
|
+
readonly skillCatalogueRoot?: string;
|
|
145
|
+
readonly launcherPackageRoot?: string;
|
|
146
|
+
/** Overrides where the packed conversation contract is read from (tests). */
|
|
147
|
+
readonly contractPath?: string;
|
|
148
|
+
/** Live registry `@clossys/launcher` version, used only to grade catalogue-sourced skill staleness. */
|
|
149
|
+
readonly liveLauncherVersion?: string;
|
|
83
150
|
}
|
|
84
151
|
|
|
85
152
|
export interface CwdObservation {
|
|
@@ -91,6 +158,8 @@ export interface CwdObservation {
|
|
|
91
158
|
readonly hub?: HubDocument;
|
|
92
159
|
readonly looksLikeFoundry: boolean;
|
|
93
160
|
readonly inventory?: InventoryObservation;
|
|
161
|
+
/** Present only when a hub marker was found; absent means neither path has one. */
|
|
162
|
+
readonly hubMigration?: HubMigrationState;
|
|
94
163
|
}
|
|
95
164
|
|
|
96
165
|
export interface WorkspaceObservation {
|
|
@@ -117,6 +186,10 @@ export interface WorkspacePlanResume {
|
|
|
117
186
|
readonly repository: string;
|
|
118
187
|
readonly directory: string;
|
|
119
188
|
readonly clone: boolean;
|
|
189
|
+
/** Live registry Advisor version, when observeWorkspace could read one. Used only to grade health. */
|
|
190
|
+
readonly advisorVersion?: string;
|
|
191
|
+
/** Set when the hub marker was found only at the legacy `.clossys/` path; apply migrates it. */
|
|
192
|
+
readonly migrateFrom?: "legacy";
|
|
120
193
|
}
|
|
121
194
|
|
|
122
195
|
export interface WorkspacePlanAdopt {
|
|
File without changes
|
|
File without changes
|