@clossys/launcher 0.1.5 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/README.md +213 -20
  2. package/contracts/conversation-contract.md +41 -0
  3. package/dist/apply-plan-cli.d.ts +7 -0
  4. package/dist/apply-plan-cli.d.ts.map +1 -0
  5. package/dist/apply-plan-cli.js +96 -0
  6. package/dist/apply-plan-cli.js.map +1 -0
  7. package/dist/apply-plan.d.ts +79 -0
  8. package/dist/apply-plan.d.ts.map +1 -0
  9. package/dist/apply-plan.js +129 -0
  10. package/dist/apply-plan.js.map +1 -0
  11. package/dist/check-cli.d.ts.map +1 -1
  12. package/dist/check-cli.js +3 -0
  13. package/dist/check-cli.js.map +1 -1
  14. package/dist/cli.d.ts +2 -1
  15. package/dist/cli.d.ts.map +1 -1
  16. package/dist/cli.js +37 -11
  17. package/dist/cli.js.map +1 -1
  18. package/dist/contract.d.ts +28 -0
  19. package/dist/contract.d.ts.map +1 -0
  20. package/dist/contract.js +78 -0
  21. package/dist/contract.js.map +1 -0
  22. package/dist/core.d.ts +38 -6
  23. package/dist/core.d.ts.map +1 -1
  24. package/dist/core.js +289 -42
  25. package/dist/core.js.map +1 -1
  26. package/dist/doctor-cli.d.ts +4 -0
  27. package/dist/doctor-cli.d.ts.map +1 -0
  28. package/dist/doctor-cli.js +32 -0
  29. package/dist/doctor-cli.js.map +1 -0
  30. package/dist/doctor.d.ts +28 -0
  31. package/dist/doctor.d.ts.map +1 -0
  32. package/dist/doctor.js +68 -0
  33. package/dist/doctor.js.map +1 -0
  34. package/dist/host.d.ts.map +1 -1
  35. package/dist/host.js +3 -0
  36. package/dist/host.js.map +1 -1
  37. package/dist/hosts.d.ts +14 -0
  38. package/dist/hosts.d.ts.map +1 -0
  39. package/dist/hosts.js +61 -0
  40. package/dist/hosts.js.map +1 -0
  41. package/dist/index.d.ts +15 -2
  42. package/dist/index.d.ts.map +1 -1
  43. package/dist/index.js +7 -1
  44. package/dist/index.js.map +1 -1
  45. package/dist/inventory-adoption.d.ts +20 -0
  46. package/dist/inventory-adoption.d.ts.map +1 -0
  47. package/dist/inventory-adoption.js +67 -0
  48. package/dist/inventory-adoption.js.map +1 -0
  49. package/dist/manifest.d.ts +20 -0
  50. package/dist/manifest.d.ts.map +1 -0
  51. package/dist/manifest.js +106 -0
  52. package/dist/manifest.js.map +1 -0
  53. package/dist/model-profile.d.ts +46 -0
  54. package/dist/model-profile.d.ts.map +1 -0
  55. package/dist/model-profile.js +98 -0
  56. package/dist/model-profile.js.map +1 -0
  57. package/dist/product-repository.d.ts +26 -0
  58. package/dist/product-repository.d.ts.map +1 -0
  59. package/dist/product-repository.js +49 -0
  60. package/dist/product-repository.js.map +1 -0
  61. package/dist/skills.d.ts +20 -1
  62. package/dist/skills.d.ts.map +1 -1
  63. package/dist/skills.js +98 -7
  64. package/dist/skills.js.map +1 -1
  65. package/dist/types.d.ts +57 -1
  66. package/dist/types.d.ts.map +1 -1
  67. package/model-profiles/claude-code.json +10 -0
  68. package/model-profiles/codex.json +10 -0
  69. package/model-profiles/cursor.json +10 -0
  70. package/package.json +9 -5
  71. package/skeleton/README.md +5 -0
  72. package/skeleton/package.json +1 -1
  73. package/skill/SKILL.md +41 -0
  74. package/skill-catalogue/advisor/SKILL.md +46 -8
  75. package/skill-catalogue/architect/SKILL.md +0 -11
  76. package/skill-catalogue/bouncer/SKILL.md +0 -11
  77. package/skill-catalogue/builder/SKILL.md +0 -11
  78. package/skill-catalogue/butler/SKILL.md +0 -11
  79. package/skill-catalogue/controller/SKILL.md +6 -9
  80. package/skill-catalogue/customer/SKILL.md +83 -0
  81. package/skill-catalogue/designer/SKILL.md +18 -9
  82. package/skill-catalogue/giver/SKILL.md +0 -11
  83. package/skill-catalogue/influencer/SKILL.md +0 -11
  84. package/skill-catalogue/inspector/SKILL.md +1 -12
  85. package/skill-catalogue/integrator/SKILL.md +0 -11
  86. package/skill-catalogue/keeper/SKILL.md +0 -11
  87. package/skill-catalogue/launcher/SKILL.md +0 -12
  88. package/skill-catalogue/locksmith/SKILL.md +0 -11
  89. package/skill-catalogue/messenger/SKILL.md +0 -11
  90. package/skill-catalogue/observer/SKILL.md +0 -11
  91. package/skill-catalogue/publisher/SKILL.md +18 -10
  92. package/skill-catalogue/starter/SKILL.md +0 -11
  93. package/skill-catalogue/strategist/SKILL.md +35 -12
  94. package/skill-catalogue/writer/SKILL.md +7 -9
  95. package/src/apply-plan-cli.ts +94 -0
  96. package/src/apply-plan.ts +172 -0
  97. package/src/check-cli.ts +3 -0
  98. package/src/cli.ts +45 -10
  99. package/src/contract.ts +81 -0
  100. package/src/core.ts +337 -37
  101. package/src/doctor-cli.ts +33 -0
  102. package/src/doctor.ts +145 -0
  103. package/src/host.ts +3 -0
  104. package/src/hosts.ts +79 -0
  105. package/src/index.ts +33 -0
  106. package/src/inventory-adoption.ts +85 -0
  107. package/src/manifest.ts +103 -0
  108. package/src/model-profile.ts +148 -0
  109. package/src/product-repository.ts +73 -0
  110. package/src/skills.ts +113 -8
  111. package/src/types.ts +58 -1
  112. package/CHANGELOG.md +0 -64
  113. /package/skeleton/{.clossys → clossys/.state}/inventory.json +0 -0
  114. /package/skeleton/{.clossys → clossys/.state}/workspace.json +0 -0
