diff --git a/docs/schemas/personas/.gitkeep b/docs/schemas/personas/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/docs/schemas/personas/.gitkeep @@ -0,0 +1 @@ + diff --git a/docs/schemas/templates/.gitkeep b/docs/schemas/templates/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/docs/schemas/templates/.gitkeep @@ -0,0 +1 @@ + diff --git a/package.json b/package.json index ce3047d..704c665 100644 --- a/package.json +++ b/package.json @@ -9,9 +9,10 @@ "pnpm": ">=9.0.0 <10" }, "scripts": { - "build": "tsc -b", + "build": "tsc -b && pnpm -r --if-present build", "db:generate": "drizzle-kit generate", "db:migrate": "tsx scripts/migrate.ts", + "db:seed": "tsx scripts/seed.ts", "devflow": "tsx apps/cli/src/index.ts", "typecheck": "tsc -p tsconfig.typecheck.json --noEmit", "test": "vitest run", diff --git a/packages/core/package.json b/packages/core/package.json index ac8e2b0..79f3459 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -7,12 +7,13 @@ "module": "./dist/index.js", "types": "./dist/index.d.ts", "scripts": { - "build": "tsup src/index.ts --format esm,cjs --dts", + "build": "tsup src/index.ts --format esm,cjs --clean && tsc -p tsconfig.build.json", "typecheck": "tsc -b --noEmit", "test": "vitest run" }, "dependencies": { "dotenv": "17.4.2", + "yaml": "2.6.1", "zod": "3.24.1" } } diff --git a/packages/core/src/hash.test.ts b/packages/core/src/hash.test.ts index f8c5e0d..6133c90 100644 --- a/packages/core/src/hash.test.ts +++ b/packages/core/src/hash.test.ts @@ -17,10 +17,47 @@ describe("content hashing", () => { expect(left).toMatch(/^[a-f0-9]{64}$/); }); + it("preserves own __proto__ keys while canonicalizing objects", () => { + const withProtoKey = JSON.parse('{"__proto__":{"x":1},"a":2}') as unknown; + + expect(canonicalize(withProtoKey)).toBe('{"__proto__":{"x":1},"a":2}'); + expect(hash(withProtoKey)).not.toBe(hash({ a: 2 })); + }); + + it("rejects hidden object keys that would be ignored by JSON rendering", () => { + const withSymbol = { a: 1, [Symbol("x")]: 2 }; + const withHidden = { a: 1 }; + Object.defineProperty(withHidden, "hidden", { value: 2, enumerable: false }); + + expect(() => canonicalize(withSymbol)).toThrow(/non-enumerable or symbol/); + expect(() => canonicalize(withHidden)).toThrow(/non-enumerable or symbol/); + }); + + it("rejects non-index array object keys that would be ignored by JSON rendering", () => { + const withStringKey = [1] as number[] & { extra?: number }; + withStringKey.extra = 2; + const withSymbol = [1] as unknown[]; + Object.defineProperty(withSymbol, Symbol("x"), { value: 2, enumerable: true }); + + expect(() => canonicalize(withStringKey)).toThrow(/non-index array/); + expect(() => canonicalize(withSymbol)).toThrow(/non-index array/); + }); + + it("renders the shortest round-trippable number literals without plus signs", () => { + expect(canonicalize([100, 1000, 11000, 123000, 1e20, 1e21, 0.000001, 0.0000001])).toBe( + "[100,1e3,11e3,123e3,1e20,1e21,1e-6,1e-7]", + ); + }); + it("rejects values that are not JSON-safe", () => { + const sparse = Array(3); + sparse[0] = 1; + sparse[2] = 3; + expect(() => canonicalize({ date: new Date("2026-05-09T00:00:00Z") })).toThrow( /non-plain object/, ); expect(() => canonicalize({ missing: undefined })).toThrow(/undefined/); + expect(() => canonicalize(sparse)).toThrow(/sparse array/); }); }); diff --git a/packages/core/src/hash.ts b/packages/core/src/hash.ts index ded3a1b..ed86a6e 100644 --- a/packages/core/src/hash.ts +++ b/packages/core/src/hash.ts @@ -16,7 +16,7 @@ function renderCanonical(value: JsonValue): string { } if (typeof value === "number") { - return JSON.stringify(value); + return renderCanonicalNumber(value); } if (Array.isArray(value)) { @@ -37,7 +37,17 @@ function assertJsonValue(value: unknown): JsonValue { Array.isArray(value) ) { if (Array.isArray(value)) { - return value.map((item) => assertJsonValue(item)); + assertOnlyArrayIndexKeys(value); + const arrayValue: JsonValue[] = []; + for (let index = 0; index < value.length; index += 1) { + if (!(index in value)) { + throw new TypeError(`Cannot canonicalize sparse array at index ${index}`); + } + + arrayValue.push(assertJsonValue(value[index])); + } + + return arrayValue; } return value; @@ -57,7 +67,8 @@ function assertJsonValue(value: unknown): JsonValue { throw new TypeError("Cannot canonicalize non-plain object"); } - const objectValue: Record = {}; + assertOnlyEnumerableStringKeys(value); + const objectValue: Record = Object.create(null) as Record; for (const [key, childValue] of Object.entries(value as Record)) { if (childValue === undefined) { @@ -72,3 +83,183 @@ function assertJsonValue(value: unknown): JsonValue { throw new TypeError(`Cannot canonicalize ${typeof value}`); } + +function assertOnlyEnumerableStringKeys(value: object) { + const enumerableStringKeys = Object.keys(value); + const ownKeys = Reflect.ownKeys(value); + const hasOnlyEnumerableStringKeys = + ownKeys.length === enumerableStringKeys.length && + ownKeys.every( + (key) => typeof key === "string" && Object.prototype.propertyIsEnumerable.call(value, key), + ); + + if (!hasOnlyEnumerableStringKeys) { + throw new TypeError("Cannot canonicalize non-enumerable or symbol object keys"); + } +} + +function assertOnlyArrayIndexKeys(value: unknown[]) { + for (const key of Reflect.ownKeys(value)) { + if (key === "length") { + continue; + } + + if (typeof key !== "string" || !Object.prototype.propertyIsEnumerable.call(value, key)) { + throw new TypeError("Cannot canonicalize non-index array object keys"); + } + + const index = Number(key); + if (!Number.isInteger(index) || index < 0 || index >= value.length || String(index) !== key) { + throw new TypeError("Cannot canonicalize non-index array object keys"); + } + } +} + +function renderCanonicalNumber(value: number): string { + if (Object.is(value, -0) || value === 0) { + return "0"; + } + + const candidates = new Set(); + addNumberCandidate(candidates, value, value.toString()); + const jsonCandidate = JSON.stringify(value); + if (jsonCandidate !== undefined) { + addNumberCandidate(candidates, value, jsonCandidate); + } + + for (let precision = 1; precision <= 17; precision += 1) { + addNumberCandidate(candidates, value, value.toPrecision(precision)); + addNumberCandidate(candidates, value, value.toExponential(precision - 1)); + } + + const [best] = [...candidates].sort( + (left, right) => left.length - right.length || compareCodeUnits(left, right), + ); + if (!best) { + throw new TypeError(`Cannot canonicalize number ${value}`); + } + + return best; +} + +function addNumberCandidate(candidates: Set, value: number, raw: string) { + const candidate = normalizeNumberLiteral(raw); + for (const equivalent of expandNumberLiteral(candidate)) { + if (Number(equivalent) === value) { + candidates.add(equivalent); + } + } +} + +function compareCodeUnits(left: string, right: string) { + if (left < right) { + return -1; + } + + if (left > right) { + return 1; + } + + return 0; +} + +function normalizeNumberLiteral(raw: string): string { + const [mantissaText, exponentText] = raw.toLowerCase().split("e"); + const mantissa = normalizeDecimal(mantissaText ?? ""); + if (exponentText === undefined) { + return mantissa; + } + + const exponent = normalizeExponent(exponentText); + if (exponent === "0") { + return mantissa; + } + + return `${mantissa}e${exponent}`; +} + +function normalizeDecimal(raw: string): string { + if (!raw.includes(".")) { + return raw; + } + + const trimmed = raw.replace(/0+$/, "").replace(/\.$/, ""); + if (trimmed === "-0") { + return "0"; + } + + return trimmed; +} + +function normalizeExponent(raw: string): string { + const sign = raw.startsWith("-") ? "-" : ""; + const unsigned = raw.replace(/^[+-]/, "").replace(/^0+/, ""); + if (unsigned === "") { + return "0"; + } + + return `${sign}${unsigned}`; +} + +function expandNumberLiteral(raw: string): string[] { + const parsed = parseNumberLiteral(raw); + if (!parsed) { + return [raw]; + } + + const plain = renderPlainDecimal(parsed); + const candidates = new Set([plain]); + + for (let integerDigits = 1; integerDigits <= parsed.digits.length; integerDigits += 1) { + const exponent = parsed.power + parsed.digits.length - integerDigits; + if (exponent === 0) { + continue; + } + + const mantissa = + integerDigits === parsed.digits.length + ? parsed.digits + : `${parsed.digits.slice(0, integerDigits)}.${parsed.digits.slice(integerDigits)}`; + candidates.add(`${parsed.sign}${mantissa}e${exponent}`); + } + + return [...candidates]; +} + +function parseNumberLiteral(raw: string) { + const match = raw.match(/^(-?)(\d+)(?:\.(\d+))?(?:e(-?\d+))?$/); + if (!match) { + return undefined; + } + + const [, sign, integerPart, fractionalPart = "", exponentPart] = match; + let digits = `${integerPart}${fractionalPart}`; + let power = (exponentPart === undefined ? 0 : Number(exponentPart)) - fractionalPart.length; + digits = digits.replace(/^0+/, ""); + if (digits === "") { + return { sign: "", digits: "0", power: 0 }; + } + + const lengthBeforeTrailingTrim = digits.length; + digits = digits.replace(/0+$/, ""); + power += lengthBeforeTrailingTrim - digits.length; + + return { sign: sign ?? "", digits, power }; +} + +function renderPlainDecimal(parsed: { sign: string; digits: string; power: number }) { + if (parsed.digits === "0") { + return "0"; + } + + if (parsed.power >= 0) { + return `${parsed.sign}${parsed.digits}${"0".repeat(parsed.power)}`; + } + + const pointIndex = parsed.digits.length + parsed.power; + if (pointIndex > 0) { + return `${parsed.sign}${parsed.digits.slice(0, pointIndex)}.${parsed.digits.slice(pointIndex)}`; + } + + return `${parsed.sign}0.${"0".repeat(-pointIndex)}${parsed.digits}`; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 46a3aa5..2f2ad32 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -2,5 +2,9 @@ export * from "./config.js"; export * from "./enums.js"; export * from "./errors.js"; export * from "./hash.js"; +export * from "./persona.js"; export * from "./prompt-envelope.js"; +export * from "./registry-loader.js"; export * from "./run-event.js"; +export * from "./template.js"; +export * from "./version.js"; diff --git a/packages/core/src/persona.ts b/packages/core/src/persona.ts new file mode 100644 index 0000000..72b3cb4 --- /dev/null +++ b/packages/core/src/persona.ts @@ -0,0 +1,147 @@ +import { z } from "zod"; + +import { Backend, Capability, RiskLevel } from "./enums.js"; +import { hash } from "./hash.js"; +import { DbIntVersion } from "./version.js"; + +export type JsonValue = + | null + | boolean + | number + | string + | JsonValue[] + | { [key: string]: JsonValue }; + +export type JsonObject = { [key: string]: JsonValue }; + +export const JsonObject: z.ZodType = z.lazy(() => + z + .custom>(isPlainJsonRecordInput, { + message: "expected plain JSON object", + }) + .superRefine((value, context) => { + for (const key of Reflect.ownKeys(value)) { + if (typeof key !== "string" || !Object.prototype.propertyIsEnumerable.call(value, key)) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: "expected plain JSON object", + }); + continue; + } + + if (!isSafeJsonObjectKey(key)) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: "reserved object key", + path: [key], + }); + } + } + }) + .pipe(z.record(z.string(), JsonValue)), +); + +export const JsonValue: z.ZodType = z.lazy(() => + z.union([z.null(), z.boolean(), z.number().finite(), z.string(), JsonArray, JsonObject]), +); + +export const JsonArray: z.ZodType = z.lazy(() => + z + .custom(Array.isArray, { message: "expected JSON array" }) + .superRefine((value, context) => { + for (const key of Reflect.ownKeys(value)) { + if (key === "length") { + continue; + } + + if (typeof key !== "string" || !Object.prototype.propertyIsEnumerable.call(value, key)) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: "expected JSON array", + }); + continue; + } + + const index = Number(key); + if ( + !Number.isInteger(index) || + index < 0 || + index >= value.length || + String(index) !== key + ) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: "expected JSON array", + }); + } + } + }) + .pipe(z.array(JsonValue)), +); + +export const Persona = z + .object({ + name: z.string().min(1), + version: DbIntVersion, + backend: Backend, + capabilities: z.array(Capability), + maxRiskLevel: RiskLevel, + allowedRoles: z.array(z.string().min(1)).optional(), + promptConfig: z + .object({ + systemPrompt: z.string().optional(), + instructionsPrelude: z.string().optional(), + }) + .strict() + .default({}) + .transform((value) => { + const promptConfig: { systemPrompt?: string; instructionsPrelude?: string } = {}; + if (value.systemPrompt !== undefined) { + promptConfig.systemPrompt = value.systemPrompt; + } + if (value.instructionsPrelude !== undefined) { + promptConfig.instructionsPrelude = value.instructionsPrelude; + } + + return promptConfig; + }), + modelConfig: JsonObject.default({}), + }) + .strict(); + +export type Persona = z.infer; + +export function personaHash(persona: Persona | undefined): string { + if (!persona) { + throw new TypeError("persona is required"); + } + + const hashSubject = { + name: persona.name, + version: persona.version, + capabilities: persona.capabilities, + backend: persona.backend, + maxRiskLevel: persona.maxRiskLevel, + promptConfig: persona.promptConfig, + modelConfig: persona.modelConfig, + }; + + return hash( + persona.allowedRoles === undefined + ? hashSubject + : { ...hashSubject, allowedRoles: persona.allowedRoles }, + ); +} + +function isSafeJsonObjectKey(key: string) { + return key !== "__proto__" && key !== "constructor" && key !== "prototype"; +} + +function isPlainJsonRecordInput(value: unknown) { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + return false; + } + + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} diff --git a/packages/core/src/registry-loader.test.ts b/packages/core/src/registry-loader.test.ts new file mode 100644 index 0000000..f7ffd4d --- /dev/null +++ b/packages/core/src/registry-loader.test.ts @@ -0,0 +1,297 @@ +import { mkdirSync, mkdtempSync, realpathSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +import { Persona } from "./persona.js"; +import { + assertNoReferencedRegistryDeletions, + buildRegistrySeedPlan, + loadPersonaFiles, + loadTemplateFiles, + personaHash, + templateHash, +} from "./registry-loader.js"; + +function makeRoot() { + return mkdtempSync(join(tmpdir(), "devflow-registry-")); +} + +describe("registry loader", () => { + it("loads versioned persona YAML files and computes stable hashes", () => { + const root = makeRoot(); + const dir = join(root, "personas"); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, "fake_developer@1.yaml"), + [ + "name: fake_developer", + "version: 1", + "backend: fake", + "capabilities:", + " - code_edit", + "maxRiskLevel: medium", + ].join("\n"), + ); + + const [entry] = loadPersonaFiles(dir); + + expect(entry?.name).toBe("fake_developer"); + expect(entry?.version).toBe(1); + expect(entry?.path).toBe(realpathSync(join(dir, "fake_developer@1.yaml"))); + expect(entry?.hash).toMatch(/^[a-f0-9]{64}$/); + expect(entry?.hash).toBe(personaHash(entry?.definition)); + }); + + it("rejects non-canonical template filenames and filename identity mismatches", () => { + const root = makeRoot(); + const dir = join(root, "templates"); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, "development@1.yaml"), + [ + "name: development", + "version: 1", + "roles:", + " - id: implementer", + " requiredCapabilities:", + " - code_edit", + "phases:", + " - key: spec", + " title: Spec", + " risk: low", + " roles:", + " - implementer", + ].join("\n"), + ); + writeFileSync( + join(dir, "development@01.yaml"), + ["name: development", "version: 1", "roles: []", "phases: []"].join("\n"), + ); + writeFileSync( + join(dir, "actual_name@2.yml"), + ["name: actual_name", "version: 2", "roles: []", "phases: []"].join("\n"), + ); + + expect(() => loadTemplateFiles(dir)).toThrow(/registry filename/); + + const validDir = join(root, "valid-templates"); + mkdirSync(validDir); + writeFileSync(join(validDir, "development@1.yaml"), readDevelopmentTemplate()); + const [entry] = loadTemplateFiles(validDir); + + expect(entry?.hash).toBe(templateHash(entry?.definition)); + + const mismatchDir = join(root, "mismatched-templates"); + mkdirSync(mismatchDir); + writeFileSync(join(mismatchDir, "wrong@1.yaml"), readDevelopmentTemplate()); + + expect(() => loadTemplateFiles(mismatchDir)).toThrow(/identity mismatch/); + }); + + it("rejects registry versions outside the database integer range", () => { + const root = makeRoot(); + const personaDir = join(root, "personas"); + mkdirSync(personaDir); + writeFileSync( + join(personaDir, "fake@2147483648.yaml"), + [ + "name: fake", + "version: 2147483648", + "backend: fake", + "capabilities: []", + "maxRiskLevel: low", + ].join("\n"), + ); + + expect(() => loadPersonaFiles(personaDir)).toThrow(/less than or equal/); + }); + + it("rejects unknown template and persona keys instead of silently stripping them", () => { + const root = makeRoot(); + const personaDir = join(root, "personas"); + const templateDir = join(root, "templates"); + mkdirSync(personaDir); + mkdirSync(templateDir); + writeFileSync( + join(personaDir, "fake@1.yaml"), + [ + "name: fake", + "version: 1", + "backend: fake", + "capabilities: []", + "maxRiskLevel: low", + "typo: accepted", + ].join("\n"), + ); + writeFileSync( + join(templateDir, "development@1.yaml"), + ["name: development", "version: 1", "roles: []", "phases: []", "typo: accepted"].join("\n"), + ); + + expect(() => loadPersonaFiles(personaDir)).toThrow(/Unrecognized key/); + expect(() => loadTemplateFiles(templateDir)).toThrow(/Unrecognized key/); + }); + + it("rejects persona model config values that cannot be content-hashed as JSON", () => { + const root = makeRoot(); + const personaDir = join(root, "personas"); + mkdirSync(personaDir); + writeFileSync( + join(personaDir, "fake@1.yaml"), + [ + "name: fake", + "version: 1", + "backend: fake", + "capabilities: []", + "maxRiskLevel: low", + "modelConfig:", + " temperature: .nan", + ].join("\n"), + ); + + expect(() => loadPersonaFiles(personaDir)).toThrow(/finite|number|expected plain JSON object/); + }); + + it("rejects persona model config keys that would mutate object prototypes", () => { + const root = makeRoot(); + const personaDir = join(root, "personas"); + mkdirSync(personaDir); + writeFileSync( + join(personaDir, "fake@1.yaml"), + [ + "name: fake", + "version: 1", + "backend: fake", + "capabilities: []", + "maxRiskLevel: low", + "modelConfig:", + ' "__proto__":', + " x: 1", + ].join("\n"), + ); + + expect(() => loadPersonaFiles(personaDir)).toThrow(/reserved object key/); + }); + + it("rejects non-plain programmatic model config objects", () => { + class ModelConfig { + readonly inherited = true; + } + + expect(() => + Persona.parse({ + name: "fake", + version: 1, + backend: "fake", + capabilities: [], + maxRiskLevel: "low", + modelConfig: new ModelConfig(), + }), + ).toThrow(/expected plain JSON object/); + }); + + it("rejects programmatic model config arrays with non-index keys", () => { + const array = [1] as unknown[] & { extra?: number }; + array.extra = 2; + + expect(() => + Persona.parse({ + name: "fake", + version: 1, + backend: "fake", + capabilities: [], + maxRiskLevel: "low", + modelConfig: { array }, + }), + ).toThrow(/expected JSON array/); + }); + + it("rejects symlinked registry files", () => { + const root = makeRoot(); + const dir = join(root, "personas"); + mkdirSync(dir); + const target = join(root, "target.yaml"); + writeFileSync( + target, + ["name: fake", "version: 1", "backend: fake", "capabilities: []", "maxRiskLevel: low"].join( + "\n", + ), + ); + symlinkSync(target, join(dir, "fake@1.yaml")); + + expect(() => loadPersonaFiles(dir)).toThrow(/not a symlink/); + }); + + it("builds seed actions and fails on published hash mismatch", () => { + const root = makeRoot(); + const personaDir = join(root, "personas"); + mkdirSync(personaDir); + writeFileSync( + join(personaDir, "fake@1.yaml"), + ["name: fake", "version: 1", "backend: fake", "capabilities: []", "maxRiskLevel: low"].join( + "\n", + ), + ); + const [entry] = loadPersonaFiles(personaDir); + if (!entry) { + throw new Error("expected persona registry entry"); + } + + expect(buildRegistrySeedPlan([entry], [])).toEqual({ + unchanged: [], + inserts: [entry], + missingReferenced: [], + missingUnreferenced: [], + }); + expect(() => + buildRegistrySeedPlan( + [entry], + [{ name: "fake", version: 1, hash: "different", referencedByRun: false }], + ), + ).toThrow(/published registry entry was modified/); + }); + + it("reports published registry rows that no longer have YAML files", () => { + const plan = buildRegistrySeedPlan( + [], + [ + { name: "unused", version: 1, hash: "abc", referencedByRun: false }, + { name: "referenced", version: 1, hash: "def", referencedByRun: true }, + ], + ); + + expect(plan).toEqual({ + inserts: [], + missingReferenced: [{ name: "referenced", version: 1, hash: "def", referencedByRun: true }], + missingUnreferenced: [{ name: "unused", version: 1, hash: "abc", referencedByRun: false }], + unchanged: [], + }); + }); + + it("rejects referenced published registry deletions", () => { + const plan = buildRegistrySeedPlan( + [], + [{ name: "referenced", version: 1, hash: "def", referencedByRun: true }], + ); + + expect(() => assertNoReferencedRegistryDeletions("persona", plan)).toThrow(/referenced@1/); + }); +}); + +function readDevelopmentTemplate() { + return [ + "name: development", + "version: 1", + "roles:", + " - id: implementer", + " requiredCapabilities:", + " - code_edit", + "phases:", + " - key: spec", + " title: Spec", + " risk: low", + " roles:", + " - implementer", + ].join("\n"); +} diff --git a/packages/core/src/registry-loader.ts b/packages/core/src/registry-loader.ts new file mode 100644 index 0000000..5f1f4a1 --- /dev/null +++ b/packages/core/src/registry-loader.ts @@ -0,0 +1,152 @@ +import { lstatSync, readFileSync, readdirSync, realpathSync } from "node:fs"; +import { basename, join } from "node:path"; +import { parse } from "yaml"; + +import { Persona, personaHash } from "./persona.js"; +import { Template, templateHash } from "./template.js"; + +export interface RegistryEntry { + name: string; + version: number; + hash: string; + definition: TDefinition; + path: string; +} + +export interface PublishedRegistryRow { + name: string; + version: number; + hash: string; + referencedByRun: boolean; +} + +export interface RegistrySeedPlan { + inserts: RegistryEntry[]; + missingReferenced: PublishedRegistryRow[]; + missingUnreferenced: PublishedRegistryRow[]; + unchanged: RegistryEntry[]; +} + +export type RegistryKind = "persona" | "template"; + +export function loadPersonaFiles(directory: string): RegistryEntry[] { + return loadVersionedYamlFiles(directory, Persona, personaHash); +} + +export function loadTemplateFiles(directory: string): RegistryEntry