@app-vitals/agent-admin 0.16.1 → 0.16.2

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.
@@ -537,7 +537,7 @@
537
537
  },
538
538
  "specialty": {
539
539
  "type": "string",
540
- "example": "coding"
540
+ "example": "specialist"
541
541
  }
542
542
  },
543
543
  "required": [
@@ -558,7 +558,7 @@
558
558
  "specialty": {
559
559
  "type": "string",
560
560
  "minLength": 1,
561
- "example": "coding"
561
+ "example": "specialist"
562
562
  },
563
563
  "runtime": {
564
564
  "type": "string",
@@ -3091,11 +3091,11 @@
3091
3091
  },
3092
3092
  "specialty": {
3093
3093
  "type": "string",
3094
- "example": "coding"
3094
+ "example": "specialist"
3095
3095
  },
3096
3096
  "typeName": {
3097
3097
  "type": "string",
3098
- "example": "coding",
3098
+ "example": "specialist",
3099
3099
  "deprecated": true
3100
3100
  },
3101
3101
  "phaseMethodology": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@app-vitals/agent-admin",
3
- "version": "0.16.1",
3
+ "version": "0.16.2",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/app-vitals/agent.git",
@@ -43,7 +43,7 @@
43
43
  },
44
44
  "dependencies": {
45
45
  "@anthropic-ai/sdk": "0.135.0",
46
- "@app-vitals/agent-lib": "0.16.1",
46
+ "@app-vitals/agent-lib": "0.16.2",
47
47
  "@hono/zod-openapi": "^1.0.0",
48
48
  "@prisma/adapter-pg": "^7.10.0",
49
49
  "@prisma/client": "^7.10.0",
@@ -20,7 +20,7 @@ const config: runtime.GetPrismaClientConfig = {
20
20
  "clientVersion": "7.10.0",
21
21
  "engineVersion": "0edf323efd1d98336f3f0a68684b56f689b900d3",
22
22
  "activeProvider": "postgresql",
23
- "inlineSchema": "// Shipwright — Admin Service Schema: coding-owned models (AEP-3.4)\n//\n// Part of the admin Prisma multi-file schema (prisma/schema/). These models\n// belong to the coding feature surface and are isolated here so the coding\n// code can later be extracted. They share the single `db` datasource and\n// generator declared in schema.prisma, so the generated client and the\n// migrations are unchanged by this split.\n//\n// Coding-owned columns that live on the shared Agent model (schema.prisma)\n// and are grouped/commented there: reviewAuthorAllowlist, patchAuthorAllowlist,\n// autoPostReviews, allowSelfReview, minConfidence, maxFindings.\n\n// ─── Agent Work Queue Snapshot ────────────────────────────────────────────────\n// One row per agent, holding the agent's most recently pushed work-queue\n// snapshot (its ranked view of pending tasks/PRs across pipeline phases).\n// POST /agents/:id/work-queue upserts this single row, overwriting any prior\n// snapshot — there is no history, only the latest state.\n\nmodel AgentWorkQueueSnapshot {\n id String @id @default(cuid())\n agentId String @unique\n agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade)\n computedAt DateTime\n items Json // RankedWorkItem[]\n createdAt DateTime @default(now())\n}\n\n// ─── Agent Phase Methodology ──────────────────────────────────────────────────\n// Per-phase subagent-type override. Each row records which subagent type\n// (e.g. a custom Specialty name) should handle one pipeline phase — prd,\n// plan-session, review, patch, deploy, or dev-task — for a given agent,\n// overriding that phase's default methodology. `subagentType` is nullable:\n// a row with a null value (or the absence of a row) means \"use the default\".\n// The `phase` value is constrained to the six-value enum at the service/route\n// layer (Zod), not here — Postgres/Prisma has no native enum-column\n// requirement in this schema style (mirrors AgentTool.pattern / AgentCronJob\n// validation living at the service layer). The unique constraint on\n// [agentId, phase] makes upsert-based writes idempotent (one override row per\n// phase per agent), mirroring AgentTool's [agentId, pattern] and AgentPlugin's\n// [agentId, name].\n\nmodel AgentPhaseMethodology {\n id String @id @default(cuid())\n agentId String\n agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade)\n phase String\n subagentType String?\n updatedAt DateTime @updatedAt\n\n @@unique([agentId, phase])\n}\n\n// ─── Session Follow ───────────────────────────────────────────────────────────\n// Records that a user is \"following\" a planning session (identified by its\n// slug) so they can receive reminders/notifications about it. `muted` lets a\n// user stay following (kept in listByUser) while suppressing alerts, distinct\n// from unfollowing (row deleted entirely). The unique constraint on\n// [userEmail, sessionSlug] makes follow() idempotent via upsert.\nmodel SessionFollow {\n id String @id @default(cuid())\n userEmail String\n sessionSlug String\n muted Boolean @default(false)\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n @@unique([userEmail, sessionSlug])\n @@index([sessionSlug])\n}\n\n// ─── Session Alert State ─────────────────────────────────────────────────────\n// Tracks the last time a user was alerted about a given session, so a\n// reminder job can avoid re-alerting the same user/session pair within a\n// cooldown window. The unique constraint on [userEmail, sessionSlug] allows\n// upsert semantics (one state row per user per session).\nmodel SessionAlertState {\n id String @id @default(cuid())\n userEmail String\n sessionSlug String\n lastAlertedAt DateTime?\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n @@unique([userEmail, sessionSlug])\n}\n\n// Shipwright — Admin Service Schema\n// Owns: Agent, AgentEnv, AgentCronJob, AgentTool, AgentToken, AgentPlugin,\n// plus the coding-owned models in coding.prisma (AgentWorkQueueSnapshot,\n// AgentPhaseMethodology, SessionFollow, SessionAlertState).\n// Agent is a first-class model (standalone, not embedded in a User model).\n//\n// WARNING: This schema uses DATABASE_URL_SHIPWRIGHT_ADMIN — a dedicated database\n// for the admin service. Do NOT point this at a shared database.\n\n// Prisma 7: the `prisma-client` generator (ESM, driver-adapter based) replaces\n// `prisma-client-js`, and the datasource block no longer accepts `url` — the\n// connection string for CLI/Migrate lives in ../../prisma.config.ts, and the\n// runtime client connects through a PrismaPg driver adapter (see\n// admin/src/prisma-client.ts).\ngenerator client {\n provider = \"prisma-client\"\n output = \"../client\"\n}\n\ndatasource db {\n provider = \"postgresql\"\n}\n\n// ─── Agent ────────────────────────────────────────────────────────────────────\n// A Shipwright agent: an autonomous runner that picks tasks, builds, and ships PRs.\n\nmodel Agent {\n id String @id @default(cuid())\n name String\n slackId String? @unique\n slackBotToken String? // AES-256-GCM encrypted at the service layer\n anthropicApiKey String? // AES-256-GCM encrypted at the service layer\n selfHosted Boolean @default(false)\n repos String[] @default([])\n // DBR-2.1→2.4: renamed from authorAllowlist. DBR-2.1 added this column as\n // a dual-written/dual-read twin of the old authorAllowlist column and\n // backfilled it; DBR-2.2/2.3 migrated the admin UI and the agent's\n // runtime read path onto this field exclusively; DBR-2.4 dropped the old\n // authorAllowlist column now that nothing reads or writes it.\n // CODING-OWNED (AEP-3.4): reviewAuthorAllowlist and patchAuthorAllowlist are\n // read by the coding review/patch flows. Kept on Agent (no column move).\n reviewAuthorAllowlist String[] @default([])\n patchAuthorAllowlist String[] @default([])\n restrictSlackToMembers Boolean @default(false)\n // The agent's Specialty name (manifest key). AGR-6.3 made it required and\n // dropped the legacy typeName column.\n specialty String @default(\"coding\")\n // SSP-1.2: owning account. Nullable/additive — existing agents keep NULL.\n accountId String?\n account Account? @relation(fields: [accountId], references: [id], onDelete: Restrict)\n // ATE-1.1: trial expiry tracking. Both nullable/additive — null means \"no\n // trial configured\" / \"no warning sent yet\". trialExpiresAt is settable via\n // PATCH /agents/:id like other nullable scalar fields; trialExpiryWarnedAt\n // is written internally by ATE-2.1's warning check (not user-editable via\n // the PATCH route) but is included in GET/PATCH responses.\n trialExpiresAt DateTime?\n trialExpiryWarnedAt DateTime?\n // PAI-1.1: owner and Anthropic identity. All nullable/additive — existing\n // agents keep NULL. anthropicIdentityName is unique across agents.\n ownerEmail String?\n anthropicIdentityName String? @unique\n anthropicServiceAccountId String?\n anthropicFederationRuleId String?\n k8sServiceAccountName String?\n // COS-48.1: the pod's own Kubernetes ServiceAccount (cluster identity). A\n // separate column from k8sServiceAccountName, which is the Anthropic-\n // federation KSA. Only rendered when the pod-SA flag is on.\n podServiceAccountName String?\n // APM-1.1: the 6 agent policy fields (auto_post_reviews, allow_self_review,\n // min_confidence, max_findings, cleanup_merged_worktrees,\n // cleanup_after_days). Originally mirrored from the legacy\n // state/agent-policy.md; the DB is now the only source and each column\n // carries its documented default.\n // CODING-OWNED (AEP-3.4): autoPostReviews, allowSelfReview, minConfidence,\n // maxFindings configure the coding review flow; cleanupMergedWorktrees and\n // cleanupAfterDays below are runtime-owned.\n autoPostReviews Boolean @default(true)\n allowSelfReview Boolean @default(false)\n minConfidence Int @default(75)\n maxFindings Int @default(5)\n cleanupMergedWorktrees Boolean @default(true)\n cleanupAfterDays Int @default(14)\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n envVars AgentEnv[]\n cronJobs AgentCronJob[]\n tools AgentTool[]\n tokens AgentToken[]\n plugins AgentPlugin[]\n members AgentMember[]\n chatTokenUsage AgentChatTokenUsageDailyByModel[]\n workQueueSnapshot AgentWorkQueueSnapshot?\n githubInstallationsSnapshot AgentGitHubInstallationsSnapshot?\n phaseMethodologies AgentPhaseMethodology[]\n\n @@index([accountId])\n}\n\n// ─── Agent Env ────────────────────────────────────────────────────────────────\n// Generic key/value env var store for deployed agents.\n// Keys are env var names (e.g. ANTHROPIC_API_KEY). Values are AES-256-GCM encrypted.\n\nmodel AgentEnv {\n id String @id @default(cuid())\n agentId String\n agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade)\n key String // env var name\n value String // AES-256-GCM encrypted\n secret Boolean @default(false) // when true, GET /agents/:id/envs masks the value as ***\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n @@unique([agentId, key])\n}\n\n// ─── Agent Cron Jobs ──────────────────────────────────────────────────────────\n// Scheduled prompts per agent.\n// Delivery target is either a Slack channel ID or a Slack user ID (DM).\n// Validation that exactly one of channel/user is set happens in the service layer.\n\nmodel AgentCronJob {\n id String @id @default(cuid())\n agentId String\n agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade)\n schedule String // cron expression e.g. \"0 9 * * 1-5\"\n prompt String\n channel String? // Slack channel ID (mutually exclusive with user)\n user String? // Slack user ID for DMs\n silent Boolean @default(false)\n enabled Boolean @default(true)\n preCheck String? // optional pre-check script e.g. \"shipwright:check-docs-freshness.ts\"\n name String? // stable key for system crons; null for user-created crons\n system Boolean @default(false)\n // Set when the trial-expiry lockdown (not the user) disabled this cron, so\n // restoreLockdownDisabled() can re-enable exactly those crons. Null for\n // enabled crons and for crons the user disabled manually.\n lockdownDisabledAt DateTime?\n // Run-now request (COS-42.1): stamped by POST .../run-now, cleared when the\n // runtime creates the run (commandVariant \"run-now\"). The runtime honours a\n // stamp at most once and only while it is fresh (15 minutes).\n runRequestedAt DateTime?\n runRequestedBy String?\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n // Self-relation: a parent cron can have child \"phase\" crons (e.g. dev-task/\n // review/patch/deploy phases of a single logical loop). Null for top-level\n // crons. Deleting a parent cascades to its phase children.\n parentCronId String?\n parentCron AgentCronJob? @relation(\"CronPhases\", fields: [parentCronId], references: [id], onDelete: Cascade)\n phases AgentCronJob[] @relation(\"CronPhases\")\n\n runs AgentCronRun[]\n phaseRuns AgentCronRun[] @relation(\"PhaseRuns\")\n\n @@index([parentCronId])\n}\n\n// ─── Agent Tools ──────────────────────────────────────────────────────────────\n// Allowed tool patterns per agent. Each row grants an agent permission to use\n// a specific tool pattern (e.g. \"Read\", \"Bash\", \"Write\"). The unique constraint\n// on [agentId, pattern] prevents duplicates.\n\nmodel AgentTool {\n id String @id @default(cuid())\n agentId String\n agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade)\n pattern String\n enabled Boolean @default(true)\n createdAt DateTime @default(now())\n\n @@unique([agentId, pattern])\n}\n\n// ─── Agent Tokens ─────────────────────────────────────────────────────────────\n// Scoped API tokens for agent authentication. The raw token is returned once at\n// creation; only the SHA-256 hash is stored. Token validation is O(1) via the\n// hash index.\n\nmodel AgentToken {\n id String @id @default(cuid())\n agentId String\n agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade)\n token String @unique // SHA-256 hash of the raw token (hex)\n label String?\n createdAt DateTime @default(now())\n revokedAt DateTime?\n}\n\n// ─── Agent Plugins ────────────────────────────────────────────────────────────\n// Claude Code plugins installed per agent. Each row represents one installed\n// plugin package. The unique constraint on [agentId, name] prevents duplicates.\n\nmodel AgentPlugin {\n id String @id @default(cuid())\n agentId String\n agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade)\n name String // plugin package name e.g. \"@shipwright/plugin\"\n version String? // pinned version; null = latest\n enabled Boolean @default(true)\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n @@unique([agentId, name])\n}\n\n// ─── Agent Members ────────────────────────────────────────────────────────────\n// Non-admin users granted web UI access to a specific agent.\n// Admins (SHIPWRIGHT_ADMIN_ALLOWED_EMAILS) bypass this table entirely.\n// Members can view and edit only the agents they are listed on.\n\nmodel AgentMember {\n id String @id @default(cuid())\n agentId String\n email String\n createdAt DateTime @default(now())\n agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade)\n\n @@unique([agentId, email])\n @@index([email])\n}\n\n// ─── Accounts ─────────────────────────────────────────────────────────────────\n// SSP-1.2: self-serve agent provisioning. An Account owns agents and has\n// members (email-keyed) and pending invites. Phase 1: one account per email\n// (UNIQUE(email) on AccountMember). Enum-like strings are validated in the\n// service layer, matching the rest of this schema.\n\nmodel Account {\n id String @id @default(cuid())\n name String\n status String @default(\"active\") // 'active' | 'suspended' | 'trial_expired'\n maxAgents Int @default(0)\n plan String?\n trialExpiresAt DateTime?\n trialExpiryWarnedAt DateTime?\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n agents Agent[]\n members AccountMember[]\n invites AccountInvite[]\n}\n\nmodel AccountMember {\n id String @id @default(cuid())\n accountId String\n email String @unique // lowercased; one account per email in Phase 1\n role String // 'owner' | 'member'\n createdAt DateTime @default(now())\n account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)\n\n @@unique([accountId, email])\n}\n\nmodel AccountInvite {\n id String @id @default(cuid())\n accountId String\n email String // lowercased\n invitedBy String\n createdAt DateTime @default(now())\n acceptedAt DateTime?\n account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)\n\n @@unique([accountId, email])\n @@index([email])\n}\n\n// ─── Agent Cron Runs ──────────────────────────────────────────────────────────\n// Execution history for AgentCronJob firings. Each row records one cron invocation:\n// whether it was skipped (pre-check returned false), the outcome, and any error.\n// agentId is denormalized for efficient agent-level history queries.\n\nmodel AgentCronRun {\n id String @id @default(cuid())\n cronId String\n cron AgentCronJob @relation(fields: [cronId], references: [id], onDelete: Cascade)\n agentId String // denormalized from cron.agentId for efficient queries\n startedAt DateTime\n completedAt DateTime?\n skipped Boolean @default(false)\n skipReason String?\n outcome String?\n error String?\n itemType String? // work item type this run was dispatched against (\"task\" | \"pr\");\n // null when the tick had no dispatch (skipped tick, empty queue)\n itemId String? // work item id this run was dispatched against (e.g. \"WLS-2.2\" or\n // \"acme/x#123\"); null when the tick had no dispatch\n commandVariant String? // which slash-command variant a loop dispatch ran (\"default\" | \"alternate\");\n // null for runs with no variant attribution\n sessionId String? // Claude session id this cron run corresponds to\n lastHeartbeatAt DateTime? // Most recent debounced progress-push (recordProgress())\n // timestamp, sourced from the agent's injected Clock. Null\n // for runs that never reported progress (short/legacy runs).\n // Lets the admin UI show a \"last checked in\" signal — and a\n // best-effort in-progress duration — for a run that stalls\n // or crashes before reaching completedAt. Additive/nullable;\n // no backfill for existing rows.\n createdAt DateTime @default(now())\n\n // Secondary/denormalized pointer to the phase cron (a child AgentCronJob)\n // this run was dispatched by, distinct from cronId which always points at\n // the top-level cron. Null until phase crons are wired up (LPC-1.2+).\n // SetNull (not Cascade) on delete: deleting a phase cron shouldn't delete\n // the parent cron's run history, which cronId's own Cascade already owns.\n phaseId String?\n phaseCron AgentCronJob? @relation(\"PhaseRuns\", fields: [phaseId], references: [id], onDelete: SetNull)\n\n // ── Run telemetry (prompt-audit metrics; all additive/nullable) ──────────\n // First-turn context baseline: the usage of the run's first assistant\n // message. baselineContextTokens = input + cacheCreation + cacheRead of\n // that turn — the full always-loaded context (system prompt, tool schemas,\n // CLAUDE.md/rules, skill listing, cron prompt) independent of cache\n // warmth. Null for resumed sessions (their first turn replays history)\n // and for runs reported by older agent builds.\n baselineModel String?\n baselineContextTokens Int?\n baselineInputTokens Int?\n baselineCacheCreationTokens Int?\n baselineCacheReadTokens Int?\n // Distinct usage-bearing assistant turns / distinct tool_use blocks.\n turns Int?\n toolCalls Int?\n // sha256[:12] over the workspace's always-loaded markdown (CLAUDE.md, its\n // @imports, no-`paths` rules) + plugin version — groups runs by what the\n // model was given, so before/after series for a markdown change key on\n // content rather than merge date. Computed agent-side.\n contextFingerprint String?\n pluginVersion String?\n claudeCodeVersion String?\n\n modelBreakdown AgentCronRunModelBreakdown[]\n skillUsage AgentCronRunSkillUsage[]\n\n @@index([cronId])\n @@index([agentId, startedAt])\n @@index([phaseId])\n @@index([startedAt])\n @@index([contextFingerprint])\n}\n\n// ─── Agent Cron Run Skill Usage ──────────────────────────────────────────────\n// Per-skill / per-subagent token attribution for a cron run, derived from the\n// Claude CLI stream by the agent. `kind` is \"skill\" (a plugin\n// skill invoked via the Skill tool), \"agent\" (a subagent type invoked via the\n// Agent tool), or \"root\" (turns before any skill was invoked). Attribution is\n// last-wins: every turn after an invoke is charged to the most recently\n// invoked skill. invokeContextDelta is input + cacheCreation of the first\n// turn after the skill's first invoke — the new context admitted when its\n// body loaded. The unique constraint on [cronRunId, kind, name] allows upsert.\n\nmodel AgentCronRunSkillUsage {\n id String @id @default(cuid())\n cronRunId String\n kind String\n name String\n invocations Int @default(0)\n turns Int @default(0)\n inputTokens Int @default(0)\n outputTokens Int @default(0)\n cacheReadTokens Int @default(0)\n cacheCreationTokens Int @default(0)\n invokeContextDelta Int?\n cronRun AgentCronRun @relation(fields: [cronRunId], references: [id], onDelete: Cascade)\n\n @@unique([cronRunId, kind, name])\n @@index([kind, name])\n}\n\n// ─── Agent Cron Run Model Breakdown ──────────────────────────────────────────\n// Per-model token breakdown for a cron run. Populated when sub-agents use\n// different models in a single run. The unique constraint on [cronRunId, model]\n// allows upsert semantics.\n\nmodel AgentCronRunModelBreakdown {\n id String @id @default(cuid())\n cronRunId String\n model String\n inputTokens Int @default(0)\n outputTokens Int @default(0)\n cacheReadTokens Int @default(0)\n cacheCreationTokens Int @default(0)\n costUsd Float @default(0)\n cronRun AgentCronRun @relation(fields: [cronRunId], references: [id], onDelete: Cascade)\n\n @@unique([cronRunId, model])\n}\n\n// ─── Agent Chat Token Usage (Daily, per Model) ───────────────────────────────\n// Daily rollup table for Slack/chat session token usage per agent per model.\n// Accumulated atomically via INSERT ... ON CONFLICT ... DO UPDATE (no read-modify-write).\n// Totals and byAgent aggregations are computed by summing across models.\n\nmodel AgentChatTokenUsageDailyByModel {\n id String @id @default(cuid())\n agentId String\n agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade)\n date String // YYYY-MM-DD\n model String // e.g. \"claude-sonnet-4-5\"\n inputTokens BigInt @default(0)\n outputTokens BigInt @default(0)\n cacheReadTokens BigInt @default(0)\n cacheCreationTokens BigInt @default(0)\n costUsd Float @default(0)\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n @@unique([agentId, date, model])\n}\n\n// ─── Agent Ask ──────────────────────────────────────────────────────────────\n// Audit row per agent-to-agent ask (COS-31.1, POST /agents/:id/ask). Written\n// before the chat call and updated after it. The question text is never\n// stored. No relation to Agent on purpose: the audit trail outlives either\n// agent. threadId is null until the chat thread is created (and stays null\n// when that call fails). status: created | pending | replied | failed.\n\nmodel AgentAsk {\n id String @id @default(cuid())\n fromAgentId String\n toAgentId String\n threadId String?\n depth Int\n waitMs Int\n ttlSeconds Int\n status String\n callerLabel String\n createdAt DateTime @default(now())\n repliedAt DateTime?\n\n @@index([toAgentId, createdAt])\n @@index([fromAgentId, createdAt])\n}\n\n// ─── Agent Cron Run Requests ────────────────────────────────────────────────\n// Audit row per POST /agents/{id}/crons/{cronId}/run-now (COS-42.1), written\n// before the 202 returns. status: requested | honoured | expired. Holds no\n// prompt text. honouredRunId links the AgentCronRun the runtime created.\n\nmodel AgentCronRunRequest {\n id String @id @default(cuid())\n cronId String\n agentId String\n requestedBy String\n requestedAt DateTime\n status String @default(\"requested\")\n honouredRunId String?\n createdAt DateTime @default(now())\n\n @@index([cronId, requestedAt])\n @@index([agentId, requestedAt])\n}\n\n// ─── Cron-run transcripts (COS-43.1) ────────────────────────────────────────\n// One redacted session transcript per cron run, uploaded by the agent runtime\n// (already redacted + tail-truncated to 1 MiB) and served only to admin /\n// platform callers behind SHIPWRIGHT_TRANSCRIPT_API_ENABLED. No FK on runId:\n// the retention sweep (newest 200 per agent) is the only lifecycle. The body\n// is never logged. AgentTranscriptRead is the audit row written before every\n// transcript read.\n\nmodel AgentCronRunTranscript {\n id String @id @default(cuid())\n runId String @unique\n agentId String\n cronId String\n sessionId String?\n bytes Int\n truncated Boolean\n redactions Int\n body String @db.Text\n createdAt DateTime @default(now())\n\n @@index([agentId, createdAt])\n}\n\nmodel AgentTranscriptRead {\n id String @id @default(cuid())\n runId String\n agentId String\n reader String\n scopeKind String\n at DateTime @default(now())\n\n @@index([agentId, at])\n}\n\n// ─── Agent GitHub Installations Snapshot ────────────────────────────────────\n// One row per agent holding the agent's most recently reported GitHub App\n// installations (owner, installation id, state, sanitized lastError). PUT\n// replaces the whole list — a dropped installation disappears. No tokens or\n// other credentials are ever stored here.\n\nmodel AgentGitHubInstallationsSnapshot {\n id String @id @default(cuid())\n agentId String @unique\n agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade)\n reportedAt DateTime\n installations Json // GitHubInstallation[]\n repoAccess Json? // RepoAccessEntry[] — per-repo read/write probe (COS-44.1); null until a flagged runtime reports it\n createdAt DateTime @default(now())\n}\n\n// ─── Push Subscription ──────────────────────────────────────────────────────\n// A single browser/device Web Push subscription (RFC 8291) belonging to a\n// user. One user may have many rows (multi-device). Keyed by the unique push\n// `endpoint`; `p256dh` and `auth` are the subscription's public key + auth\n// secret used to encrypt the payload. `detailOptIn` is the USER'S opt-in for\n// how much notification detail they want on a locked screen — the effective\n// level is min(operator ceiling SHIPWRIGHT_ADMIN_PUSH_MAX_DETAIL, detailOptIn).\n// Values: \"generic\" | \"title\" | \"preview\". Never carries an agentId — targeting\n// is by userEmail via ChatThreadWatch, so a subscription is agent-agnostic.\nmodel PushSubscription {\n id String @id @default(cuid())\n userEmail String\n endpoint String @unique\n p256dh String // base64url of the UA public key\n auth String // base64url of the UA auth secret\n detailOptIn String @default(\"generic\") // user opt-in: generic | title | preview\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n @@index([userEmail])\n}\n\n// ─── Chat Thread Watch ──────────────────────────────────────────────────────\n// Records that a user is \"watching\" a chat thread so the agent's reply can be\n// targeted to exactly the right subscriber(s) without fanning out to every\n// member of the agent. Upserted whenever a user sends a message in a thread.\n// The chat Message model has no author column, and admin is the only web front\n// door that knows both userEmail and threadId at send time — so authorship\n// targeting lives here, not in the chat service.\nmodel ChatThreadWatch {\n id String @id @default(cuid())\n userEmail String\n threadId String\n agentId String\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n @@unique([userEmail, threadId])\n @@index([threadId])\n}\n\n// ─── User Notification Prefs ─────────────────────────────────────────────────\n// One row per user, holding their notification preferences. `userEmail` is\n// the primary key (one row per user, not per session). `autoFollowSessions`\n// controls whether sessions *visible to* the user (any session in an agent or\n// repo they can see — not only sessions they started) are auto-followed by\n// session-alert-sweeper.ts; `autoFollowSince` records when that behavior was\n// turned on (null if never explicitly set), and the sweeper skips\n// auto-following sessions that were already waiting before that moment. When\n// it is null the sweeper falls back to this row's `createdAt` as the boundary\n// — `autoFollowSessions` defaults to true and a row is created just by loading\n// the settings page, so \"null\" must not mean \"no boundary\", or the first sweep\n// would back-follow and immediately push the whole waiting backlog. Rows that\n// predate the sweeper were stamped once by\n// 20260911000000_backfill_auto_follow_since.\n// `reminderHourLocal` is validated to be an integer in\n// [0, 23] at the service layer (session-follow-service.ts), not here — Prisma\n// has no native CHECK constraint support in this schema style.\nmodel UserNotificationPrefs {\n userEmail String @id\n autoFollowSessions Boolean @default(true)\n reminderHourLocal Int @default(9)\n autoFollowSince DateTime?\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n}\n",
23
+ "inlineSchema": "// Shipwright — Admin Service Schema: coding-owned models (AEP-3.4)\n//\n// Part of the admin Prisma multi-file schema (prisma/schema/). These models\n// belong to the coding feature surface and are isolated here so the coding\n// code can later be extracted. They share the single `db` datasource and\n// generator declared in schema.prisma, so the generated client and the\n// migrations are unchanged by this split.\n//\n// Coding-owned columns that live on the shared Agent model (schema.prisma)\n// and are grouped/commented there: reviewAuthorAllowlist, patchAuthorAllowlist,\n// autoPostReviews, allowSelfReview, minConfidence, maxFindings.\n\n// ─── Agent Work Queue Snapshot ────────────────────────────────────────────────\n// One row per agent, holding the agent's most recently pushed work-queue\n// snapshot (its ranked view of pending tasks/PRs across pipeline phases).\n// POST /agents/:id/work-queue upserts this single row, overwriting any prior\n// snapshot — there is no history, only the latest state.\n\nmodel AgentWorkQueueSnapshot {\n id String @id @default(cuid())\n agentId String @unique\n agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade)\n computedAt DateTime\n items Json // RankedWorkItem[]\n createdAt DateTime @default(now())\n}\n\n// ─── Agent Phase Methodology ──────────────────────────────────────────────────\n// Per-phase subagent-type override. Each row records which subagent type\n// (e.g. a custom Specialty name) should handle one pipeline phase — prd,\n// plan-session, review, patch, deploy, or dev-task — for a given agent,\n// overriding that phase's default methodology. `subagentType` is nullable:\n// a row with a null value (or the absence of a row) means \"use the default\".\n// The `phase` value is constrained to the six-value enum at the service/route\n// layer (Zod), not here — Postgres/Prisma has no native enum-column\n// requirement in this schema style (mirrors AgentTool.pattern / AgentCronJob\n// validation living at the service layer). The unique constraint on\n// [agentId, phase] makes upsert-based writes idempotent (one override row per\n// phase per agent), mirroring AgentTool's [agentId, pattern] and AgentPlugin's\n// [agentId, name].\n\nmodel AgentPhaseMethodology {\n id String @id @default(cuid())\n agentId String\n agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade)\n phase String\n subagentType String?\n updatedAt DateTime @updatedAt\n\n @@unique([agentId, phase])\n}\n\n// ─── Session Follow ───────────────────────────────────────────────────────────\n// Records that a user is \"following\" a planning session (identified by its\n// slug) so they can receive reminders/notifications about it. `muted` lets a\n// user stay following (kept in listByUser) while suppressing alerts, distinct\n// from unfollowing (row deleted entirely). The unique constraint on\n// [userEmail, sessionSlug] makes follow() idempotent via upsert.\nmodel SessionFollow {\n id String @id @default(cuid())\n userEmail String\n sessionSlug String\n muted Boolean @default(false)\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n @@unique([userEmail, sessionSlug])\n @@index([sessionSlug])\n}\n\n// ─── Session Alert State ─────────────────────────────────────────────────────\n// Tracks the last time a user was alerted about a given session, so a\n// reminder job can avoid re-alerting the same user/session pair within a\n// cooldown window. The unique constraint on [userEmail, sessionSlug] allows\n// upsert semantics (one state row per user per session).\nmodel SessionAlertState {\n id String @id @default(cuid())\n userEmail String\n sessionSlug String\n lastAlertedAt DateTime?\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n @@unique([userEmail, sessionSlug])\n}\n\n// Shipwright — Admin Service Schema\n// Owns: Agent, AgentEnv, AgentCronJob, AgentTool, AgentToken, AgentPlugin,\n// plus the coding-owned models in coding.prisma (AgentWorkQueueSnapshot,\n// AgentPhaseMethodology, SessionFollow, SessionAlertState).\n// Agent is a first-class model (standalone, not embedded in a User model).\n//\n// WARNING: This schema uses DATABASE_URL_SHIPWRIGHT_ADMIN — a dedicated database\n// for the admin service. Do NOT point this at a shared database.\n\n// Prisma 7: the `prisma-client` generator (ESM, driver-adapter based) replaces\n// `prisma-client-js`, and the datasource block no longer accepts `url` — the\n// connection string for CLI/Migrate lives in ../../prisma.config.ts, and the\n// runtime client connects through a PrismaPg driver adapter (see\n// admin/src/prisma-client.ts).\ngenerator client {\n provider = \"prisma-client\"\n output = \"../client\"\n}\n\ndatasource db {\n provider = \"postgresql\"\n}\n\n// ─── Agent ────────────────────────────────────────────────────────────────────\n// A Shipwright agent: an autonomous runner that picks tasks, builds, and ships PRs.\n\nmodel Agent {\n id String @id @default(cuid())\n name String\n slackId String? @unique\n slackBotToken String? // AES-256-GCM encrypted at the service layer\n anthropicApiKey String? // AES-256-GCM encrypted at the service layer\n selfHosted Boolean @default(false)\n repos String[] @default([])\n // DBR-2.1→2.4: renamed from authorAllowlist. DBR-2.1 added this column as\n // a dual-written/dual-read twin of the old authorAllowlist column and\n // backfilled it; DBR-2.2/2.3 migrated the admin UI and the agent's\n // runtime read path onto this field exclusively; DBR-2.4 dropped the old\n // authorAllowlist column now that nothing reads or writes it.\n // CODING-OWNED (AEP-3.4): reviewAuthorAllowlist and patchAuthorAllowlist are\n // read by the coding review/patch flows. Kept on Agent (no column move).\n reviewAuthorAllowlist String[] @default([])\n patchAuthorAllowlist String[] @default([])\n restrictSlackToMembers Boolean @default(false)\n // The agent's Specialty name (manifest key). AGR-6.3 made it required and\n // dropped the legacy typeName column.\n specialty String @default(\"specialist\")\n // SSP-1.2: owning account. Nullable/additive — existing agents keep NULL.\n accountId String?\n account Account? @relation(fields: [accountId], references: [id], onDelete: Restrict)\n // ATE-1.1: trial expiry tracking. Both nullable/additive — null means \"no\n // trial configured\" / \"no warning sent yet\". trialExpiresAt is settable via\n // PATCH /agents/:id like other nullable scalar fields; trialExpiryWarnedAt\n // is written internally by ATE-2.1's warning check (not user-editable via\n // the PATCH route) but is included in GET/PATCH responses.\n trialExpiresAt DateTime?\n trialExpiryWarnedAt DateTime?\n // PAI-1.1: owner and Anthropic identity. All nullable/additive — existing\n // agents keep NULL. anthropicIdentityName is unique across agents.\n ownerEmail String?\n anthropicIdentityName String? @unique\n anthropicServiceAccountId String?\n anthropicFederationRuleId String?\n k8sServiceAccountName String?\n // COS-48.1: the pod's own Kubernetes ServiceAccount (cluster identity). A\n // separate column from k8sServiceAccountName, which is the Anthropic-\n // federation KSA. Only rendered when the pod-SA flag is on.\n podServiceAccountName String?\n // APM-1.1: the 6 agent policy fields (auto_post_reviews, allow_self_review,\n // min_confidence, max_findings, cleanup_merged_worktrees,\n // cleanup_after_days). Originally mirrored from the legacy\n // state/agent-policy.md; the DB is now the only source and each column\n // carries its documented default.\n // CODING-OWNED (AEP-3.4): autoPostReviews, allowSelfReview, minConfidence,\n // maxFindings configure the coding review flow; cleanupMergedWorktrees and\n // cleanupAfterDays below are runtime-owned.\n autoPostReviews Boolean @default(true)\n allowSelfReview Boolean @default(false)\n minConfidence Int @default(75)\n maxFindings Int @default(5)\n cleanupMergedWorktrees Boolean @default(true)\n cleanupAfterDays Int @default(14)\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n envVars AgentEnv[]\n cronJobs AgentCronJob[]\n tools AgentTool[]\n tokens AgentToken[]\n plugins AgentPlugin[]\n members AgentMember[]\n chatTokenUsage AgentChatTokenUsageDailyByModel[]\n workQueueSnapshot AgentWorkQueueSnapshot?\n githubInstallationsSnapshot AgentGitHubInstallationsSnapshot?\n phaseMethodologies AgentPhaseMethodology[]\n\n @@index([accountId])\n}\n\n// ─── Agent Env ────────────────────────────────────────────────────────────────\n// Generic key/value env var store for deployed agents.\n// Keys are env var names (e.g. ANTHROPIC_API_KEY). Values are AES-256-GCM encrypted.\n\nmodel AgentEnv {\n id String @id @default(cuid())\n agentId String\n agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade)\n key String // env var name\n value String // AES-256-GCM encrypted\n secret Boolean @default(false) // when true, GET /agents/:id/envs masks the value as ***\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n @@unique([agentId, key])\n}\n\n// ─── Agent Cron Jobs ──────────────────────────────────────────────────────────\n// Scheduled prompts per agent.\n// Delivery target is either a Slack channel ID or a Slack user ID (DM).\n// Validation that exactly one of channel/user is set happens in the service layer.\n\nmodel AgentCronJob {\n id String @id @default(cuid())\n agentId String\n agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade)\n schedule String // cron expression e.g. \"0 9 * * 1-5\"\n prompt String\n channel String? // Slack channel ID (mutually exclusive with user)\n user String? // Slack user ID for DMs\n silent Boolean @default(false)\n enabled Boolean @default(true)\n preCheck String? // optional pre-check script e.g. \"shipwright:check-docs-freshness.ts\"\n name String? // stable key for system crons; null for user-created crons\n system Boolean @default(false)\n // Set when the trial-expiry lockdown (not the user) disabled this cron, so\n // restoreLockdownDisabled() can re-enable exactly those crons. Null for\n // enabled crons and for crons the user disabled manually.\n lockdownDisabledAt DateTime?\n // Run-now request (COS-42.1): stamped by POST .../run-now, cleared when the\n // runtime creates the run (commandVariant \"run-now\"). The runtime honours a\n // stamp at most once and only while it is fresh (15 minutes).\n runRequestedAt DateTime?\n runRequestedBy String?\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n // Self-relation: a parent cron can have child \"phase\" crons (e.g. dev-task/\n // review/patch/deploy phases of a single logical loop). Null for top-level\n // crons. Deleting a parent cascades to its phase children.\n parentCronId String?\n parentCron AgentCronJob? @relation(\"CronPhases\", fields: [parentCronId], references: [id], onDelete: Cascade)\n phases AgentCronJob[] @relation(\"CronPhases\")\n\n runs AgentCronRun[]\n phaseRuns AgentCronRun[] @relation(\"PhaseRuns\")\n\n @@index([parentCronId])\n}\n\n// ─── Agent Tools ──────────────────────────────────────────────────────────────\n// Allowed tool patterns per agent. Each row grants an agent permission to use\n// a specific tool pattern (e.g. \"Read\", \"Bash\", \"Write\"). The unique constraint\n// on [agentId, pattern] prevents duplicates.\n\nmodel AgentTool {\n id String @id @default(cuid())\n agentId String\n agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade)\n pattern String\n enabled Boolean @default(true)\n createdAt DateTime @default(now())\n\n @@unique([agentId, pattern])\n}\n\n// ─── Agent Tokens ─────────────────────────────────────────────────────────────\n// Scoped API tokens for agent authentication. The raw token is returned once at\n// creation; only the SHA-256 hash is stored. Token validation is O(1) via the\n// hash index.\n\nmodel AgentToken {\n id String @id @default(cuid())\n agentId String\n agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade)\n token String @unique // SHA-256 hash of the raw token (hex)\n label String?\n createdAt DateTime @default(now())\n revokedAt DateTime?\n}\n\n// ─── Agent Plugins ────────────────────────────────────────────────────────────\n// Claude Code plugins installed per agent. Each row represents one installed\n// plugin package. The unique constraint on [agentId, name] prevents duplicates.\n\nmodel AgentPlugin {\n id String @id @default(cuid())\n agentId String\n agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade)\n name String // plugin package name e.g. \"@shipwright/plugin\"\n version String? // pinned version; null = latest\n enabled Boolean @default(true)\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n @@unique([agentId, name])\n}\n\n// ─── Agent Members ────────────────────────────────────────────────────────────\n// Non-admin users granted web UI access to a specific agent.\n// Admins (SHIPWRIGHT_ADMIN_ALLOWED_EMAILS) bypass this table entirely.\n// Members can view and edit only the agents they are listed on.\n\nmodel AgentMember {\n id String @id @default(cuid())\n agentId String\n email String\n createdAt DateTime @default(now())\n agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade)\n\n @@unique([agentId, email])\n @@index([email])\n}\n\n// ─── Accounts ─────────────────────────────────────────────────────────────────\n// SSP-1.2: self-serve agent provisioning. An Account owns agents and has\n// members (email-keyed) and pending invites. Phase 1: one account per email\n// (UNIQUE(email) on AccountMember). Enum-like strings are validated in the\n// service layer, matching the rest of this schema.\n\nmodel Account {\n id String @id @default(cuid())\n name String\n status String @default(\"active\") // 'active' | 'suspended' | 'trial_expired'\n maxAgents Int @default(0)\n plan String?\n trialExpiresAt DateTime?\n trialExpiryWarnedAt DateTime?\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n agents Agent[]\n members AccountMember[]\n invites AccountInvite[]\n}\n\nmodel AccountMember {\n id String @id @default(cuid())\n accountId String\n email String @unique // lowercased; one account per email in Phase 1\n role String // 'owner' | 'member'\n createdAt DateTime @default(now())\n account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)\n\n @@unique([accountId, email])\n}\n\nmodel AccountInvite {\n id String @id @default(cuid())\n accountId String\n email String // lowercased\n invitedBy String\n createdAt DateTime @default(now())\n acceptedAt DateTime?\n account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)\n\n @@unique([accountId, email])\n @@index([email])\n}\n\n// ─── Agent Cron Runs ──────────────────────────────────────────────────────────\n// Execution history for AgentCronJob firings. Each row records one cron invocation:\n// whether it was skipped (pre-check returned false), the outcome, and any error.\n// agentId is denormalized for efficient agent-level history queries.\n\nmodel AgentCronRun {\n id String @id @default(cuid())\n cronId String\n cron AgentCronJob @relation(fields: [cronId], references: [id], onDelete: Cascade)\n agentId String // denormalized from cron.agentId for efficient queries\n startedAt DateTime\n completedAt DateTime?\n skipped Boolean @default(false)\n skipReason String?\n outcome String?\n error String?\n itemType String? // work item type this run was dispatched against (\"task\" | \"pr\");\n // null when the tick had no dispatch (skipped tick, empty queue)\n itemId String? // work item id this run was dispatched against (e.g. \"WLS-2.2\" or\n // \"acme/x#123\"); null when the tick had no dispatch\n commandVariant String? // which slash-command variant a loop dispatch ran (\"default\" | \"alternate\");\n // null for runs with no variant attribution\n sessionId String? // Claude session id this cron run corresponds to\n lastHeartbeatAt DateTime? // Most recent debounced progress-push (recordProgress())\n // timestamp, sourced from the agent's injected Clock. Null\n // for runs that never reported progress (short/legacy runs).\n // Lets the admin UI show a \"last checked in\" signal — and a\n // best-effort in-progress duration — for a run that stalls\n // or crashes before reaching completedAt. Additive/nullable;\n // no backfill for existing rows.\n createdAt DateTime @default(now())\n\n // Secondary/denormalized pointer to the phase cron (a child AgentCronJob)\n // this run was dispatched by, distinct from cronId which always points at\n // the top-level cron. Null until phase crons are wired up (LPC-1.2+).\n // SetNull (not Cascade) on delete: deleting a phase cron shouldn't delete\n // the parent cron's run history, which cronId's own Cascade already owns.\n phaseId String?\n phaseCron AgentCronJob? @relation(\"PhaseRuns\", fields: [phaseId], references: [id], onDelete: SetNull)\n\n // ── Run telemetry (prompt-audit metrics; all additive/nullable) ──────────\n // First-turn context baseline: the usage of the run's first assistant\n // message. baselineContextTokens = input + cacheCreation + cacheRead of\n // that turn — the full always-loaded context (system prompt, tool schemas,\n // CLAUDE.md/rules, skill listing, cron prompt) independent of cache\n // warmth. Null for resumed sessions (their first turn replays history)\n // and for runs reported by older agent builds.\n baselineModel String?\n baselineContextTokens Int?\n baselineInputTokens Int?\n baselineCacheCreationTokens Int?\n baselineCacheReadTokens Int?\n // Distinct usage-bearing assistant turns / distinct tool_use blocks.\n turns Int?\n toolCalls Int?\n // sha256[:12] over the workspace's always-loaded markdown (CLAUDE.md, its\n // @imports, no-`paths` rules) + plugin version — groups runs by what the\n // model was given, so before/after series for a markdown change key on\n // content rather than merge date. Computed agent-side.\n contextFingerprint String?\n pluginVersion String?\n claudeCodeVersion String?\n\n modelBreakdown AgentCronRunModelBreakdown[]\n skillUsage AgentCronRunSkillUsage[]\n\n @@index([cronId])\n @@index([agentId, startedAt])\n @@index([phaseId])\n @@index([startedAt])\n @@index([contextFingerprint])\n}\n\n// ─── Agent Cron Run Skill Usage ──────────────────────────────────────────────\n// Per-skill / per-subagent token attribution for a cron run, derived from the\n// Claude CLI stream by the agent. `kind` is \"skill\" (a plugin\n// skill invoked via the Skill tool), \"agent\" (a subagent type invoked via the\n// Agent tool), or \"root\" (turns before any skill was invoked). Attribution is\n// last-wins: every turn after an invoke is charged to the most recently\n// invoked skill. invokeContextDelta is input + cacheCreation of the first\n// turn after the skill's first invoke — the new context admitted when its\n// body loaded. The unique constraint on [cronRunId, kind, name] allows upsert.\n\nmodel AgentCronRunSkillUsage {\n id String @id @default(cuid())\n cronRunId String\n kind String\n name String\n invocations Int @default(0)\n turns Int @default(0)\n inputTokens Int @default(0)\n outputTokens Int @default(0)\n cacheReadTokens Int @default(0)\n cacheCreationTokens Int @default(0)\n invokeContextDelta Int?\n cronRun AgentCronRun @relation(fields: [cronRunId], references: [id], onDelete: Cascade)\n\n @@unique([cronRunId, kind, name])\n @@index([kind, name])\n}\n\n// ─── Agent Cron Run Model Breakdown ──────────────────────────────────────────\n// Per-model token breakdown for a cron run. Populated when sub-agents use\n// different models in a single run. The unique constraint on [cronRunId, model]\n// allows upsert semantics.\n\nmodel AgentCronRunModelBreakdown {\n id String @id @default(cuid())\n cronRunId String\n model String\n inputTokens Int @default(0)\n outputTokens Int @default(0)\n cacheReadTokens Int @default(0)\n cacheCreationTokens Int @default(0)\n costUsd Float @default(0)\n cronRun AgentCronRun @relation(fields: [cronRunId], references: [id], onDelete: Cascade)\n\n @@unique([cronRunId, model])\n}\n\n// ─── Agent Chat Token Usage (Daily, per Model) ───────────────────────────────\n// Daily rollup table for Slack/chat session token usage per agent per model.\n// Accumulated atomically via INSERT ... ON CONFLICT ... DO UPDATE (no read-modify-write).\n// Totals and byAgent aggregations are computed by summing across models.\n\nmodel AgentChatTokenUsageDailyByModel {\n id String @id @default(cuid())\n agentId String\n agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade)\n date String // YYYY-MM-DD\n model String // e.g. \"claude-sonnet-4-5\"\n inputTokens BigInt @default(0)\n outputTokens BigInt @default(0)\n cacheReadTokens BigInt @default(0)\n cacheCreationTokens BigInt @default(0)\n costUsd Float @default(0)\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n @@unique([agentId, date, model])\n}\n\n// ─── Agent Ask ──────────────────────────────────────────────────────────────\n// Audit row per agent-to-agent ask (COS-31.1, POST /agents/:id/ask). Written\n// before the chat call and updated after it. The question text is never\n// stored. No relation to Agent on purpose: the audit trail outlives either\n// agent. threadId is null until the chat thread is created (and stays null\n// when that call fails). status: created | pending | replied | failed.\n\nmodel AgentAsk {\n id String @id @default(cuid())\n fromAgentId String\n toAgentId String\n threadId String?\n depth Int\n waitMs Int\n ttlSeconds Int\n status String\n callerLabel String\n createdAt DateTime @default(now())\n repliedAt DateTime?\n\n @@index([toAgentId, createdAt])\n @@index([fromAgentId, createdAt])\n}\n\n// ─── Agent Cron Run Requests ────────────────────────────────────────────────\n// Audit row per POST /agents/{id}/crons/{cronId}/run-now (COS-42.1), written\n// before the 202 returns. status: requested | honoured | expired. Holds no\n// prompt text. honouredRunId links the AgentCronRun the runtime created.\n\nmodel AgentCronRunRequest {\n id String @id @default(cuid())\n cronId String\n agentId String\n requestedBy String\n requestedAt DateTime\n status String @default(\"requested\")\n honouredRunId String?\n createdAt DateTime @default(now())\n\n @@index([cronId, requestedAt])\n @@index([agentId, requestedAt])\n}\n\n// ─── Cron-run transcripts (COS-43.1) ────────────────────────────────────────\n// One redacted session transcript per cron run, uploaded by the agent runtime\n// (already redacted + tail-truncated to 1 MiB) and served only to admin /\n// platform callers behind SHIPWRIGHT_TRANSCRIPT_API_ENABLED. No FK on runId:\n// the retention sweep (newest 200 per agent) is the only lifecycle. The body\n// is never logged. AgentTranscriptRead is the audit row written before every\n// transcript read.\n\nmodel AgentCronRunTranscript {\n id String @id @default(cuid())\n runId String @unique\n agentId String\n cronId String\n sessionId String?\n bytes Int\n truncated Boolean\n redactions Int\n body String @db.Text\n createdAt DateTime @default(now())\n\n @@index([agentId, createdAt])\n}\n\nmodel AgentTranscriptRead {\n id String @id @default(cuid())\n runId String\n agentId String\n reader String\n scopeKind String\n at DateTime @default(now())\n\n @@index([agentId, at])\n}\n\n// ─── Agent GitHub Installations Snapshot ────────────────────────────────────\n// One row per agent holding the agent's most recently reported GitHub App\n// installations (owner, installation id, state, sanitized lastError). PUT\n// replaces the whole list — a dropped installation disappears. No tokens or\n// other credentials are ever stored here.\n\nmodel AgentGitHubInstallationsSnapshot {\n id String @id @default(cuid())\n agentId String @unique\n agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade)\n reportedAt DateTime\n installations Json // GitHubInstallation[]\n repoAccess Json? // RepoAccessEntry[] — per-repo read/write probe (COS-44.1); null until a flagged runtime reports it\n createdAt DateTime @default(now())\n}\n\n// ─── Push Subscription ──────────────────────────────────────────────────────\n// A single browser/device Web Push subscription (RFC 8291) belonging to a\n// user. One user may have many rows (multi-device). Keyed by the unique push\n// `endpoint`; `p256dh` and `auth` are the subscription's public key + auth\n// secret used to encrypt the payload. `detailOptIn` is the USER'S opt-in for\n// how much notification detail they want on a locked screen — the effective\n// level is min(operator ceiling SHIPWRIGHT_ADMIN_PUSH_MAX_DETAIL, detailOptIn).\n// Values: \"generic\" | \"title\" | \"preview\". Never carries an agentId — targeting\n// is by userEmail via ChatThreadWatch, so a subscription is agent-agnostic.\nmodel PushSubscription {\n id String @id @default(cuid())\n userEmail String\n endpoint String @unique\n p256dh String // base64url of the UA public key\n auth String // base64url of the UA auth secret\n detailOptIn String @default(\"generic\") // user opt-in: generic | title | preview\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n @@index([userEmail])\n}\n\n// ─── Chat Thread Watch ──────────────────────────────────────────────────────\n// Records that a user is \"watching\" a chat thread so the agent's reply can be\n// targeted to exactly the right subscriber(s) without fanning out to every\n// member of the agent. Upserted whenever a user sends a message in a thread.\n// The chat Message model has no author column, and admin is the only web front\n// door that knows both userEmail and threadId at send time — so authorship\n// targeting lives here, not in the chat service.\nmodel ChatThreadWatch {\n id String @id @default(cuid())\n userEmail String\n threadId String\n agentId String\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n\n @@unique([userEmail, threadId])\n @@index([threadId])\n}\n\n// ─── User Notification Prefs ─────────────────────────────────────────────────\n// One row per user, holding their notification preferences. `userEmail` is\n// the primary key (one row per user, not per session). `autoFollowSessions`\n// controls whether sessions *visible to* the user (any session in an agent or\n// repo they can see — not only sessions they started) are auto-followed by\n// session-alert-sweeper.ts; `autoFollowSince` records when that behavior was\n// turned on (null if never explicitly set), and the sweeper skips\n// auto-following sessions that were already waiting before that moment. When\n// it is null the sweeper falls back to this row's `createdAt` as the boundary\n// — `autoFollowSessions` defaults to true and a row is created just by loading\n// the settings page, so \"null\" must not mean \"no boundary\", or the first sweep\n// would back-follow and immediately push the whole waiting backlog. Rows that\n// predate the sweeper were stamped once by\n// 20260911000000_backfill_auto_follow_since.\n// `reminderHourLocal` is validated to be an integer in\n// [0, 23] at the service layer (session-follow-service.ts), not here — Prisma\n// has no native CHECK constraint support in this schema style.\nmodel UserNotificationPrefs {\n userEmail String @id\n autoFollowSessions Boolean @default(true)\n reminderHourLocal Int @default(9)\n autoFollowSince DateTime?\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n}\n",
24
24
  "runtimeDataModel": {
25
25
  "models": {},
26
26
  "enums": {},
@@ -0,0 +1,4 @@
1
+ -- PCD-3.4: change the Agent.specialty column default from 'coding' to 'specialist'.
2
+ -- Default only: no row rewrites, no constraint, no backfill, so every existing
3
+ -- agent keeps its stored value.
4
+ ALTER TABLE "Agent" ALTER COLUMN "specialty" SET DEFAULT 'specialist';
@@ -44,7 +44,7 @@ model Agent {
44
44
  restrictSlackToMembers Boolean @default(false)
45
45
  // The agent's Specialty name (manifest key). AGR-6.3 made it required and
46
46
  // dropped the legacy typeName column.
47
- specialty String @default("coding")
47
+ specialty String @default("specialist")
48
48
  // SSP-1.2: owning account. Nullable/additive — existing agents keep NULL.
49
49
  accountId String?
50
50
  account Account? @relation(fields: [accountId], references: [id], onDelete: Restrict)
package/src/admin-boot.ts CHANGED
@@ -69,6 +69,7 @@ import {
69
69
  mountRouteContributions,
70
70
  resolveAdminExtensions,
71
71
  startBackgroundPasses,
72
+ withExtensionAgentTypes,
72
73
  } from "./admin-extension.ts";
73
74
  import { createAdminUIApp } from "./admin-ui.ts";
74
75
  import { AgentAskService } from "./agent-ask.ts";
@@ -182,6 +183,10 @@ export async function startAdmin(
182
183
  // Extension-supplied specialty defaults (crons/tools/plugins), consulted by
183
184
  // the cron reconcile and create-time seeding before the manifest.
184
185
  const specialtyDefaults = collectSpecialtyDefaults(extensions);
186
+ const specialtyRegistry = withExtensionAgentTypes(
187
+ new SpecialtyRegistry(),
188
+ extensions,
189
+ );
185
190
  const agentCronJobService = new AgentCronJobService(
186
191
  prisma,
187
192
  undefined,
@@ -458,7 +463,7 @@ export async function startAdmin(
458
463
  agentPluginService,
459
464
  agentMemberService,
460
465
  callerScopeResolver,
461
- specialtyRegistry: new SpecialtyRegistry(),
466
+ specialtyRegistry,
462
467
  specialtyDefaults,
463
468
  agentChatTokenService,
464
469
  agentGitHubInstallationsService,
@@ -551,6 +556,7 @@ export async function startAdmin(
551
556
  agentService,
552
557
  provisioner,
553
558
  anthropicFederationEnabled,
559
+ specialtyRegistry,
554
560
  specialtyDefaults,
555
561
  serviceIntegrations,
556
562
  chatService: deletionChatService,
@@ -30,6 +30,11 @@
30
30
  * reconcile and create-time seeding consult it
31
31
  * before the specialty manifest, which remains the
32
32
  * fallback when no extension returns anything
33
+ * agentTypes() — agent types this extension offers in the new-agent
34
+ * picker and accepts in POST /admin/agents, for
35
+ * types with no manifest on disk. Extension entries
36
+ * win over a manifest of the same name; a type's
37
+ * crons still come from specialtyDefaults(name)
33
38
  *
34
39
  * openApi() is static (no context): scripts/generate-admin-spec.ts calls it to
35
40
  * fold extension routes/schemas into admin/openapi.json without booting.
@@ -62,6 +67,10 @@ import type {
62
67
  SpecialtyDefaults,
63
68
  SpecialtyDefaultsResolver,
64
69
  } from "./specialty-defaults.ts";
70
+ import type {
71
+ SpecialtyManifestResolver,
72
+ SpecialtyOption,
73
+ } from "./specialty-manifest-loader.ts";
65
74
 
66
75
  /** The platform services an extension may build on. */
67
76
  export interface AdminPlatformServices {
@@ -180,6 +189,8 @@ export interface AdminExtension {
180
189
  serviceIntegrations?(env: NodeJS.ProcessEnv): ServiceIntegration[];
181
190
  /** See the header: undefined means "no opinion" — the manifest applies. */
182
191
  specialtyDefaults?(specialty: string): SpecialtyDefaults | undefined;
192
+ /** See the header: additive; merged over the manifest-discovered types. */
193
+ agentTypes?(): SpecialtyOption[];
183
194
  services?(ctx: AdminExtensionContext): AdminExtensionServices;
184
195
  apiRoutes?(ctx: AdminExtensionContext): AdminRouteContribution[];
185
196
  adminUiRoutes?(ctx: AdminExtensionContext): AdminRouteContribution[];
@@ -373,6 +384,40 @@ export function collectSpecialtyDefaults(
373
384
  };
374
385
  }
375
386
 
387
+ /**
388
+ * Merges every extension's agentTypes() over the manifest-discovered `base`
389
+ * list. Order is base first, then new extension names; on a name collision the
390
+ * extension's entry replaces the base entry in place.
391
+ */
392
+ export function mergeAgentTypes(
393
+ base: readonly SpecialtyOption[],
394
+ extensions: readonly AdminExtension[],
395
+ ): SpecialtyOption[] {
396
+ const byName = new Map(base.map((type) => [type.name, type]));
397
+ for (const ext of extensions) {
398
+ for (const type of ext.agentTypes?.() ?? []) byName.set(type.name, type);
399
+ }
400
+ return [...byName.values()];
401
+ }
402
+
403
+ /**
404
+ * Wraps the registry so the new-agent picker lists extension types and
405
+ * POST /admin/agents accepts them. With no extension declaring agentTypes()
406
+ * the wrapped registry behaves exactly like `registry`.
407
+ */
408
+ export function withExtensionAgentTypes(
409
+ registry: SpecialtyManifestResolver,
410
+ extensions: readonly AdminExtension[],
411
+ ): SpecialtyManifestResolver {
412
+ return {
413
+ getManifest: (specialty) => registry.getManifest(specialty),
414
+ tryGetManifest: (specialty) => registry.tryGetManifest(specialty),
415
+ listTypes: () => mergeAgentTypes(registry.listTypes(), extensions),
416
+ hasExtensionType: (specialty) =>
417
+ mergeAgentTypes([], extensions).some((type) => type.name === specialty),
418
+ };
419
+ }
420
+
376
421
  /** Mounts each contribution on root, in order. */
377
422
  export function mountRouteContributions(
378
423
  root: AnyHono,
@@ -10,11 +10,13 @@ import {
10
10
  collectOpenApiContributions,
11
11
  collectServiceIntegrations,
12
12
  collectSpecialtyDefaults,
13
+ mergeAgentTypes,
13
14
  mountRouteContributions,
14
15
  renderAgentDetailCardSlots,
15
16
  renderAgentDetailCards,
16
17
  resolveAdminExtensions,
17
18
  startBackgroundPasses,
19
+ withExtensionAgentTypes,
18
20
  } from "./admin-extension.ts";
19
21
 
20
22
  // The seam only hands the context through to hooks, so an opaque stand-in is
@@ -549,3 +551,62 @@ describe("collectSpecialtyDefaults", () => {
549
551
  expect(resolve("b")).toEqual({ tools: ["B"] });
550
552
  });
551
553
  });
554
+
555
+ describe("mergeAgentTypes", () => {
556
+ const base = [
557
+ { name: "specialist", displayName: "Specialist" },
558
+ { name: "coding", displayName: "Manifest Coding" },
559
+ ];
560
+
561
+ it("returns the base list unchanged with no or hookless extensions", () => {
562
+ expect(mergeAgentTypes(base, [])).toEqual(base);
563
+ expect(mergeAgentTypes(base, [{ name: "bare" }])).toEqual(base);
564
+ expect(mergeAgentTypes([], [])).toEqual([]);
565
+ });
566
+
567
+ it("appends extension types, across multiple extensions", () => {
568
+ const merged = mergeAgentTypes(base, [
569
+ { agentTypes: () => [{ name: "a", displayName: "A" }] },
570
+ { agentTypes: () => [{ name: "b", displayName: "B" }] },
571
+ ]);
572
+ expect(merged.map((t) => t.name)).toEqual([
573
+ "specialist",
574
+ "coding",
575
+ "a",
576
+ "b",
577
+ ]);
578
+ });
579
+
580
+ it("lets the extension win on a name collision, keeping position", () => {
581
+ const merged = mergeAgentTypes(base, [
582
+ { agentTypes: () => [{ name: "coding", displayName: "Coding Agent" }] },
583
+ { agentTypes: () => [{ name: "coding", displayName: "Later Wins" }] },
584
+ ]);
585
+ expect(merged).toEqual([
586
+ { name: "specialist", displayName: "Specialist" },
587
+ { name: "coding", displayName: "Later Wins" },
588
+ ]);
589
+ });
590
+ });
591
+
592
+ describe("withExtensionAgentTypes", () => {
593
+ const registry = {
594
+ getManifest: () => {
595
+ throw new Error("unused");
596
+ },
597
+ tryGetManifest: () => undefined,
598
+ listTypes: () => [{ name: "specialist", displayName: "Specialist" }],
599
+ };
600
+
601
+ it("merges listTypes and reports extension-only types", () => {
602
+ const wrapped = withExtensionAgentTypes(registry, [
603
+ { agentTypes: () => [{ name: "coding", displayName: "Coding Agent" }] },
604
+ ]);
605
+ expect(wrapped.listTypes().map((t) => t.name)).toEqual([
606
+ "specialist",
607
+ "coding",
608
+ ]);
609
+ expect(wrapped.hasExtensionType?.("coding")).toBe(true);
610
+ expect(wrapped.hasExtensionType?.("specialist")).toBe(false);
611
+ });
612
+ });
@@ -7,9 +7,11 @@
7
7
  */
8
8
 
9
9
  import { beforeAll, describe, expect, it } from "bun:test";
10
- import { existsSync } from "node:fs";
10
+ import { existsSync, mkdtempSync } from "node:fs";
11
+ import { tmpdir } from "node:os";
11
12
  import { join } from "node:path";
12
13
  import { sign } from "hono/jwt";
14
+ import { withExtensionAgentTypes } from "./admin-extension.ts";
13
15
  import { createAdminUIApp } from "./admin-ui.ts";
14
16
  import { SpecialtyRegistry } from "./specialty-manifest-loader.ts";
15
17
  import {
@@ -5666,6 +5668,36 @@ describe("admin UI — create agent seeds manifest tools and plugins", () => {
5666
5668
  expect(seededPlugins).toEqual(["fixture-plugin"]);
5667
5669
  });
5668
5670
 
5671
+ it("an extension-contributed type (ETP-1.2) shows in the picker and POST /admin/agents accepts it with no manifest", async () => {
5672
+ const emptyDir = mkdtempSync(join(tmpdir(), "no-specialties-"));
5673
+ const registry = withExtensionAgentTypes(
5674
+ new SpecialtyRegistry({ typesDir: emptyDir }),
5675
+ [{ agentTypes: () => [{ name: "coding", displayName: "Coding Agent" }] }],
5676
+ );
5677
+ const deps = makeMockDeps();
5678
+ deps.specialtyRegistry = registry;
5679
+ const app = createAdminUIApp(deps);
5680
+ const form = await app.request("/admin/agents/new", {
5681
+ headers: { Cookie: `admin_session=${cookie}` },
5682
+ });
5683
+ expect(await form.text()).toContain(
5684
+ '<option value="coding">Coding Agent</option>',
5685
+ );
5686
+ const res = await app.request("/admin/agents", {
5687
+ method: "POST",
5688
+ body: new URLSearchParams({
5689
+ name: "Ext Agent",
5690
+ type: "coding",
5691
+ }).toString(),
5692
+ headers: {
5693
+ "Content-Type": "application/x-www-form-urlencoded",
5694
+ Cookie: `admin_session=${cookie}`,
5695
+ },
5696
+ });
5697
+ expect(res.status).toBe(302);
5698
+ expect(res.headers.get("Location")).toBe(`/admin/agents/${AGENT_ID}`);
5699
+ });
5700
+
5669
5701
  it("POST /admin/agents rolls back the agent and redirects with error when tool seeding fails", async () => {
5670
5702
  let deletedId: string | undefined;
5671
5703
  const deps = makeMockDeps();
@@ -40,6 +40,9 @@ function migrationSql(dir: string): string {
40
40
  const ADD_TYPE_NAME = migrationSql("20260724000000_add_agent_type_name");
41
41
  const ADD_SPECIALTY = migrationSql("20261029000000_add_agent_specialty");
42
42
  const DROP_TYPE_NAME = migrationSql("20261030000000_drop_agent_type_name");
43
+ const SET_DEFAULT_SPECIALIST = migrationSql(
44
+ "20261030100000_set_agent_specialty_default_specialist",
45
+ );
43
46
 
44
47
  describeOrSkip("typeName -> specialty migrations (AGR-6.1 / AGR-6.3)", () => {
45
48
  const schema = `agr63_${process.pid}_${Date.now()}`;
@@ -123,4 +126,31 @@ describeOrSkip("typeName -> specialty migrations (AGR-6.1 / AGR-6.3)", () => {
123
126
  );
124
127
  expect(def.rows[0]?.specialty).toBe("coding");
125
128
  });
129
+
130
+ it("set_agent_specialty_default_specialist changes only the default and leaves rows unchanged (PCD-3.4)", async () => {
131
+ await client.query(ADD_TYPE_NAME);
132
+ await client.query(ADD_SPECIALTY);
133
+ await client.query(DROP_TYPE_NAME);
134
+ for (const [i, s] of LIVE_SPECIALTIES.entries()) {
135
+ await client.query(
136
+ `INSERT INTO "Agent" ("id","name","specialty") VALUES ($1,$2,$3)`,
137
+ [`a${i}`, `agent-${i}`, s],
138
+ );
139
+ }
140
+
141
+ await client.query(SET_DEFAULT_SPECIALIST);
142
+
143
+ const { rows } = await client.query<{ specialty: string }>(
144
+ `SELECT "specialty" FROM "Agent" ORDER BY "id"`,
145
+ );
146
+ expect(rows.map((r) => r.specialty)).toEqual([...LIVE_SPECIALTIES]);
147
+
148
+ await client.query(
149
+ `INSERT INTO "Agent" ("id","name") VALUES ('a9','agent-9')`,
150
+ );
151
+ const def = await client.query<{ specialty: string }>(
152
+ `SELECT "specialty" FROM "Agent" WHERE "id" = 'a9'`,
153
+ );
154
+ expect(def.rows[0]?.specialty).toBe("specialist");
155
+ });
126
156
  });
@@ -311,7 +311,7 @@ describeOrSkip("admin CRUD API (integration)", () => {
311
311
 
312
312
  // ─── specialty field ─────────────────────────────────────────────────────────
313
313
 
314
- it("GET /agents includes specialty:'coding' for an agent created before specialty existed", async () => {
314
+ it("GET /agents includes specialty:'specialist' for an agent created before specialty existed", async () => {
315
315
  const res = await app.request("/agents", {
316
316
  headers: { Cookie: `admin_session=${cookie}` },
317
317
  });
@@ -319,28 +319,28 @@ describeOrSkip("admin CRUD API (integration)", () => {
319
319
  const body = await res.json();
320
320
  const entry = body.find((a: { id: string }) => a.id === agentId);
321
321
  expect(entry).toBeDefined();
322
- expect(entry.specialty).toBe("coding");
322
+ expect(entry.specialty).toBe("specialist");
323
323
  });
324
324
 
325
- it("GET /agents/:id includes specialty:'coding' by default", async () => {
325
+ it("GET /agents/:id includes specialty:'specialist' by default", async () => {
326
326
  const res = await app.request(`/agents/${agentId}`, {
327
327
  headers: { Cookie: `admin_session=${cookie}` },
328
328
  });
329
329
  expect(res.status).toBe(200);
330
330
  const body = await res.json();
331
- expect(body.specialty).toBe("coding");
331
+ expect(body.specialty).toBe("specialist");
332
332
  });
333
333
 
334
334
  // ─── Migration backfill read-back (pre-existing rows + child rows) ────────────
335
335
  //
336
336
  // Simulates the exact scenario the 20260724000000_add_agent_type_name
337
337
  // migration must handle: rows that existed *before* the specialty column was
338
- // added (never received an explicit value) must read back specialty="coding"
338
+ // added (never received an explicit value) must read back specialty="specialist"
339
339
  // via the column DEFAULT, and every child row (cron/plugin/tool/env/member)
340
340
  // must be completely untouched — same count, same updatedAt — since the
341
341
  // migration only ALTERs the Agent table.
342
342
 
343
- it("migration backfill: pre-existing agent rows read back specialty='coding' and child rows (cron/plugin/tool/env/member) are untouched", async () => {
343
+ it("migration backfill: pre-existing agent rows read back specialty='specialist' and child rows (cron/plugin/tool/env/member) are untouched", async () => {
344
344
  // Seed a second "pre-existing" agent with one row in every child table,
345
345
  // simulating data that existed prior to the specialty migration landing.
346
346
  const preExisting = await prisma.agent.create({
@@ -383,7 +383,7 @@ describeOrSkip("admin CRUD API (integration)", () => {
383
383
  const rawAgent = await prisma.agent.findUniqueOrThrow({
384
384
  where: { id: preExisting.id },
385
385
  });
386
- expect(rawAgent.specialty).toBe("coding");
386
+ expect(rawAgent.specialty).toBe("specialist");
387
387
 
388
388
  // Read back via the HTTP API too — end-to-end confirmation.
389
389
  const res = await app.request(`/agents/${preExisting.id}`, {
@@ -391,7 +391,7 @@ describeOrSkip("admin CRUD API (integration)", () => {
391
391
  });
392
392
  expect(res.status).toBe(200);
393
393
  const body = await res.json();
394
- expect(body.specialty).toBe("coding");
394
+ expect(body.specialty).toBe("specialist");
395
395
 
396
396
  // Child row counts are unchanged — exactly one of each, still linked to
397
397
  // the same agent.
@@ -455,7 +455,7 @@ describeOrSkip("admin CRUD API (integration)", () => {
455
455
  const rawAgent = await prisma.agent.findUniqueOrThrow({
456
456
  where: { id: agentId },
457
457
  });
458
- expect(rawAgent.specialty).toBe("coding");
458
+ expect(rawAgent.specialty).toBe("specialist");
459
459
  });
460
460
 
461
461
  // ─── repos field round-trip ───────────────────────────────────────────────────
@@ -574,7 +574,7 @@ describeOrSkip("Agent.specialty — final shape (AGR-6.3)", () => {
574
574
  ).toBe("research");
575
575
  });
576
576
 
577
- it("specialty is required: NULL is rejected and omitting it applies the 'coding' default", async () => {
577
+ it("specialty is required: NULL is rejected and omitting it applies the 'specialist' default", async () => {
578
578
  await expect(
579
579
  (async () =>
580
580
  prisma.agent.create({
@@ -584,6 +584,6 @@ describeOrSkip("Agent.specialty — final shape (AGR-6.3)", () => {
584
584
 
585
585
  const created = await agentService.create({ name: "No Specialty" });
586
586
  createdIds.push(created.id);
587
- expect(created.specialty).toBe("coding");
587
+ expect(created.specialty).toBe("specialist");
588
588
  });
589
589
  });
package/src/agents.ts CHANGED
@@ -33,7 +33,7 @@ interface CreateAgentInput {
33
33
  name: string;
34
34
  slackId?: string | null;
35
35
  selfHosted?: boolean;
36
- /** Specialty name. Omitted means Prisma's column default ("coding") applies. */
36
+ /** Specialty name. Omitted means Prisma's column default ("specialist") applies. */
37
37
  specialty?: string;
38
38
  /** Initial repos[] — the manifest repos merged with any request-supplied repos. */
39
39
  repos?: string[];
@@ -784,7 +784,10 @@ export interface CreateAgentDeps {
784
784
  agentEnvService: Pick<AgentEnvService, "patch">;
785
785
  agentCronJobService: Pick<AgentCronJobService, "reconcileSystemCrons">;
786
786
  provisioner: Pick<AgentProvisioner, "canProvision" | "provision">;
787
- specialtyRegistry: Pick<SpecialtyManifestResolver, "tryGetManifest">;
787
+ specialtyRegistry: Pick<
788
+ SpecialtyManifestResolver,
789
+ "tryGetManifest" | "hasExtensionType"
790
+ >;
788
791
  /** Extension-supplied tools/plugins, consulted before the manifest's. */
789
792
  specialtyDefaults?: SpecialtyDefaultsResolver;
790
793
  /** WIF-2.1: Anthropic federation is enabled; defaults to false. */
@@ -884,7 +887,12 @@ export async function createAgent(
884
887
  const extensionDefaults = specialty
885
888
  ? deps.specialtyDefaults?.(specialty)
886
889
  : undefined;
887
- if (!specialty || (!manifest && !extensionDefaults)) {
890
+ if (
891
+ !specialty ||
892
+ (!manifest &&
893
+ !extensionDefaults &&
894
+ !deps.specialtyRegistry.hasExtensionType?.(specialty))
895
+ ) {
888
896
  return { ok: false, errorCode: "invalid_type" };
889
897
  }
890
898
 
@@ -34,7 +34,7 @@ export const AgentSummarySchema = z
34
34
  id: z.string().openapi({ example: "clx1234567890" }),
35
35
  name: z.string().openapi({ example: "Bodhi" }),
36
36
  selfHosted: z.boolean().openapi({ example: false }),
37
- specialty: z.string().openapi({ example: "coding" }),
37
+ specialty: z.string().openapi({ example: "specialist" }),
38
38
  })
39
39
  .openapi("AgentSummary");
40
40
 
@@ -147,7 +147,7 @@ export const PatchAgentBodySchema = z
147
147
  export const CreateAgentBodySchema = z
148
148
  .object({
149
149
  name: z.string().min(1).openapi({ example: "Bodhi" }),
150
- specialty: z.string().min(1).openapi({ example: "coding" }),
150
+ specialty: z.string().min(1).openapi({ example: "specialist" }),
151
151
  /**
152
152
  * "in-cluster" means K8s-provisioned; anything else (including absent)
153
153
  * means self-hosted.
@@ -1343,7 +1343,7 @@ export const AgentConfigResponseSchema = z
1343
1343
  * ARD-6.1: the agent's Specialty name, used by the runtime to pick its
1344
1344
  * workspace template directory.
1345
1345
  */
1346
- specialty: z.string().openapi({ example: "coding" }),
1346
+ specialty: z.string().openapi({ example: "specialist" }),
1347
1347
  /**
1348
1348
  * DEPRECATED back-compat alias of `specialty` (always equal to it). Kept
1349
1349
  * only for pre-AGR-6.3 runtime images, whose entrypoint reads
@@ -1353,7 +1353,7 @@ export const AgentConfigResponseSchema = z
1353
1353
  typeName: z
1354
1354
  .string()
1355
1355
  .optional()
1356
- .openapi({ example: "coding", deprecated: true }),
1356
+ .openapi({ example: "specialist", deprecated: true }),
1357
1357
  /**
1358
1358
  * PMC-1.1: phase -> subagentType (or null) map, covering all six pipeline
1359
1359
  * phases (see AGENT_PHASES) regardless of whether the agent has an
@@ -121,7 +121,9 @@ export interface SpecialtyManifestResolver {
121
121
  getManifest(specialty: string): SpecialtyManifest;
122
122
  /**
123
123
  * Resolve `specialty` to its parsed manifest WITHOUT falling back to
124
- * the default type — returns undefined for an unknown type instead. No warning is
124
+ * the default type — returns undefined for an unknown type instead (the one exception
125
+ * is the built-in default type when no specialty is discoverable, matching the
126
+ * picker's fallback option). No warning is
125
127
  * logged (an unknown type here is an expected, callers-handle-it outcome,
126
128
  * not a degraded-boot condition). Used by POST /admin/agents to reject an
127
129
  * unknown requested `type` with 400 rather than silently seeding a
@@ -134,9 +136,17 @@ export interface SpecialtyManifestResolver {
134
136
  * type picker. A directory whose manifest fails to load or parse is
135
137
  * skipped (not thrown) — the picker still renders every other valid type
136
138
  * rather than 500ing the whole /admin/agents/new page over one bad
137
- * manifest. Order matches the underlying `list` fn's order.
139
+ * manifest. Order matches the underlying `list` fn's order. Never throws:
140
+ * a missing type dir is treated as empty (with a warning), and when no type
141
+ * is discoverable the built-in default option is returned.
138
142
  */
139
143
  listTypes(): SpecialtyOption[];
144
+ /**
145
+ * True when an AdminExtension declares `specialty` via agentTypes() — a type
146
+ * with no manifest that POST /admin/agents must still accept. Optional: the
147
+ * disk-backed registry has none.
148
+ */
149
+ hasExtensionType?(specialty: string): boolean;
140
150
  }
141
151
 
142
152
  export class SpecialtyRegistry implements SpecialtyManifestResolver {
@@ -198,11 +208,41 @@ export class SpecialtyRegistry implements SpecialtyManifestResolver {
198
208
  * internally by getManifest().
199
209
  */
200
210
  tryGetManifest(specialty: string): SpecialtyManifest | undefined {
201
- return this.tryLoad(specialty);
211
+ const manifest = this.tryLoad(specialty);
212
+ if (manifest || specialty !== DEFAULT_SPECIALTY_NAME) return manifest;
213
+ // The picker offers the built-in default when nothing is discoverable, so
214
+ // creating an agent with it must resolve too.
215
+ return this.discoverOptions().length === 0
216
+ ? parseSpecialtyManifest(BUILT_IN_FALLBACK_MANIFEST_YAML)
217
+ : undefined;
202
218
  }
203
219
 
204
220
  listTypes(): SpecialtyOption[] {
205
- const specialties = this.list();
221
+ const options = this.discoverOptions();
222
+ if (options.length > 0) return options;
223
+ // Nothing discoverable: offer the built-in default so the picker is never empty.
224
+ const fallback = parseSpecialtyManifest(BUILT_IN_FALLBACK_MANIFEST_YAML);
225
+ return [
226
+ {
227
+ name: fallback.metadata.name,
228
+ displayName: fallback.metadata.displayName,
229
+ },
230
+ ];
231
+ }
232
+
233
+ /** Every loadable specialty found on disk; empty when the dir is missing or has none. */
234
+ private discoverOptions(): SpecialtyOption[] {
235
+ let specialties: string[];
236
+ try {
237
+ specialties = this.list();
238
+ } catch (err) {
239
+ // Missing dir (e.g. npm consumers, where agent-types/ is not shipped).
240
+ this.warn(
241
+ `SpecialtyRegistry: could not list specialties under "${this.typesDir}" — ` +
242
+ `treating as empty (${err instanceof Error ? err.message : String(err)})`,
243
+ );
244
+ specialties = [];
245
+ }
206
246
  const options: SpecialtyOption[] = [];
207
247
  for (const specialty of specialties) {
208
248
  const manifest = this.tryLoad(specialty);
@@ -160,6 +160,38 @@ describe("SpecialtyRegistry", () => {
160
160
  expect(warnings).toHaveLength(0);
161
161
  });
162
162
 
163
+ it("tryGetManifest resolves the built-in default type when no specialty is discoverable", () => {
164
+ const registry = new SpecialtyRegistry({
165
+ defaultTypeName: DEFAULT_SPECIALTY_NAME,
166
+ list: () => {
167
+ throw new Error("ENOENT: no such file or directory");
168
+ },
169
+ read: (t) => {
170
+ throw new Error(`ENOENT: no manifest for ${t}`);
171
+ },
172
+ warn: () => {},
173
+ });
174
+
175
+ expect(registry.tryGetManifest(DEFAULT_SPECIALTY_NAME)?.metadata.name).toBe(
176
+ DEFAULT_SPECIALTY_NAME,
177
+ );
178
+ expect(registry.tryGetManifest("nonexistent-type")).toBeUndefined();
179
+ });
180
+
181
+ it("tryGetManifest does not synthesize the default type when other specialties are discoverable", () => {
182
+ const registry = new SpecialtyRegistry({
183
+ defaultTypeName: DEFAULT_SPECIALTY_NAME,
184
+ list: () => ["coding"],
185
+ read: (t) => {
186
+ if (t === "coding") return manifestYaml("coding");
187
+ throw new Error(`ENOENT: no manifest for ${t}`);
188
+ },
189
+ warn: () => {},
190
+ });
191
+
192
+ expect(registry.tryGetManifest(DEFAULT_SPECIALTY_NAME)).toBeUndefined();
193
+ });
194
+
163
195
  it("tryGetManifest shares the cache with getManifest — reader called once per type", () => {
164
196
  let reads = 0;
165
197
  const registry = new SpecialtyRegistry({
@@ -259,14 +291,47 @@ voice: true
259
291
  expect(types).toEqual([{ name: "coding", displayName: "coding" }]);
260
292
  });
261
293
 
262
- it("listTypes returns an empty array when no type directories are discovered", () => {
294
+ it("listTypes falls back to the built-in default option when no type directories are discovered", () => {
263
295
  const registry = new SpecialtyRegistry({
264
296
  defaultTypeName: DEFAULT_SPECIALTY_NAME,
265
297
  list: () => [],
266
298
  read: (t) => manifestYaml(t),
267
299
  });
268
300
 
269
- expect(registry.listTypes()).toEqual([]);
301
+ expect(registry.listTypes()).toEqual([
302
+ { name: DEFAULT_SPECIALTY_NAME, displayName: "Specialist Agent" },
303
+ ]);
304
+ });
305
+
306
+ it("listTypes warns and returns the built-in default when the list fn throws (missing dir)", () => {
307
+ const warnings: string[] = [];
308
+ const registry = new SpecialtyRegistry({
309
+ defaultTypeName: DEFAULT_SPECIALTY_NAME,
310
+ warn: (m) => warnings.push(m),
311
+ list: () => {
312
+ throw new Error("ENOENT: no such file or directory");
313
+ },
314
+ read: (t) => manifestYaml(t),
315
+ });
316
+
317
+ expect(registry.listTypes()).toEqual([
318
+ { name: DEFAULT_SPECIALTY_NAME, displayName: "Specialist Agent" },
319
+ ]);
320
+ expect(warnings).toHaveLength(1);
321
+ expect(warnings[0]).toContain("ENOENT");
322
+ });
323
+
324
+ it("listTypes does not throw on a nonexistent typesDir with the real lister", () => {
325
+ const warnings: string[] = [];
326
+ const registry = new SpecialtyRegistry({
327
+ typesDir: "/nonexistent/agent-types",
328
+ warn: (m) => warnings.push(m),
329
+ });
330
+
331
+ expect(registry.listTypes()).toEqual([
332
+ { name: DEFAULT_SPECIALTY_NAME, displayName: "Specialist Agent" },
333
+ ]);
334
+ expect(warnings.length).toBeGreaterThan(0);
270
335
  });
271
336
  });
272
337