package/src/doctor.ts ADDED
@@ -0,0 +1,145 @@
1
+ // launcher doctor (#1220) -- read-only. Names each missing prerequisite in
2
+ // plain language, one at a time, in the order a non-technical client
3
+ // should fix them: git -> the GitHub command-line tool -> being signed in
4
+ // -> Node.js -> npm. A missing coding agent is advisory only -- Launcher
5
+ // cannot detect every host, and it is a one-time choice the client makes,
6
+ // not a step to fix in sequence.
7
+
8
+ import type { CommandResult } from "./types.js";
9
+
10
+ export interface DoctorCheckHost {
11
+ run(command: string, args: readonly string[]): CommandResult;
12
+ }
13
+
14
+ export type DoctorStepId = "git" | "gh-cli" | "gh-auth" | "node" | "npm" | "coding-agent";
15
+
16
+ export interface DoctorStepResult {
17
+ readonly id: DoctorStepId;
18
+ readonly label: string;
19
+ readonly satisfied: boolean;
20
+ /** Plain-language description of what is missing, present only when satisfied is false. */
21
+ readonly problem?: string;
22
+ /** The single next action, present only when satisfied is false. */
23
+ readonly nextAction?: string;
24
+ /** True for steps that inform but never block (e.g. coding-agent discovery). */
25
+ readonly advisory: boolean;
26
+ }
27
+
28
+ export interface DoctorReport {
29
+ readonly schemaVersion: 1;
30
+ readonly steps: readonly DoctorStepResult[];
31
+ /** The first unsatisfied non-advisory step, in order -- what the client should fix right now. */
32
+ readonly nextToFix?: DoctorStepResult;
33
+ readonly allSatisfied: boolean;
34
+ }
35
+
36
+ function ok(id: DoctorStepId, label: string, advisory = false): DoctorStepResult {
37
+ return { id, label, satisfied: true, advisory };
38
+ }
39
+
40
+ function missing(id: DoctorStepId, label: string, problem: string, nextAction: string, advisory = false): DoctorStepResult {
41
+ return { id, label, satisfied: false, problem, nextAction, advisory };
42
+ }
43
+
44
+ function commandSucceeds(host: DoctorCheckHost, command: string, args: readonly string[]): boolean {
45
+ try {
46
+ return host.run(command, args).status === 0;
47
+ } catch {
48
+ return false;
49
+ }
50
+ }
51
+
52
+ /** Runs every doctor check in the fix-in-this-order sequence. Never mutates anything. */
53
+ export function runDoctorChecks(host: DoctorCheckHost): DoctorReport {
54
+ const steps: DoctorStepResult[] = [];
55
+
56
+ const hasGit = commandSucceeds(host, "git", ["--version"]);
57
+ steps.push(
58
+ hasGit
59
+ ? ok("git", "Git is installed")
60
+ : missing(
61
+ "git",
62
+ "Git is installed",
63
+ "Git is not installed on this computer. Launcher and your coding agent both need it to save and share work.",
64
+ "Install Git from https://git-scm.com/downloads, then run this command again.",
65
+ ),
66
+ );
67
+
68
+ const hasGh = commandSucceeds(host, "gh", ["--version"]);
69
+ steps.push(
70
+ hasGh
71
+ ? ok("gh-cli", "The GitHub command-line tool (gh) is installed")
72
+ : missing(
73
+ "gh-cli",
74
+ "The GitHub command-line tool (gh) is installed",
75
+ "The GitHub command-line tool is not installed. Launcher uses it to create and find your GitHub repositories.",
76
+ "Install it from https://cli.github.com, then run this command again.",
77
+ ),
78
+ );
79
+
80
+ const ghAuthed = hasGh && commandSucceeds(host, "gh", ["auth", "status"]);
81
+ steps.push(
82
+ !hasGh
83
+ ? missing(
84
+ "gh-auth",
85
+ "You are signed in to GitHub",
86
+ "This cannot be checked yet because the GitHub command-line tool is not installed.",
87
+ "Install the GitHub command-line tool first (see above), then run this command again.",
88
+ )
89
+ : ghAuthed
90
+ ? ok("gh-auth", "You are signed in to GitHub")
91
+ : missing(
92
+ "gh-auth",
93
+ "You are signed in to GitHub",
94
+ "You are not signed in to GitHub yet.",
95
+ "Run `gh auth login` and follow the prompts, then run this command again.",
96
+ ),
97
+ );
98
+
99
+ const hasNode = commandSucceeds(host, "node", ["--version"]);
100
+ steps.push(
101
+ hasNode
102
+ ? ok("node", "Node.js is installed")
103
+ : missing(
104
+ "node",
105
+ "Node.js is installed",
106
+ "Node.js is not installed. It is what runs the team's tools on this computer.",
107
+ "Install the current LTS release from https://nodejs.org, then run this command again.",
108
+ ),
109
+ );
110
+
111
+ const hasNpm = commandSucceeds(host, "npm", ["--version"]);
112
+ steps.push(
113
+ hasNpm
114
+ ? ok("npm", "npm is installed")
115
+ : missing(
116
+ "npm",
117
+ "npm is installed",
118
+ "npm is not installed. It comes with Node.js, so this is usually fixed by installing or reinstalling Node.js.",
119
+ "Install Node.js from https://nodejs.org (npm is included), then run this command again.",
120
+ ),
121
+ );
122
+
123
+ // Advisory only: never blocks doctor's overall verdict. A missing coding
124
+ // agent is not a step to fix in a particular order -- it is a one-time
125
+ // choice the client makes, and Launcher cannot detect every host.
126
+ steps.push(ok("coding-agent", "A coding agent is available to open this hub", true));
127
+
128
+ const firstUnsatisfied = steps.find((step) => !step.satisfied && !step.advisory);
129
+ return {
130
+ schemaVersion: 1,
131
+ steps,
132
+ ...(firstUnsatisfied === undefined ? {} : { nextToFix: firstUnsatisfied }),
133
+ allSatisfied: firstUnsatisfied === undefined,
134
+ };
135
+ }
136
+
137
+ /** Renders one step at a time, plain language, the way a non-technical client reads it. */
138
+ export function renderDoctorReport(report: DoctorReport): string {
139
+ if (report.allSatisfied) {
140
+ return "Everything doctor checks is ready. Open this folder in your coding agent and talk to @clossys-advisor to get started.";
141
+ }
142
+ const step = report.nextToFix;
143
+ if (step === undefined) return "Everything doctor checks is ready.";
144
+ return `${step.label}: not yet.\n${step.problem}\n\nNext: ${step.nextAction}`;
145
+ }
package/src/host.ts CHANGED
@@ -69,6 +69,9 @@ export function createNodeHost(cwd = process.cwd(), env: NodeJS.ProcessEnv = pro
69
69
  if (existsSync(linkPath)) rmSync(linkPath, { recursive: true, force: true });
70
70
  symlinkSync(relativeTarget, linkPath, "dir");
71
71
  },
