Browse Source

feat(compat): widen system profiles to all component kinds

SystemProfileSchema gains optional commands/tools/skills/plugins/config;
agents absorbs subagents (the canonical tree collapses the distinction).
resolveSystemProfile resolves every kind against the registry and fails
closed on any miss, with a dual agent/subagent lookup bridging the legacy
registry's category split until the emitter splits it back at emission.
darrenhinde 2 weeks ago
parent
commit
60a349818f

+ 157 - 0
packages/compatibility-layer/src/core/ProfileLoader.ts

@@ -29,6 +29,7 @@
 import { readFileSync, readdirSync, existsSync, statSync } from "node:fs";
 import { join } from "node:path";
 import { z } from "zod";
+import { ContextProfileSchema, SystemProfileSchema } from "../types.js";
 import { ReferenceResolver, type Reference, type Resolution } from "./ReferenceResolver.js";
 
 // ============================================================================
@@ -85,8 +86,28 @@ export interface ProfileDrift {
   onlyInRegistry: string[];
 }
 
+/**
+ * The result of resolving a system profile: deduplicated, locale-independently sorted ids.
+ * Every key is always present; a kind the profile does not name resolves to `[]`.
+ */
+export interface SystemProfileResolution {
+  agents: string[];
+  contexts: string[];
+  commands: string[];
+  tools: string[];
+  skills: string[];
+  plugins: string[];
+  config: string[];
+}
+
 const PROFILES_DIR = ".opencode/profiles";
 
+/** Canonical system profiles: `content/profiles/system/<id>.json`. */
+const SYSTEM_PROFILES_DIR = "content/profiles/system";
+
+/** Canonical context profiles: `content/profiles/context/<id>.json`. */
+const CONTEXT_PROFILES_DIR = "content/profiles/context";
+
 /** Locale-independent ordering. `localeCompare` is locale-dependent — never use it here. */
 function compare(a: string, b: string): number {
   return a < b ? -1 : a > b ? 1 : 0;
@@ -258,4 +279,140 @@ export class ProfileLoader {
       onlyInRegistry: registryComponents.filter((ref) => !onDisk.has(ref)).sort(compare),
     };
   }
