@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.
Files changed (114) hide show
  1. package/CHANGELOG.md +87 -0
  2. package/README.md +231 -19
  3. package/contracts/conversation-contract.md +40 -0
  4. package/dist/apply-plan-cli.d.ts +7 -0
  5. package/dist/apply-plan-cli.d.ts.map +1 -0
  6. package/dist/apply-plan-cli.js +96 -0
  7. package/dist/apply-plan-cli.js.map +1 -0
  8. package/dist/apply-plan.d.ts +79 -0
  9. package/dist/apply-plan.d.ts.map +1 -0
  10. package/dist/apply-plan.js +129 -0
  11. package/dist/apply-plan.js.map +1 -0
  12. package/dist/check-cli.d.ts.map +1 -1
  13. package/dist/check-cli.js +7 -0
  14. package/dist/check-cli.js.map +1 -1
  15. package/dist/cli.d.ts +2 -1
  16. package/dist/cli.d.ts.map +1 -1
  17. package/dist/cli.js +43 -14
  18. package/dist/cli.js.map +1 -1
  19. package/dist/contract.d.ts +28 -0
  20. package/dist/contract.d.ts.map +1 -0
  21. package/dist/contract.js +78 -0
  22. package/dist/contract.js.map +1 -0
  23. package/dist/core.d.ts +50 -8
  24. package/dist/core.d.ts.map +1 -1
  25. package/dist/core.js +458 -40
  26. package/dist/core.js.map +1 -1
  27. package/dist/doctor-cli.d.ts +4 -0
  28. package/dist/doctor-cli.d.ts.map +1 -0
  29. package/dist/doctor-cli.js +32 -0
  30. package/dist/doctor-cli.js.map +1 -0
  31. package/dist/doctor.d.ts +28 -0
  32. package/dist/doctor.d.ts.map +1 -0
  33. package/dist/doctor.js +68 -0
  34. package/dist/doctor.js.map +1 -0
  35. package/dist/host.d.ts.map +1 -1
  36. package/dist/host.js +19 -1
  37. package/dist/host.js.map +1 -1
  38. package/dist/hosts.d.ts +14 -0
  39. package/dist/hosts.d.ts.map +1 -0
  40. package/dist/hosts.js +61 -0
  41. package/dist/hosts.js.map +1 -0
  42. package/dist/index.d.ts +15 -2
  43. package/dist/index.d.ts.map +1 -1
  44. package/dist/index.js +7 -1
  45. package/dist/index.js.map +1 -1
  46. package/dist/inventory-adoption.d.ts +20 -0
  47. package/dist/inventory-adoption.d.ts.map +1 -0
  48. package/dist/inventory-adoption.js +67 -0
  49. package/dist/inventory-adoption.js.map +1 -0
  50. package/dist/manifest.d.ts +20 -0
  51. package/dist/manifest.d.ts.map +1 -0
  52. package/dist/manifest.js +106 -0
  53. package/dist/manifest.js.map +1 -0
  54. package/dist/model-profile.d.ts +46 -0
  55. package/dist/model-profile.d.ts.map +1 -0
  56. package/dist/model-profile.js +98 -0
  57. package/dist/model-profile.js.map +1 -0
  58. package/dist/product-repository.d.ts +26 -0
  59. package/dist/product-repository.d.ts.map +1 -0
  60. package/dist/product-repository.js +49 -0
  61. package/dist/product-repository.js.map +1 -0
  62. package/dist/skills.d.ts +39 -0
  63. package/dist/skills.d.ts.map +1 -0
  64. package/dist/skills.js +197 -0
  65. package/dist/skills.js.map +1 -0
  66. package/dist/types.d.ts +78 -1
  67. package/dist/types.d.ts.map +1 -1
  68. package/model-profiles/claude-code.json +10 -0
  69. package/model-profiles/codex.json +10 -0
  70. package/model-profiles/cursor.json +10 -0
  71. package/package.json +11 -5
  72. package/skeleton/README.md +15 -20
  73. package/skeleton/package.json +1 -1
  74. package/skill/SKILL.md +53 -0
  75. package/skill-catalogue/advisor/SKILL.md +52 -0
  76. package/skill-catalogue/architect/SKILL.md +42 -0
  77. package/skill-catalogue/bouncer/SKILL.md +43 -0
  78. package/skill-catalogue/builder/SKILL.md +42 -0
  79. package/skill-catalogue/butler/SKILL.md +43 -0
  80. package/skill-catalogue/controller/SKILL.md +50 -0
  81. package/skill-catalogue/customer/SKILL.md +92 -0
  82. package/skill-catalogue/designer/SKILL.md +66 -0
  83. package/skill-catalogue/giver/SKILL.md +43 -0
  84. package/skill-catalogue/influencer/SKILL.md +43 -0
  85. package/skill-catalogue/inspector/SKILL.md +42 -0
  86. package/skill-catalogue/integrator/SKILL.md +42 -0
  87. package/skill-catalogue/keeper/SKILL.md +43 -0
  88. package/skill-catalogue/launcher/SKILL.md +53 -0
  89. package/skill-catalogue/locksmith/SKILL.md +42 -0
  90. package/skill-catalogue/messenger/SKILL.md +43 -0
  91. package/skill-catalogue/observer/SKILL.md +42 -0
  92. package/skill-catalogue/publisher/SKILL.md +64 -0
  93. package/skill-catalogue/starter/SKILL.md +48 -0
  94. package/skill-catalogue/strategist/SKILL.md +85 -0
  95. package/skill-catalogue/writer/SKILL.md +56 -0
  96. package/src/apply-plan-cli.ts +94 -0
  97. package/src/apply-plan.ts +172 -0
  98. package/src/check-cli.ts +7 -0
  99. package/src/cli.ts +51 -13
  100. package/src/contract.ts +81 -0
  101. package/src/core.ts +555 -38
  102. package/src/doctor-cli.ts +33 -0
  103. package/src/doctor.ts +145 -0
  104. package/src/host.ts +17 -1
  105. package/src/hosts.ts +79 -0
  106. package/src/index.ts +36 -0
  107. package/src/inventory-adoption.ts +85 -0
  108. package/src/manifest.ts +103 -0
  109. package/src/model-profile.ts +148 -0
  110. package/src/product-repository.ts +73 -0
  111. package/src/skills.ts +225 -0
  112. package/src/types.ts +74 -1
  113. /package/skeleton/{.clossys → clossys/.state}/inventory.json +0 -0
  114. /package/skeleton/{.clossys → clossys/.state}/workspace.json +0 -0