72
+ remove: (path) => {
73
+ rmSync(path, { recursive: true, force: true });
74
+ },
72
75
  readDir: (path) => readdirSync(path),
73
76
  run,
74
77
  prompt,
package/src/hosts.ts ADDED
@@ -0,0 +1,79 @@
1
+ // Records which coding-agent host(s) this run linked skill discovery for
2
+ // (#1180, Launcher side), so a consumer (Advisor's next-action phrasing)
3
+ // can name the client's actual tool instead of guessing.
4
+ //
5
+ // Codex discovery verified 2026-09-22 (developers.openai.com/codex/skills,
6
+ // developers.openai.com/codex/concepts/customization): Codex reads
7
+ // repository skills directly from `.agents/skills` -- the SAME directory
8
+ // skills.ts's AGENTS_SKILLS_REL already writes the real composed skill to.
9
+ // It needs no separate discovery symlink the way Claude Code (.claude/skills)
10
+ // and Cursor (.cursor/skills) do, so "codex" is detected by the presence of
11
+ // `.agents/skills` itself, not a host-specific prefix.
12
+
13
+ import { join } from "node:path";
14
+ import type { WorkspaceHost } from "./types.js";
15
+
16
+ export type DiscoveredHost = "claude-code" | "cursor" | "codex";
17
+
18
+ export interface HostRecord {
19
+ readonly schemaVersion: 1;
20
+ /** Hosts whose discovery this directory currently carries. */
21
+ readonly linkedHosts: readonly DiscoveredHost[];
22
+ readonly recordedAt: string;
23
+ }
24
+
25
+ export const HOSTS_REL = join("clossys", ".state", "hosts.json");
26
+
27
+ const AGENTS_SKILLS_REL = join(".agents", "skills");
28
+
29
+ // Claude Code and Cursor need their own discovery symlink; Codex reads
30
+ // .agents/skills directly (see module header) and is detected separately.
31
+ const SYMLINK_DISCOVERY_PATHS: ReadonlyArray<{ host: DiscoveredHost; prefix: string }> = [
32
+ { host: "claude-code", prefix: ".claude/skills" },
33
+ { host: "cursor", prefix: ".cursor/skills" },
34
+ ];
35
+
36
+ /** Read-only: which hosts can actually discover skills in this directory right now. */
37
+ export function detectLinkedHosts(host: WorkspaceHost, directory: string): readonly DiscoveredHost[] {
38
+ const found: DiscoveredHost[] = [];
39
+ for (const { host: id, prefix } of SYMLINK_DISCOVERY_PATHS) {
40
+ if (host.isDirectory(join(directory, prefix)) || host.isSymlink(join(directory, prefix))) {
41
+ found.push(id);
42
+ }
43
+ }
44
+ if (host.isDirectory(join(directory, AGENTS_SKILLS_REL))) {
45
+ found.push("codex");
46
+ }
47
+ return found;
48
+ }
49
+
50
+ export function serializeHostRecord(record: HostRecord): string {
51
+ return `${JSON.stringify(record, null, 2)}\n`;
52
+ }
53
+
54
+ export function parseHostRecord(raw: string | null): HostRecord | undefined {
55
+ if (raw === null) return undefined;
56
+ try {
57
+ const parsed: unknown = JSON.parse(raw);
58
+ if (
59
+ typeof parsed === "object" &&
60
+ parsed !== null &&
61
+ !Array.isArray(parsed) &&
62
+ (parsed as Record<string, unknown>).schemaVersion === 1 &&
63
+ Array.isArray((parsed as Record<string, unknown>).linkedHosts)
64
+ ) {
65
+ const record = parsed as { linkedHosts: unknown[]; recordedAt: unknown };
66
+ const linkedHosts = record.linkedHosts.filter(
67
+ (item): item is DiscoveredHost => item === "claude-code" || item === "cursor" || item === "codex",
68
+ );
69
+ return {
70
+ schemaVersion: 1,
71
+ linkedHosts,
72
+ recordedAt: typeof record.recordedAt === "string" ? record.recordedAt : "",
73
+ };
74
+ }
75
+ } catch {
76
+ /* falls through */
77
+ }
78
+ return undefined;
79
+ }
package/src/index.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  export {
3
3
  applyWorkspacePlan,
4
4
  checkInventoryEntries,
5
+ cloneMissingInventoryRepositories,
5
6
  formatHubHealth,
6
7
  hasAdvisorPin,
7
8
  inspectInventory,
@@ -11,11 +12,39 @@ export {
11
12
  parseGitHubRemote,
12
13
  planWorkspace,
13
14
  readInventoryRepositories,
15
+ readLiveLauncherVersion,
14
16
  reportHubHealth,
17
+ CLOSSYS_DIR_REL,
18
+ CLOSSYS_README_REL,
15
19
  DEFAULT_REPOSITORY_NAME,
20
+ LEGACY_STATE_DIR_REL,
21
+ LEGACY_WORKSPACE_INVENTORY_REL,
22
+ LEGACY_WORKSPACE_MARKER_REL,
23
+ STATE_DIR_REL,
16
24
  WORKSPACE_INVENTORY_REL,
17
25
  WORKSPACE_MARKER_REL,
18
26
  } from "./core.js";
27
+ export type { CloneMissingOutcome } from "./core.js";
28
+ export { runDoctorChecks, renderDoctorReport } from "./doctor.js";
29
+ export { applyEngagementBrief, isPlanApproved, validateAdvisorPlan, validateEngagementBrief } from "./apply-plan.js";
30
+ export type { AdvisorPlan, ApplyBriefResult, BlockerKind, EngagementBrief, EngagementBriefRole, PlanBlocker, PlanDecision, ValidationResult } from "./apply-plan.js";
31
+ export type { DoctorCheckHost, DoctorReport, DoctorStepId, DoctorStepResult } from "./doctor.js";
32
+ export { checkCloudSessionBootstrap } from "./product-repository.js";
33
+ export type { CloudBootstrapCheck, CloudBootstrapReport } from "./product-repository.js";
34
+ export { reportInventoryDrift } from "./inventory-adoption.js";
35
+ export type { ExternalInventoryDeclaration, InventoryDriftReport } from "./inventory-adoption.js";
36
+ export { detectLinkedHosts, parseHostRecord, serializeHostRecord, HOSTS_REL } from "./hosts.js";
37
+ export type { DiscoveredHost, HostRecord } from "./hosts.js";
38
+ export { parsePreferences, readHostModelProfile, resolveModelForTier } from "./model-profile.js";
39
+ export type {
40
+ BudgetPreference,
41
+ HostModelProfile,
42
+ HostTierMapping,
43
+ ModelResolution,
44
+ PreferencesDocument,
45
+ ReasoningTier,
46
+ SupportedHost,
47
+ } from "./model-profile.js";
19
48
  export type {
20
49
  ApplyWorkspaceOptions,
21
50
  CommandResult,
@@ -23,11 +52,15 @@ export type {
23
52
  DependencyBucket,
24
53
  HubDocument,
25
54
  HubHealthReport,
55
+ HubMigrationState,
26
56
  InventoryObservation,
27
57
  InventoryValidationEntry,
28
58
  InventoryValidationReport,
29
59
  PinFinding,
30
60
  PinGrade,
61
+ SkillManifestDocument,
62
+ SkillManifestEntry,
63
+ SkillsManifestSummary,
31
64
  WorkspaceApplyResult,
32
65
  WorkspaceDecision,
33
66
  WorkspaceHost,
@@ -0,0 +1,85 @@
1
+ // Adopt an existing repository inventory instead of creating a second one
2
+ // (#1216). An account that already keeps a repository inventory in its own
3
+ // control plane becomes the source of truth when the hub marker declares
4
+ // it. Launcher writes only what that inventory lacks (a later apply step,
5
+ // not this module) and reports drift instead of silently merging.
6
+
7
+ import type { WorkspaceHost } from "./types.js";
8
+
9
+ export interface ExternalInventoryDeclaration {
10
+ readonly path: string;
11
+ readonly shape: "foundry" | "custom";
12
+ }
13
+
14
+ export interface InventoryDriftReport {
15
+ readonly status: "no-external-source" | "reconciled" | "indeterminate";
16
+ readonly externalOnly: readonly string[];
17
+ readonly launcherOnly: readonly string[];
18
+ readonly agreeing: readonly string[];
19
+ readonly note?: string;
20
+ }
21
+
22
+ function isRecord(value: unknown): value is Record<string, unknown> {
23
+ return typeof value === "object" && value !== null && !Array.isArray(value);
24
+ }
25
+
26
+ /** Reads a foundry-shaped inventory document's repository ids. Malformed or missing is null, never []. */
27
+ function readForeignIds(host: WorkspaceHost, path: string): readonly string[] | null {
28
+ const raw = host.readText(path);
29
+ if (raw === null) return null;
30
+ let parsed: unknown;
31
+ try {
32
+ parsed = JSON.parse(raw);
33
+ } catch {
34
+ return null;
35
+ }
36
+ if (!isRecord(parsed) || parsed.schemaVersion !== 1 || !Array.isArray(parsed.repositories)) return null;
37
+ const ids: string[] = [];
38
+ for (const entry of parsed.repositories) {
39
+ if (isRecord(entry) && typeof entry.id === "string" && entry.id.trim() !== "") ids.push(entry.id);
40
+ }
41
+ return ids;
42
+ }
43
+
44
+ /**
45
+ * Compares the declared external inventory against the launcher-written
46
+ * inventory at `directory/launcherInventoryRelPath`. Read-only -- callers
47
+ * decide whether and how to write the reconciled set, as an explicit,
48
+ * approved apply step (same #1045 pattern as clone-on-approval).
49
+ */
50
+ export function reportInventoryDrift(
51
+ host: WorkspaceHost,
52
+ directory: string,
53
+ declaration: ExternalInventoryDeclaration | undefined,
54
+ launcherInventoryRelPath: string,
55
+ ): InventoryDriftReport {
56
+ if (declaration === undefined) {
57
+ return { status: "no-external-source", externalOnly: [], launcherOnly: [], agreeing: [] };
58
+ }
59
+ if (declaration.shape === "custom") {
60
+ return {
61
+ status: "indeterminate",
62
+ externalOnly: [],
63
+ launcherOnly: [],
64
+ agreeing: [],
65
+ note: `externalInventory at ${declaration.path} declares shape "custom"; launcher has no mapping for a non-foundry inventory shape yet and will not guess one. Reconcile by hand or file the mapping gap.`,
66
+ };
67
+ }
68
+ const externalIds = readForeignIds(host, declaration.path);
69
+ if (externalIds === null) {
70
+ return {
71
+ status: "indeterminate",
72
+ externalOnly: [],
73
+ launcherOnly: [],
74
+ agreeing: [],
75
+ note: `externalInventory at ${declaration.path} could not be read as a populated schemaVersion:1 inventory document.`,
76
+ };
77
+ }
78
+ const launcherIds = readForeignIds(host, `${directory}/${launcherInventoryRelPath}`) ?? [];
79
+ const externalSet = new Set(externalIds);
80
+ const launcherSet = new Set(launcherIds);
81
+ const externalOnly = externalIds.filter((id) => !launcherSet.has(id));
82
+ const launcherOnly = launcherIds.filter((id) => !externalSet.has(id));
83
+ const agreeing = externalIds.filter((id) => launcherSet.has(id));
84
+ return { status: "reconciled", externalOnly, launcherOnly, agreeing };
85
+ }
@@ -0,0 +1,103 @@
1
+ import { createHash } from "node:crypto";
2
+ import { join } from "node:path";
3
+ import type { SkillManifestDocument, SkillManifestEntry, SkillsManifestSummary, WorkspaceHost } from "./types.js";
4
+
5
+ function isRecord(value: unknown): value is Record<string, unknown> {
6
+ return typeof value === "object" && value !== null && !Array.isArray(value);
7
+ }
8
+
9
+ /** sha256 hex digest of a skill's final composed content (post contract injection). */
10
+ export function sha256Hex(content: string): string {
11
+ return createHash("sha256").update(content, "utf8").digest("hex");
12
+ }
13
+
14
+ /** Tiny semver-ish compare, duplicated from core.ts to keep this module dependency-free within the package. */
15
+ function compareVersions(a: string, b: string): -1 | 0 | 1 | null {
16
+ const parse = (version: string): readonly number[] | null => {
17
+ const match = version.trim().match(/^v?(\d+)\.(\d+)\.(\d+)$/);
18
+ return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null;
19
+ };
20
+ const left = parse(a);
21
+ const right = parse(b);
22
+ if (!left || !right) return null;
23
+ for (let index = 0; index < 3; index += 1) {
24
+ const l = left[index] ?? 0;
25
+ const r = right[index] ?? 0;
26
+ if (l < r) return -1;
27
+ if (l > r) return 1;
28
+ }
29
+ return 0;
30
+ }
31
+
32
+ /** Parses `clossys/.state/skills.json`. An unreadable or malformed document is treated as absent. */
33
+ export function parseSkillManifest(raw: string | null): SkillManifestDocument | undefined {
34
+ if (raw === null) return undefined;
35
+ let parsed: unknown;
36
+ try {
37
+ parsed = JSON.parse(raw);
38
+ } catch {
39
+ return undefined;
40
+ }
41
+ if (!isRecord(parsed) || parsed.schemaVersion !== 1 || !Array.isArray(parsed.skills)) return undefined;
42
+ const skills: SkillManifestEntry[] = [];
43
+ for (const entry of parsed.skills) {
44
+ if (!isRecord(entry)) continue;
45
+ const { name, source, sha256 } = entry;
46
+ if (typeof name !== "string" || name.trim() === "") continue;
47
+ if (source !== "installed" && source !== "catalogue") continue;
48
+ if (typeof sha256 !== "string" || sha256.trim() === "") continue;
49
+ skills.push({
50
+ name,
51
+ source,
52
+ sha256,
53
+ ...(typeof entry.version === "string" && entry.version.trim() !== "" ? { version: entry.version } : {}),
54
+ });
55
+ }
56
+ return {
57
+ schemaVersion: 1,
58
+ generatedAt: typeof parsed.generatedAt === "string" ? parsed.generatedAt : "",
59
+ skills,
60
+ };
61
+ }
62
+
63
+ export function serializeSkillManifest(document: SkillManifestDocument): string {
64
+ return `${JSON.stringify(document, null, 2)}\n`;
65
+ }
66
+
67
+ /**
68
+ * Freshness summary for the health report: "N skills out of date, M
69
+ * retired". `stale` counts catalogue-sourced entries whose recorded version
70
+ * is older than `liveLauncherVersion` (a client running an old launcher
71
+ * carries a catalogue only as fresh as that release, #1033) — unknown or
72
+ * unparseable versions are never counted stale. `retiredThisRun` is supplied
73
+ * by the composition step that just ran; a read-only inspection (no
74
+ * composition) reports 0 retired, which is correct — retirement is a
75
+ * this-run event, not a property of the manifest at rest.
76
+ */
77
+ export function summarizeSkillsManifest(
78
+ manifest: SkillManifestDocument | undefined,
79
+ liveLauncherVersion: string | undefined,
80
+ retiredThisRun: readonly string[],
81
+ ): SkillsManifestSummary {
82
+ if (manifest === undefined) {
83
+ return { status: "missing", total: 0, stale: 0, retired: retiredThisRun.length };
84
+ }
85
+ const stale = manifest.skills.filter((entry) => {
86
+ if (entry.source !== "catalogue" || liveLauncherVersion === undefined || entry.version === undefined) return false;
87
+ return compareVersions(entry.version, liveLauncherVersion) === -1;
88
+ }).length;
89
+ return { status: "present", total: manifest.skills.length, stale, retired: retiredThisRun.length };
90
+ }
91
+
92
+ /** Reads `installed` version from `<composeTargetDirectory>/node_modules/@clossys/<pkg>/package.json`. */
93
+ export function readInstalledVersion(host: WorkspaceHost, composeTargetDirectory: string, packageDir: string): string | undefined {
94
+ const raw = host.readText(join(composeTargetDirectory, "node_modules", "@clossys", packageDir, "package.json"));
95
+ if (raw === null) return undefined;
96
+ try {
97
+ const parsed: unknown = JSON.parse(raw);
98
+ if (isRecord(parsed) && typeof parsed.version === "string" && parsed.version.trim() !== "") return parsed.version;
99
+ } catch {
100
+ /* unreadable manifest; no version */
101
+ }
102
+ return undefined;
103
+ }
@@ -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
+ }