+
+  // --------------------------------------------------------------------------
+  // Canonical profiles (content/profiles/**)
+  // --------------------------------------------------------------------------
+
+  /**
+   * Resolve a system profile to its full component closure.
+   *
+   * Unlike the legacy profile sources above, canonical profiles have exactly ONE source
+   * (`content/profiles/**`), so there is no union to referee: a profile is sound only when
+   * EVERY component it names — directly or via a context profile — resolves against the
+   * registry. Any miss fails the whole profile, closed.
+   *
+   * Output is deduplicated and locale-independently sorted, so authored order never leaks
+   * into the emitted install plan.
+   */
+  async resolveSystemProfile(id: string): Promise<SystemProfileResolution> {
+    const system = await this.loadProfile(SYSTEM_PROFILES_DIR, id, SystemProfileSchema, "system profile");
+
+    const contextIds: string[] = [];
+    for (const contextProfileId of system.contextProfiles) {
+      const contextProfile = await this.loadProfile(
+        CONTEXT_PROFILES_DIR,
+        contextProfileId,
+        ContextProfileSchema,
+        "context profile"
+      );
+      contextIds.push(...contextProfile.contexts);
+    }
+
+    // One table drives every component kind: field on the schema -> registry ref type.
+    // `agents` covers subagents too — the canonical tree collapses that distinction.
+    const KINDS: readonly (readonly [keyof Omit<SystemProfileResolution, "contexts">, string])[] = [
+      ["agents", "agent"],
+      ["commands", "command"],
+      ["tools", "tool"],
+      ["skills", "skill"],
+      ["plugins", "plugin"],
+      ["config", "config"],
+    ];
+
+    const references: Reference[] = contextIds.map((context) => ({
+      ref: `context:${context}`,
+      source: `${CONTEXT_PROFILES_DIR} contexts`,
+    }));
+
+    for (const [field, type] of KINDS) {
+      for (const component of system[field] ?? []) {
+        references.push(
+          field === "agents"
+            ? this.resolveAgentRef(component, `${SYSTEM_PROFILES_DIR}/${id}.json agents`)
+            : {
+                ref: `${type}:${component}`,
+                source: `${SYSTEM_PROFILES_DIR}/${id}.json ${field}`,
+              }
+        );
+      }
+    }
+
+    const failures = this.resolver.resolveMany(references).filter((resolution) => !resolution.ok);
+    if (failures.length > 0) {
+      throw new ProfileLoadError(
+        `System profile "${id}" cannot be installed:\n` +
+          failures.map((f) => `  ${f.ref} does not resolve: ${f.reason}`).join("\n"),
+        `${SYSTEM_PROFILES_DIR}/${id}.json`
+      );
+    }
+
+    return {
+      agents: [...new Set(system.agents)].sort(compare),
+      contexts: [...new Set(contextIds)].sort(compare),
+      commands: [...new Set(system.commands ?? [])].sort(compare),
+      tools: [...new Set(system.tools ?? [])].sort(compare),
+      skills: [...new Set(system.skills ?? [])].sort(compare),
+      plugins: [...new Set(system.plugins ?? [])].sort(compare),
+      config: [...new Set(system.config ?? [])].sort(compare),
+    };
+  }
+
+  /**
+   * Resolve one id from the `agents` field against BOTH registry categories.
+   *
+   * The canonical tree collapses subagents into agents (`content/agents/subagents/**` is
+   * still an agent file), so an authored id may legitimately live in either legacy
+   * category until the registry emitter splits them back out. Primary form is `agent:`;
+   * `subagent:` is the fallback. Both failing reports the primary with the fallback noted.
+   */
+  private resolveAgentRef(id: string, source: string): Resolution {
+    const asAgent = this.resolver.resolve(`agent:${id}`);
+    if (asAgent.ok) return { ref: `agent:${id}`, source, ...asAgent };
+
+    const asSubagent = this.resolver.resolve(`subagent:${id}`);
+    if (asSubagent.ok) return { ref: `subagent:${id}`, source, ...asSubagent };
+
+    return {
+      ref: `agent:${id}`,
+      source,
+      ...asAgent,
+      reason: `${asAgent.reason} (also tried as "subagent:${id}": ${asSubagent.reason})`,
+    };
+  }
+
+  /** Load, validate and return one canonical profile JSON document. */
+  private loadProfile<T>(
+    dir: string,
+    id: string,
+    schema: z.ZodType<T>,
+    kind: string
+  ): Promise<T> {
+    const relativePath = `${dir}/${id}.json`;
+    const absolute = join(this.root, relativePath);
+
+    if (!existsSync(absolute)) {
+      throw new ProfileLoadError(`${kind} "${id}" not found: ${relativePath}`, relativePath);
+    }
+
+    let raw: unknown;
+    try {
+      raw = JSON.parse(readFileSync(absolute, "utf-8"));
+    } catch (cause) {
+      throw new ProfileLoadError(`Failed to parse ${kind}: ${relativePath}`, relativePath, cause);
+    }
+
+    const parsed = schema.safeParse(raw);
+    if (!parsed.success) {
+      throw new ProfileLoadError(
+        `Invalid ${kind} ${relativePath}:\n${parsed.error.errors
+          .map((e) => `  - ${e.path.join(".")}: ${e.message}`)
+          .join("\n")}`,
+        relativePath,
+        parsed.error
+      );
+    }
+
+    return Promise.resolve(parsed.data);
+  }
 }

+ 62 - 0
packages/compatibility-layer/src/types.ts

@@ -551,6 +551,66 @@ export const CanonicalAgentSchema = AgentFrontmatterSchema.extend({
   permission: PermissionInputSchema.optional(),
 });
 
