diff --git a/src/kernel/specialist-registry.ts b/src/kernel/specialist-registry.ts index 9cc296c..7b0254f 100644 --- a/src/kernel/specialist-registry.ts +++ b/src/kernel/specialist-registry.ts @@ -290,6 +290,19 @@ export function loadSpecialistRegistry(paths: UadsPaths, schemaRoot?: string): S if (normalized.registryDigest !== parsed.value.registryDigest || parsed.value.policyVersion !== SPECIALIST_POLICY_VERSION) { throw new SpecialistRegistryError("specialist registry digest or policy version mismatch"); } + + const currentBuiltins = builtinSpecialistRegistry(schemaRoot); + const builtinIds = new Set(currentBuiltins.profiles.map((profile) => profile.specialistId)); + const preserved = normalized.profiles.filter((profile) => profile.source !== "builtin"); + const collision = preserved.find((profile) => builtinIds.has(profile.specialistId)); + if (collision) { + throw new SpecialistRegistryError(`persisted non-builtin specialist collides with built-in ID: ${collision.specialistId}`); + } + + const reconciled = createSpecialistRegistry([...currentBuiltins.profiles, ...preserved]); + if (reconciled.registryDigest !== normalized.registryDigest) { + return persistSpecialistRegistry(paths, reconciled, schemaRoot); + } return normalized; } diff --git a/tests/specialist-registry-refresh.test.ts b/tests/specialist-registry-refresh.test.ts new file mode 100644 index 0000000..64eb547 --- /dev/null +++ b/tests/specialist-registry-refresh.test.ts @@ -0,0 +1,76 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { getUadsPaths } from "../src/lib/workspace.js"; +import { + builtinSpecialistRegistry, + createSpecialistRegistry, + loadSpecialistRegistry, + normalizeSpecialistProfile, + persistSpecialistRegistry, +} from "../src/kernel/specialist-registry.js"; + +const ROOT = process.cwd(); + +describe("built-in specialist registry refresh", () => { + it("refreshes official built-ins while preserving user-config profiles", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "uads-registry-refresh-")); + const paths = getUadsPaths("project-refresh", home); + const current = builtinSpecialistRegistry(ROOT); + + const legacyBuiltins = current.profiles + .filter((profile) => profile.specialistId !== "systems-runtime-specialist") + .map((profile) => { + if (profile.specialistId === "test-engineer") { + return normalizeSpecialistProfile({ + ...profile, + coveredDomains: profile.coveredDomains.filter((domain) => domain !== "testing"), + profileDigest: undefined, + }, "builtin"); + } + if (profile.specialistId === "independent-reviewer") { + return normalizeSpecialistProfile({ + ...profile, + coveredDomains: profile.coveredDomains.filter((domain) => domain !== "certification"), + profileDigest: undefined, + }, "builtin"); + } + return profile; + }); + + const template = current.profiles.find((profile) => profile.specialistId === "frontend-specialist")!; + const custom = normalizeSpecialistProfile({ + ...template, + specialistId: "user-custom-frontend", + source: "user-config", + profileDigest: undefined, + }, "user-config"); + + const legacy = createSpecialistRegistry([...legacyBuiltins, custom]); + persistSpecialistRegistry(paths, legacy, ROOT); + + const loaded = loadSpecialistRegistry(paths, ROOT); + expect(loaded.profiles).toHaveLength(current.profiles.length + 1); + expect(loaded.profiles.some((profile) => profile.specialistId === "systems-runtime-specialist")).toBe(true); + expect( + loaded.profiles.find((profile) => profile.specialistId === "test-engineer")?.coveredDomains, + ).toContain("testing"); + expect( + loaded.profiles.find((profile) => profile.specialistId === "independent-reviewer")?.coveredDomains, + ).toContain("certification"); + expect(loaded.profiles.find((profile) => profile.specialistId === "user-custom-frontend")?.source).toBe("user-config"); + + const persisted = JSON.parse(fs.readFileSync(paths.specialistRegistry, "utf8")) as { + registryDigest: string; + profiles: Array<{ specialistId: string }>; + }; + const state = JSON.parse(fs.readFileSync(paths.specialistState, "utf8")) as { + registryDigest: string; + profileCount: number; + }; + expect(persisted.registryDigest).toBe(loaded.registryDigest); + expect(state.registryDigest).toBe(loaded.registryDigest); + expect(state.profileCount).toBe(current.profiles.length + 1); + }); +});