@@ -0,0 +1,33 @@
1
+ #!/usr/bin/env node
2
+ import { isDirectInvocation } from "./cli.js";
3
+ import { createNodeHost } from "./host.js";
4
+ import { renderDoctorReport, runDoctorChecks } from "./doctor.js";
5
+
6
+ export const DOCTOR_USAGE = `Usage: launcher-doctor
7
+
8
+ Read-only. Checks the prerequisites a client needs before the hub exists:
9
+ git, the GitHub command-line tool, whether you are signed in, Node.js, and
10
+ npm. Reports the first thing that is missing, in plain language, with the
11
+ next action to take -- never a dump of everything at once.
12
+
13
+ Exit codes: 0 = ready, 2 = at least one prerequisite is missing.`;
14
+
15
+ export function main(argv: readonly string[]): number {
16
+ if (argv.length === 1 && (argv[0] === "--help" || argv[0] === "-h")) {
17
+ console.log(DOCTOR_USAGE);
18
+ return 0;
19
+ }
20
+ if (argv.length !== 0) {
21
+ console.error("launcher-doctor: takes no arguments");
22
+ return 2;
23
+ }
24
+ const host = createNodeHost();
25
+ const report = runDoctorChecks(host);
26
+ console.log(renderDoctorReport(report));
27
+ return report.allSatisfied ? 0 : 2;
28
+ }
29
+
30
+ function run(): void {
31
+ process.exitCode = main(process.argv.slice(2));
32
+ }
33
+ if (isDirectInvocation(import.meta.url, process.argv[1])) run();
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
@@ -1,5 +1,6 @@
1
1
  import { spawnSync } from "node:child_process";