+// ============================================================================
+// Canonical Profile Schemas
+// ============================================================================
+
+/**
+ * A context profile: a named, reusable bundle of context ids, authored at
+ * `content/profiles/context/<id>.json`. System profiles compose these rather than
+ * listing contexts directly, so a team standard set is written once and shared.
+ *
+ * A context entry may be a registry wildcard (e.g. `core/*`) — a directory subscription
+ * that also installs future files added under that prefix. Wildcards are authored
+ * verbatim and expanded at emission/install time, never in the authored source.
+ *
+ * Strict: an unknown key is an error, never silently dropped.
+ */
+export const ContextProfileSchema = z
+  .object({
+    id: OacIdSchema,
+    contexts: z
+      .array(z.string().min(1))
+      .min(1, "a context profile that names no contexts installs nothing"),
+  })
+  .strict();
+
+/**
+ * A system profile: the smallest installable unit — a set of agents plus the context
+ * profiles that back them, authored at `content/profiles/system/<id>.json`.
+ *
+ * "Install `developer` and you get a working set" is a promise about the CLOSURE: every
+ * component must resolve, or the profile is broken no matter how deep the break hides.
+ * {@link import("./core/ProfileLoader.js").ProfileLoader.resolveSystemProfile} enforces that.
+ *
+ * `agents` names BOTH primary agents and subagents: the canonical tree collapses them —
+ * a subagent is just an agent file under `content/agents/subagents/**` — so the legacy
+ * `agent:`/`subagent:` distinction is an emission detail, not an authoring one.
+ *
+ * The remaining kinds are optional because most profiles name none of a given kind; a
+ * field left out parses to nothing and installs nothing. There is deliberately no
+ * `subagents` field and no wildcard expansion here: profiles name concrete ids, and the
+ * registry emitter, not the author, worries about categories.
+ *
+ * Strict: an unknown key is an error, never silently dropped.
+ */
+export const SystemProfileSchema = z
+  .object({
+    id: OacIdSchema,
+    agents: z
+      .array(z.string().min(1))
+      .min(1, "a system profile that names no agents installs nothing"),
+    contextProfiles: z
+      .array(z.string().min(1))
+      .min(1, "a system profile that names no context profiles installs nothing"),
+    commands: z.array(z.string().min(1)).optional(),
+    tools: z.array(z.string().min(1)).optional(),
+    skills: z.array(z.string().min(1)).optional(),
+    plugins: z.array(z.string().min(1)).optional(),
+    config: z.array(z.string().min(1)).optional(),
+  })
+  .strict();
+
 // ============================================================================
 // Agent Metadata Schema
 // ============================================================================
@@ -654,6 +714,8 @@ export type OacBlock = z.infer<typeof OacBlockSchema>;
 /** Authored `oac:` block, before defaults are applied. */
 export type OacBlockInput = z.input<typeof OacBlockSchema>;
 export type CanonicalAgent = z.infer<typeof CanonicalAgentSchema>;
+export type ContextProfile = z.infer<typeof ContextProfileSchema>;
+export type SystemProfile = z.infer<typeof SystemProfileSchema>;
 export type AgentMetadata = z.infer<typeof AgentMetadataSchema>;
 export type OpenAgent = z.infer<typeof OpenAgentSchema>;
 export type ToolConfig = z.infer<typeof ToolConfigSchema>;

+ 136 - 14
packages/compatibility-layer/tests/unit/build/system-profile-resolution.test.ts

