{"_id":"@diabolicallabs/prompt-registry","name":"@diabolicallabs/prompt-registry","dist-tags":{"latest":"0.1.0"},"versions":{"0.1.0":{"name":"@diabolicallabs/prompt-registry","version":"0.1.0","description":"Versioned LLM prompt lifecycle — admin-standard prompt_versions pattern as a package. Seed from repo files, publish new versions, roll back, audit history. © Diabolical Labs","author":{"name":"Diana Ismail","email":"diana@deeismail.com","url":"https://deeismail.com"},"publisher":"Diabolical Labs","license":"MIT","type":"module","exports":{".":{"import":"./dist/index.js","types":"./dist/index.d.ts"}},"bin":{"prompt-registry-eval-gate":"dist/bin/eval-gate-cli.js"},"main":"./dist/index.js","types":"./dist/index.d.ts","repository":{"type":"git","url":"git+https://github.com/mannism/dlabs-toolkit.git","directory":"packages/prompt-registry"},"homepage":"https://github.com/mannism/dlabs-toolkit#readme","bugs":{"url":"https://github.com/mannism/dlabs-toolkit/issues"},"engines":{"node":">=20"},"peerDependencies":{"pg":">=8"},"peerDependenciesMeta":{"pg":{"optional":true}},"devDependencies":{"@types/node":"^26.0.1","@types/pg":"^8.11.6","@vitest/coverage-v8":"^4.1.9","pg":"^8.13.1","tsup":"^8.3.5","vitest":"^4.1.9"},"dependencies":{"zod":"^4.4.3"},"scripts":{"build":"tsup && node scripts/chmod-bin.mjs","typecheck":"tsc --noEmit","lint":"biome check ./src && eslint ./src","test":"vitest run","test:watch":"vitest","test:integration":"vitest run --config vitest.integration.config.ts","demo:eval-gate":"node scripts/eval-gate-demo.mjs"},"_id":"@diabolicallabs/prompt-registry@0.1.0","_integrity":"sha512-XifQ51Q4AxxosrOmuQ4ZWHCpHFoSfJtXsPdWZ6a/gR6k4n+netrXRSc0u0tWH+1WolC5WdfO2Y5mTAxRSV5Glg==","_resolved":"/tmp/4db87eccef53cb7250b86a9aa3551f4c/diabolicallabs-prompt-registry-0.1.0.tgz","_from":"file:diabolicallabs-prompt-registry-0.1.0.tgz","_nodeVersion":"20.20.2","_npmVersion":"10.8.2","dist":{"integrity":"sha512-XifQ51Q4AxxosrOmuQ4ZWHCpHFoSfJtXsPdWZ6a/gR6k4n+netrXRSc0u0tWH+1WolC5WdfO2Y5mTAxRSV5Glg==","shasum":"22786a88ff9f9ddfc69c975cbda1b39334832f9a","tarball":"https://registry.npmjs.org/@diabolicallabs/prompt-registry/-/prompt-registry-0.1.0.tgz","fileCount":10,"unpackedSize":125740,"signatures":[{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEQCIEdwKgHnAZH6Ixh+tzfJWZtHs//3IUisXYivCcnXm5mHAiAMumKaFcWQfjUkLT7Bt8xvaPGN4Z432VJK3JgG3Y1POw=="}]},"_npmUser":{"name":"shackled78","email":"shackled78@gmail.com"},"directories":{},"maintainers":[{"name":"shackled78","email":"shackled78@gmail.com"}],"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/prompt-registry_0.1.0_1783564600780_0.4859938022371342"},"_hasShrinkwrap":false}},"time":{"created":"2026-07-09T02:36:40.607Z","0.1.0":"2026-07-09T02:36:40.930Z","modified":"2026-07-09T02:36:41.240Z"},"maintainers":[{"name":"shackled78","email":"shackled78@gmail.com"}],"description":"Versioned LLM prompt lifecycle — admin-standard prompt_versions pattern as a package. Seed from repo files, publish new versions, roll back, audit history. © Diabolical Labs","homepage":"https://github.com/mannism/dlabs-toolkit#readme","repository":{"type":"git","url":"git+https://github.com/mannism/dlabs-toolkit.git","directory":"packages/prompt-registry"},"author":{"name":"Diana Ismail","email":"diana@deeismail.com","url":"https://deeismail.com"},"bugs":{"url":"https://github.com/mannism/dlabs-toolkit/issues"},"license":"MIT","readme":"# @diabolicallabs/prompt-registry\n\nVersioned LLM prompt lifecycle. Implements the fleet admin standard (§S7) as a package: `seed()` from repo files, `get()` the active or a specific version, `publish()` a new version, `history()`, `rollback()`. Storage is adapter-based; `PostgresPromptStorageAdapter` is the shipped reference. © Diabolical Labs\n\n**Canonical source:** [`/Users/mann/Documents/Claude/admin-standard.md`](/Users/mann/Documents/Claude/admin-standard.md) (ratified 2026-07-06) — this package implements §S7 (\"LLM prompt storage — prompts live in the DB, versioned\"). If the standard and this README disagree, the standard wins; file a PR to reconcile.\n\n## Install\n\n```bash\npnpm add @diabolicallabs/prompt-registry\n# pg is an optional peerDependency — install if you don't already have it\npnpm add pg\n```\n\n## Usage\n\n```typescript\nimport { Pool } from 'pg';\nimport {\n  createPromptRegistry,\n  PostgresPromptStorageAdapter,\n  loadSeedFilesFromDirectory,\n} from '@diabolicallabs/prompt-registry';\n\n// Bring your own pool — the registry never opens its own connection.\nconst pool = new Pool({ connectionString: process.env.DATABASE_URL });\nconst adapter = new PostgresPromptStorageAdapter(pool);\nawait adapter.ensureSchema(); // idempotent CREATE TABLE IF NOT EXISTS\n\nconst registry = createPromptRegistry({ adapter });\n\n// Seed step (deploy time) — repo files are the source of truth for v1.\nconst entries = await loadSeedFilesFromDirectory('./constants/prompts');\nawait registry.seed(entries); // no-op after the first run\n\n// Runtime read\nconst prompt = await registry.get('onboarding'); // type defaults to 'system', version defaults to active\nconst systemPrompt = prompt.content;\n\n// Admin publish (new version, activates immediately, old version untouched)\nawait registry.publish('onboarding', updatedText, {\n  createdBy: 'diana',\n  changeNotes: 'tightened tone per Reid review',\n});\n\n// Roll back to a prior version without creating a new row\nawait registry.rollback('onboarding', 3);\n\n// Full version history, newest first\nconst versions = await registry.history('onboarding');\n```\n\n### Multiple prompt types (SYSTEM/USER)\n\n```typescript\nawait registry.publish('interview', systemText, { type: 'system' });\nawait registry.publish('interview', userTemplateText, { type: 'user' });\n\nconst system = await registry.get('interview', { type: 'system' });\nconst user = await registry.get('interview', { type: 'user' });\n```\n\n### Seed file format\n\n`.md` files with YAML-ish frontmatter — `name` required, `type` optional (defaults to `system`):\n\n```markdown\n---\nname: onboarding\ntype: system\n---\nYou are a helpful onboarding assistant...\n```\n\n## API\n\n### `createPromptRegistry(config): PromptRegistry`\n\n| Config field | Type | Description |\n|---|---|---|\n| `adapter` | `PromptStorageAdapter` | Required. `PostgresPromptStorageAdapter` or a custom implementation. |\n\n### `PromptRegistry`\n\n| Method | Signature | Description |\n|---|---|---|\n| `seed` | `(entries: SeedPromptEntry[]) => Promise<SeedResult[]>` | Idempotent — inserts v1 (active) only for names with no existing version. Safe to call on every deploy. |\n| `get` | `(name, options?: { type?, version?: number \\| 'latest' }) => Promise<PromptRecord>` | Defaults: `type: 'system'`, `version: 'latest'` (the active row). A numeric version returns that row regardless of active state. Throws `PromptNotFoundError`. |\n| `publish` | `(name, content, meta?: { type?, createdBy?, changeNotes? }) => Promise<PromptRecord>` | Always inserts a new version and activates it; never overwrites a prior row. |\n| `history` | `(name, options?: { type? }) => Promise<PromptRecord[]>` | All versions, newest first. Empty array if never seeded/published. |\n| `rollback` | `(name, version, options?: { type? }) => Promise<PromptRecord>` | Re-activates a prior version in place (content untouched, no new row). Throws `PromptNotFoundError` if the version doesn't exist. |\n\n### `PromptRecord`\n\n```typescript\ninterface PromptRecord {\n  id: number | string;\n  name: string;\n  type: string;\n  version: number;\n  content: string;\n  isActive: boolean;\n  activatedOn: Date | null;\n  createdBy: string | null;\n  changeNotes: string | null;\n  createdOn: Date;\n}\n```\n\n## Sensitivity masking\n\nPer admin-standard §S6 (`is_sensitive` masking), prompt bodies are maskable for lower-trust display contexts — audit-log list views, Slack/webhook notification payloads:\n\n```typescript\nimport { maskPromptBody, redactConnectionString } from '@diabolicallabs/prompt-registry';\n\nmaskPromptBody(record.content);          // preview mode (default): first 60 chars + byte count\nmaskPromptBody(record.content, 'full');  // fully redacted, byte count only\nmaskPromptBody(record.content, 'hash');  // sha256 fingerprint only — for diff-changed checks\n\nredactConnectionString('postgres://admin:secret@db.internal:5432/prod');\n// -> 'postgres://***:***@db.internal:5432/prod'\n```\n\nThe package's own internal logging **never** includes raw `content` or connection secrets in any log call — asserted by `src/__tests__/unit/logging-security.test.ts`, which spies on every log call across the full `seed → get → publish → history → rollback` lifecycle and asserts a sentinel prompt body never appears in logged output.\n\n## CI eval-gate\n\nGate a prompt change on an eval script's exit code — wire into your CI before a `publish()` call, or as a pre-merge check on a PR that touches a seed file:\n\n```typescript\nimport { runPromptEvalGate } from '@diabolicallabs/prompt-registry';\n\n// Throws PromptEvalGateFailedError on non-zero exit — un-caught, this fails the CI step.\nawait runPromptEvalGate({\n  promptPath: './constants/prompts/onboarding.md',\n  evalScriptPath: './scripts/eval-onboarding-prompt.mjs',\n});\n```\n\nOr as a shell step via the bundled CLI:\n\n```bash\nnpx prompt-registry-eval-gate ./constants/prompts/onboarding.md ./scripts/eval-onboarding-prompt.mjs\n```\n\nThe eval script receives the prompt path as `argv[2]` and the `PROMPT_FILE` env var; its exit code is the verdict. This package does not prescribe what the script checks — golden-output diffing, a judge-model rubric, a regex smoke test are all valid. `scripts/eval-gate-demo.mjs` demonstrates both directions (pass and fail) against fixture prompts/scripts and runs as part of this package's own CI.\n\n## `PromptStorageAdapter` interface\n\n```typescript\ninterface PromptStorageAdapter {\n  ensureSchema(): Promise<void>;\n  insertVersion(input: InsertPromptVersionInput): Promise<PromptRecord>;\n  getVersion(name: string, type: string, version: number): Promise<PromptRecord | null>;\n  getActiveVersion(name: string, type: string): Promise<PromptRecord | null>;\n  listVersions(name: string, type: string): Promise<PromptRecord[]>;\n  getMaxVersion(name: string, type: string): Promise<number>;\n  activateVersion(name: string, type: string, version: number): Promise<PromptRecord>;\n}\n```\n\n`PostgresPromptStorageAdapter` is the shipped implementation. It takes a structural `PgPoolLike` (`query` + `connect`) — any real `pg.Pool` instance satisfies it, so you pass your existing pool rather than the registry opening a new connection. All queries are parameterized (`$1`, `$2`, ...) — no string interpolation into SQL anywhere in the adapter. `insertVersion()` and `activateVersion()` each run inside a single checked-out-client transaction (`BEGIN`/`COMMIT`/`ROLLBACK`) so \"insert new version\" + \"deactivate the old active row\" (or \"deactivate + activate\") never observably interleave with a concurrent reader.\n\nA non-Postgres backend implements the same interface — the interface is the extension point, not a plugin registry.\n\n## Migration guide: converting an existing per-product implementation\n\nThis section shows the actual diff shape for migrating a product that already has its own `prompt_versions` implementation (the pre-package pattern from admin-standard §S7) onto this package. **FitCheckerApp** (`/Users/mann/Documents/GitHub/FitCheckerApp`) was used as the reference — admin-standard identifies it as the fleet's cleanest `prompt_versions` schema (`db/schema/prompt-versions.ts`, `lib/prompts.ts`, `scripts/seed-prompt-versions.ts`). This is a worked example, not a completed migration — no product is migrated in this PR (see \"Out of scope\" below); a real migration is its own per-product follow-up brief.\n\n### Before — FitCheckerApp's hand-rolled implementation\n\n`db/schema/prompt-versions.ts` (Drizzle):\n\n```typescript\nexport const promptVersions = pgTable(\"prompt_versions\", {\n  id: uuid(\"id\").defaultRandom().primaryKey(),\n  promptName: varchar(\"prompt_name\", { length: 100 }).notNull(),\n  promptType: varchar(\"prompt_type\", { length: 10 }).notNull(),\n  version: integer(\"version\").notNull(),\n  content: text(\"content\").notNull(),\n  llmModelId: uuid(\"llm_model_id\").references(() => llmModels.id),\n  isActive: boolean(\"is_active\").notNull().default(false),\n  activatedOn: timestamp(\"activated_on\"),\n  createdBy: uuid(\"created_by\").references(() => users.id),\n  createdOn: timestamp(\"created_on\").defaultNow().notNull(),\n  changeNotes: text(\"change_notes\"),\n}, (table) => [\n  unique(\"prompt_versions_prompt_name_prompt_type_version_key\")\n    .on(table.promptName, table.promptType, table.version),\n]);\n```\n\n`lib/prompts.ts` (custom cache + fallback logic, ~165 lines):\n\n```typescript\nexport async function getPrompt(prompt_name: string, prompt_type: string): Promise<Prompt | null> {\n  const normalizedName = prompt_name.trim().toUpperCase();\n  const normalizedType = prompt_type.trim().toUpperCase();\n  try {\n    const cacheKey = `prompt:${normalizedName}:${normalizedType}`;\n    const dbPrompt = await withCache(cacheKey, PROMPT_CACHE_TTL, async () => {\n      const { getActivePromptVersion, getPromptModelOverride } =\n        await import(\"@/lib/repositories/prompt.repository\");\n      const active = await getActivePromptVersion(normalizedName, normalizedType);\n      // ...\n    });\n    if (dbPrompt) return { /* hand-built Prompt shape */ };\n  } catch {\n    // DB unavailable — fall through to filesystem\n  }\n  // ...filesystem fallback via loadPrompts()...\n}\n```\n\n`scripts/seed-prompt-versions.ts` (~114 lines, hand-rolled `pg.Pool` + manual `INSERT ... WHERE NOT EXISTS` idempotency check).\n\n### After — with `@diabolicallabs/prompt-registry`\n\n```typescript\n// lib/prompt-registry.ts — replaces db/schema/prompt-versions.ts's manual\n// table definition (ensureSchema() owns the DDL) and lib/prompts.ts's\n// getPrompt()/loadPrompts() (registry.get() replaces both).\nimport { Pool } from 'pg';\nimport { createPromptRegistry, PostgresPromptStorageAdapter } from '@diabolicallabs/prompt-registry';\n\nconst pool = new Pool({ connectionString: process.env.DATABASE_URL });\nexport const promptRegistry = createPromptRegistry({\n  adapter: new PostgresPromptStorageAdapter(pool),\n});\n```\n\n```typescript\n// scripts/seed-prompts.ts — replaces scripts/seed-prompt-versions.ts (114 lines -> ~10)\nimport { loadSeedFilesFromDirectory } from '@diabolicallabs/prompt-registry';\nimport { promptRegistry } from '../lib/prompt-registry';\n\nconst entries = await loadSeedFilesFromDirectory('./constants/prompts');\nconst results = await promptRegistry.seed(entries);\nconsole.log(results); // [{ name, type, status: 'seeded' | 'skipped_existing', version }]\n```\n\n```typescript\n// call sites — getPrompt(name, type) becomes registry.get(name, { type })\n- const prompt = await getPrompt(\"ONBOARDING\", \"SYSTEM\");\n- const text = prompt?.prompt ?? FALLBACK_TEXT;\n+ const prompt = await promptRegistry.get('onboarding', { type: 'system' });\n+ const text = prompt.content;\n```\n\n### What changes, what doesn't\n\n| Aspect | FitCheckerApp before | With prompt-registry |\n|---|---|---|\n| Schema | Hand-written Drizzle table, product owns migration | `ensureSchema()` (or hand-author an equivalent migration from `schema.sql` if you already use Drizzle migrations) |\n| Idempotent seed | 114-line script, manual `WHERE NOT EXISTS` | `loadSeedFilesFromDirectory()` + `seed()`, ~10 lines |\n| Read path | `getPrompt()` + `withCache()` + filesystem fallback, ~165 lines | `registry.get(name, options)` — no built-in cache (see Performance note below); filesystem fallback is your call, not the package's |\n| Rollback | Not implemented in FitCheckerApp today | `registry.rollback(name, version)` |\n| Concurrency safety | Not explicitly handled | `PromptVersionConflictError` on a version-number race (UNIQUE constraint backstop) |\n| Frontmatter format | `name`/`type` fields, same as this package | Unchanged — `loadSeedFilesFromDirectory()` reads the exact same `.md` files FitCheckerApp already has in `constants/prompts/` |\n\n**Not carried over on purpose:** FitCheckerApp's `withCache()` 10-minute TTL and filesystem fallback are product-specific request-path optimizations, not part of the admin-standard contract. A migrating product keeps its own cache layer in front of `registry.get()` — see the Performance note in `manifest.yaml`.\n\n## Testing\n\n```bash\npnpm test              # unit suite, mocked/in-memory adapters\npnpm test:integration   # requires DATABASE_URL — see src/__tests__/integration/postgres.test.ts header\npnpm demo:eval-gate     # eval-gate fixture demonstration (requires `pnpm build` first)\n```\n","readmeFilename":"README.md","_rev":"1-288436185a687748870c6883753ee90f"}