2
- import { existsSync, mkdirSync, readFileSync, readdirSync, readSync, statSync, writeFileSync } from "node:fs";
2
+ import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
3
+ import { dirname } from "node:path";
3
4
  import type { CommandResult, WorkspaceHost } from "./types.js";
4
5
 
5
6
  function run(command: string, args: readonly string[], options?: { cwd?: string }): CommandResult {
@@ -43,6 +44,13 @@ export function createNodeHost(cwd = process.cwd(), env: NodeJS.ProcessEnv = pro
43
44
  now: () => new Date().toISOString(),
44
45
  exists: (path) => existsSync(path),
45
46
  isDirectory: (path) => existsSync(path) && statSync(path).isDirectory(),
47
+ isSymlink: (path) => {
48
+ try {
49
+ return lstatSync(path).isSymbolicLink();
50
+ } catch {
51
+ return false;
52
+ }
53
+ },
46
54
  readText: (path) => {
47
55
  try {
48
56
  return readFileSync(path, "utf8");
@@ -56,6 +64,14 @@ export function createNodeHost(cwd = process.cwd(), env: NodeJS.ProcessEnv = pro
56
64
  mkdirp: (path) => {
57
65
  mkdirSync(path, { recursive: true });
58
66
  },
67
+ symlink: (relativeTarget, linkPath) => {
68
+ mkdirSync(dirname(linkPath), { recursive: true });
69
+ if (existsSync(linkPath)) rmSync(linkPath, { recursive: true, force: true });
70
+ symlinkSync(relativeTarget, linkPath, "dir");
71
+ },
72
+ remove: (path) => {
73
+ rmSync(path, { recursive: true, force: true });
74
+ },
59
75
  readDir: (path) => readdirSync(path),
60
76
  run,
61
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,29 +2,65 @@
2
2
  export {
3
3
  applyWorkspacePlan,
4
4
  checkInventoryEntries,
5
+ cloneMissingInventoryRepositories,
5
6
  formatHubHealth,
6
7
  hasAdvisorPin,
7
8
  inspectInventory,
8
9
  isHubDocument,
10
+ launcherPackageRootFromModule,
9
11
  observeWorkspace,
10
12
  parseGitHubRemote,
11
13
  planWorkspace,
14
+ readInventoryRepositories,
15
+ readLiveLauncherVersion,
12
16
  reportHubHealth,
17
+ CLOSSYS_DIR_REL,
18
+ CLOSSYS_README_REL,
13
19
  DEFAULT_REPOSITORY_NAME,
20
+ LEGACY_STATE_DIR_REL,
21
+ LEGACY_WORKSPACE_INVENTORY_REL,
22
+ LEGACY_WORKSPACE_MARKER_REL,
23
+ STATE_DIR_REL,
14
24
  WORKSPACE_INVENTORY_REL,
15
25
  WORKSPACE_MARKER_REL,
16
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";
17
39
  export type {
40
+ BudgetPreference,
41
+ HostModelProfile,
42
+ HostTierMapping,
43
+ ModelResolution,
44
+ PreferencesDocument,
45
+ ReasoningTier,
46
+ SupportedHost,
47
+ } from "./model-profile.js";
48
+ export type {
49
+ ApplyWorkspaceOptions,
18
50
  CommandResult,
19
51
  CwdObservation,
20
52
  DependencyBucket,
21
53
  HubDocument,
22
54
  HubHealthReport,
55
+ HubMigrationState,
23
56
  InventoryObservation,
24
57
  InventoryValidationEntry,
25
58
  InventoryValidationReport,
26
59
  PinFinding,
27
60
  PinGrade,
61
+ SkillManifestDocument,
62
+ SkillManifestEntry,
63
+ SkillsManifestSummary,
28
64
  WorkspaceApplyResult,
29
65
  WorkspaceDecision,
30
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
+ }