@@ -21,6 +21,11 @@ interface Schema<T> {
 interface ResolvedSystemProfile {
   agents: string[];
   contexts: string[];
+  commands: string[];
+  tools: string[];
+  skills: string[];
+  plugins: string[];
+  config: string[];
 }
 
 interface ProfileLoaderApi {
@@ -72,6 +77,11 @@ function fixture(
     systemProfile?: unknown;
     agents?: string[];
     contexts?: string[];
+    commands?: string[];
+    tools?: string[];
+    skills?: string[];
+    plugins?: string[];
+    config?: string[];
   } = {}
 ): void {
   writeJson(root, "content/profiles/context/basic-context.json", options.contextProfile ?? {
@@ -83,19 +93,30 @@ function fixture(
     agents: ["simple-responder"],
     contextProfiles: ["basic-context"],
   });
-  writeJson(root, "registry.json", {
-    components: {
-      agents: (options.agents ?? ["simple-responder"]).map((id) => ({
-        id,
-        path: `content/agents/${id}.md`,
-      })),
-      contexts: (options.contexts ?? ["team-standard"]).map((id) => ({
-        id,
-        path: `content/context/${id}.md`,
-      })),
-    },
-    profiles: {},
-  });
+
+  const components: Record<string, { id: string; path: string }[]> = {
+    agents: (options.agents ?? ["simple-responder"]).map((id) => ({
+      id,
+      path: `content/agents/${id}.md`,
+    })),
+    contexts: (options.contexts ?? ["team-standard"]).map((id) => ({
+      id,
+      path: `content/context/${id}.md`,
+    })),
+  };
+  const kinds = [
+    ["commands", "content/commands"],
+    ["tools", "content/tools"],
+    ["skills", "content/skills"],
+    ["plugins", "content/plugins"],
+    ["config", "content/config"],
+  ] as const;
+  for (const [field, dir] of kinds) {
+    const ids = options[field];
+    if (ids !== undefined) components[field] = ids.map((id) => ({ id, path: `${dir}/${id}.md` }));
+  }
+
+  writeJson(root, "registry.json", { components, profiles: {} });
 }
 
 describe("profile schemas", () => {
@@ -139,6 +160,37 @@ describe("profile schemas", () => {
     expect(parseContext).toThrow(/contexts|at least one|unrecognized|adapter/i);
     expect(parseSystem).toThrow(/agents|contextProfiles|at least one|unrecognized|targets/i);
   });
+
+  it("accepts optional component kinds and rejects legacy-only keys", async () => {
+    // Arrange
+    const { SystemProfileSchema } = await schemas();
+    const systemProfile = {
+      id: "full-system",
+      agents: ["simple-responder"],
+      contextProfiles: ["basic-context"],
+      commands: ["commit"],
+      tools: ["env"],
+      skills: ["task-management"],
+      plugins: ["notify"],
+      config: ["env-example"],
+    };
+    // `subagents` is deliberately absent from the schema: the canonical tree collapses
+    // subagents into agents, so the legacy category must not resurrect as a field.
+    const invalidSystem = {
+      id: "basic-system",
+      agents: ["simple-responder"],
+      contextProfiles: ["basic-context"],
+      subagents: ["tester"],
+    };
+
+    // Act
+    const parsed = SystemProfileSchema.parse(systemProfile);
+    const parseInvalid = () => SystemProfileSchema.parse(invalidSystem);
+
+    // Assert
+    expect(parsed).toEqual(systemProfile);
+    expect(parseInvalid).toThrow(/unrecognized|subagents/i);
+  });
 });
 
 describe("system profile resolution", () => {
@@ -161,7 +213,15 @@ describe("system profile resolution", () => {
     const resolved = await profileLoader.resolveSystemProfile("basic-system");
 
     // Assert
-    expect(resolved).toEqual({ agents: ["simple-responder"], contexts: ["team-standard"] });
+    expect(resolved).toEqual({
+      agents: ["simple-responder"],
+      contexts: ["team-standard"],
+      commands: [],
+      tools: [],
+      skills: [],
+      plugins: [],
+      config: [],
+    });
   });
 
   it("deduplicates and locale-independently sorts differently ordered inputs", async () => {
@@ -202,10 +262,72 @@ describe("system profile resolution", () => {
     expect(first).toEqual({
       agents: ["alpha-agent", "zeta-agent"],
       contexts: ["alpha-standard", "zeta-standard"],
+      commands: [],
+      tools: [],
+      skills: [],
+      plugins: [],
+      config: [],
     });
     expect(second).toEqual(first);
   });
 
+  it("resolves commands, tools, skills, plugins and config against the registry", async () => {
+    // Arrange
+    fixture(root, {
+      systemProfile: {
+        id: "basic-system",
+        agents: ["simple-responder"],
+        contextProfiles: ["basic-context"],
+        commands: ["commit", "test"],
+        tools: ["env"],
+        skills: ["task-management"],
+        plugins: ["notify"],
+        config: ["env-example"],
+      },
+      commands: ["commit", "test"],
+      tools: ["env"],
+      skills: ["task-management"],
+      plugins: ["notify"],
+      config: ["env-example"],
+    });
+    const profileLoader = await loader(root);
+
+    // Act
+    const resolved = await profileLoader.resolveSystemProfile("basic-system");
+
+    // Assert
+    expect(resolved).toEqual({
+      agents: ["simple-responder"],
+      contexts: ["team-standard"],
+      commands: ["commit", "test"],
+      tools: ["env"],
+      skills: ["task-management"],
+      plugins: ["notify"],
+      config: ["env-example"],
+    });
+  });
+
+  it("fails actionably for an unknown command", async () => {
+    // Arrange
+    fixture(root, {
+      systemProfile: {
+        id: "basic-system",
+        agents: ["simple-responder"],
+        contextProfiles: ["basic-context"],
+        commands: ["missing-command"],
+      },
+    });
+    const profileLoader = await loader(root);
+
+    // Act
+    const resolution = profileLoader.resolveSystemProfile("basic-system");
+
+    // Assert
+    await expect(resolution).rejects.toThrow(
+      /command.*missing-command.*(not found|unknown|resolve)/i
+    );
+  });
+
   it("fails actionably for an unknown system profile", async () => {
     // Arrange
     fixture(root);