@app-vitals/agent-admin 0.16.0 → 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.
@@ -12,8 +12,8 @@ import { describe, expect, test } from "bun:test";
12
12
  import type { AgentCronJob, PrismaClient } from "../prisma/client/client.ts";
13
13
  import { AgentCronJobService, isValidCron } from "./agent-cron-jobs.ts";
14
14
  import {
15
- type SpecialtyManifestResolver,
16
15
  resolveDefaultSpecialtyName,
16
+ type SpecialtyManifestResolver,
17
17
  } from "./specialty-manifest-loader.ts";
18
18
  import type { SpecialtyManifest } from "./specialty-registry.ts";
19
19
 
@@ -374,36 +374,29 @@ describe("reconcileSystemCrons (unit, injected registry)", () => {
374
374
  expect(String(errors[0]?.[0])).toContain("agent-1");
375
375
  });
376
376
 
377
- test("the default manifest that cannot load still throws", async () => {
377
+ test("the default type with no loadable manifest logs an error and changes nothing", async () => {
378
378
  const registry = fakeRegistry({});
379
- const { prisma } = makeFakePrisma({
380
- specialty: resolveDefaultSpecialtyName(),
381
- });
382
- const service = new AgentCronJobService(
383
- prisma as unknown as PrismaClient,
384
- undefined,
385
- registry,
386
- );
387
-
388
- await expect(service.reconcileSystemCrons("agent-1")).rejects.toThrow(
389
- resolveDefaultSpecialtyName(),
390
- );
391
- });
392
-
393
- test("the default-manifest failure names the env vars that configure it", async () => {
394
- const registry = fakeRegistry({});
395
- const { prisma } = makeFakePrisma({
396
- specialty: resolveDefaultSpecialtyName(),
379
+ const { prisma, rows } = makeFakePrisma({
380
+ specialty: "specialist",
381
+ existing: [{ id: "sys-1", name: "old-system", system: true }],
397
382
  });
398
383
  const service = new AgentCronJobService(
399
384
  prisma as unknown as PrismaClient,
400
385
  undefined,
401
386
  registry,
402
387
  );
403
-
404
- await expect(service.reconcileSystemCrons("agent-1")).rejects.toThrow(
405
- /SHIPWRIGHT_SPECIALTIES_DIR.*SHIPWRIGHT_DEFAULT_AGENT_TYPE/s,
406
- );
388
+ const original = console.error;
389
+ console.error = () => {};
390
+ try {
391
+ expect(await service.reconcileSystemCrons("agent-1")).toEqual({
392
+ created: 0,
393
+ updated: 0,
394
+ deleted: 0,
395
+ });
396
+ } finally {
397
+ console.error = original;
398
+ }
399
+ expect(Array.from(rows.keys())).toEqual(["sys-1"]);
407
400
  });
408
401
 
409
402
  test("a valid non-coding manifest reconciles normally", async () => {
@@ -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
@@ -50,13 +50,17 @@ describe("SpecialtyRegistry with the fixture specialty", () => {
50
50
  expect(fixtureRegistry().tryGetManifest("renamed-type")).toBeUndefined();
51
51
  });
52
52
 
53
- it("fails with an actionable error when the default specialty is not on disk", () => {
53
+ it("warns and returns an empty-crons manifest when the default specialty is not on disk", () => {
54
+ const warnings: string[] = [];
54
55
  const registry = new SpecialtyRegistry({
55
56
  typesDir: FIXTURE_SPECIALTIES_DIR,
56
57
  defaultTypeName: "not-there",
57
- warn: () => {},
58
+ warn: (m) => warnings.push(m),
58
59
  });
59
- expect(() => registry.getManifest("fixture-or-anything")).toThrow(
60
+ const manifest = registry.getManifest("fixture-or-anything");
61
+ expect(manifest.crons).toEqual([]);
62
+ expect(manifest.plugins).toEqual([]);
63
+ expect(warnings.join("\n")).toMatch(
60
64
  /SHIPWRIGHT_SPECIALTIES_DIR.*SHIPWRIGHT_DEFAULT_AGENT_TYPE/s,
61
65
  );
62
66
  });
@@ -2,24 +2,25 @@
2
2
  * admin/src/specialty-manifest-loader.ts
3
3
  *
4
4
  * SpecialtyRegistry — resolves an agent's stored `specialty` to its parsed
5
- * Specialty manifest by loading `specialties/{specialty}/manifest.yaml` from
6
- * disk (relative to the repo root) and validating it via
5
+ * Specialty manifest by loading `{typesDir}/{specialty}/manifest.yaml` from
6
+ * disk (typesDir defaults to the shipped agent-types/) and validating it via
7
7
  * parseSpecialtyManifest().
8
8
  *
9
9
  * This is the runtime source of truth for an agent's default system crons
10
10
  * (and, in time, its default plugins/tools/env contract). It replaces the
11
11
  * former hardcoded SYSTEM_CRONS array in admin/src/system-crons.ts.
12
12
  *
13
- * Fallback contract: an unknown `specialty` (a value with no matching
14
- * directory under specialties/) resolves to the "coding" manifest with a
15
- * logged warning — never throws. This keeps the boot-path
16
- * POST /agents/:id/crons/reconcile route from beginning to 5xx for an agent
17
- * whose type was renamed or removed.
13
+ * Defaults: the default type is `specialist` and the type directory is the
14
+ * shipped `agent-types/` (both resolved by @app-vitals/agent-lib/default-specialty,
15
+ * the single shared helper; SHIPWRIGHT_DEFAULT_AGENT_TYPE /
16
+ * SHIPWRIGHT_SPECIALTIES_DIR override them).
18
17
  *
19
- * Hard-failure contract: if the "coding" manifest itself (the universal
20
- * fallback) cannot be loaded or fails validation, that is a configuration error
21
- * (it names SHIPWRIGHT_SPECIALTIES_DIR / SHIPWRIGHT_DEFAULT_AGENT_TYPE) and the error propagates — we never silently degrade the fallback into a
22
- * no-op that would strip every agent's system crons.
18
+ * Fallback contract: getManifest() never throws. An unknown `specialty` (no
19
+ * matching directory under the type dir) resolves to the default type's
20
+ * manifest with a logged warning. If even the default cannot be loaded (bad
21
+ * env override, missing/invalid manifest) it warns and returns a built-in
22
+ * minimal manifest with no crons/plugins, so the boot-path
23
+ * POST /agents/:id/crons/reconcile route never 5xxs over configuration.
23
24
  *
24
25
  * The class takes an injectable reader/warner so unit tests can drive it
25
26
  * without touching the filesystem (see agent-cron-jobs.unit.test.ts).
@@ -28,12 +29,47 @@
28
29
  import { readdirSync, readFileSync } from "node:fs";
29
30
  import { join } from "node:path";
30
31
  import {
31
- type SpecialtyManifest,
32
+ DEFAULT_SPECIALTY_NAME,
33
+ resolveDefaultSpecialtyName,
34
+ resolveSpecialtiesDir,
35
+ } from "@app-vitals/agent-lib/default-specialty";
36
+ import {
32
37
  parseSpecialtyManifest,
38
+ type SpecialtyManifest,
33
39
  } from "./specialty-registry.ts";
34
40
 
35
- /** The universal fallback type — every agent degrades to this manifest. */
36
- export const DEFAULT_SPECIALTY_NAME = "coding";
41
+ export {
42
+ DEFAULT_SPECIALTY_NAME,
43
+ resolveDefaultSpecialtyName,
44
+ resolveSpecialtiesDir,
45
+ };
46
+
47
+ /**
48
+ * Built-in last-resort manifest: used only when the default type itself cannot
49
+ * be loaded. Declares nothing (no crons, plugins, tools) so it never seeds
50
+ * anything unexpected.
51
+ */
52
+ const BUILT_IN_FALLBACK_MANIFEST_YAML = `apiVersion: shipwright.dev/v1alpha1
53
+ kind: Specialty
54
+ metadata:
55
+ name: ${DEFAULT_SPECIALTY_NAME}
56
+ displayName: Specialist Agent
57
+ description: Built-in fallback used when the default specialty manifest cannot be loaded.
58
+ version: 0.0.0
59
+ skills: []
60
+ identity:
61
+ templatesDir: workspace/
62
+ crons: []
63
+ plugins: []
64
+ tools: []
65
+ env:
66
+ required: []
67
+ optional: []
68
+ members: []
69
+ repos: []
70
+ chat: true
71
+ voice: true
72
+ `;
37
73
 
38
74
  /** Reads a manifest's raw YAML text for a type; throws if it does not exist. */
39
75
  type ManifestReader = (specialty: string) => string;
@@ -54,51 +90,6 @@ export interface SpecialtyOption {
54
90
  displayName: string;
55
91
  }
56
92
 
57
- /**
58
- * Repo root resolved relative to this module (admin/src/ → ../../). The
59
- * specialties/ directory lives at the repo root alongside admin/.
60
- */
61
- const REPO_ROOT = join(import.meta.dir, "..", "..");
62
- const BUILT_IN_SPECIALTIES_DIR = join(REPO_ROOT, "specialties");
63
-
64
- /**
65
- * The specialties directory: SHIPWRIGHT_SPECIALTIES_DIR when set, else the
66
- * repo-root specialties/ (today's location).
67
- */
68
- export function resolveSpecialtiesDir(
69
- env: Record<string, string | undefined> = process.env,
70
- ): string {
71
- return env.SHIPWRIGHT_SPECIALTIES_DIR || BUILT_IN_SPECIALTIES_DIR;
72
- }
73
-
74
- /**
75
- * The universal fallback type: SHIPWRIGHT_DEFAULT_AGENT_TYPE when set, else
76
- * "coding".
77
- */
78
- /**
79
- * The actionable error for a default specialty that cannot be loaded. The
80
- * platform ships no specialty of its own, so this is a configuration problem:
81
- * the operator must point the two env vars at a real specialties directory.
82
- */
83
- export function missingDefaultSpecialtyError(
84
- defaultTypeName: string,
85
- typesDir: string,
86
- cause?: unknown,
87
- ): Error {
88
- const detail = cause instanceof Error ? ` (${cause.message})` : "";
89
- return new Error(
90
- `SpecialtyRegistry: the default specialty "${defaultTypeName}" could not be loaded from "${typesDir}"${detail}. ` +
91
- "Set SHIPWRIGHT_SPECIALTIES_DIR to a directory containing <type>/manifest.yaml and " +
92
- "SHIPWRIGHT_DEFAULT_AGENT_TYPE to the name of a specialty in it.",
93
- );
94
- }
95
-
96
- export function resolveDefaultSpecialtyName(
97
- env: Record<string, string | undefined> = process.env,
98
- ): string {
99
- return env.SHIPWRIGHT_DEFAULT_AGENT_TYPE || DEFAULT_SPECIALTY_NAME;
100
- }
101
-
102
93
  /** Default reader: loads {typesDir}/{specialty}/manifest.yaml from disk. */
103
94
  export function defaultManifestReader(
104
95
  specialty: string,
@@ -122,18 +113,21 @@ export function defaultTypeDirLister(
122
113
  */
123
114
  export interface SpecialtyManifestResolver {
124
115
  /**
125
- * Resolve `specialty` to its parsed manifest. Falls back to the "coding"
126
- * manifest (with a warning) when the type is unknown. Never throws for an
127
- * unknown type — only if the "coding" fallback itself fails to load.
116
+ * Resolve `specialty` to its parsed manifest. Falls back to the default
117
+ * type's manifest (with a warning) when the type is unknown, and to a
118
+ * built-in empty-crons manifest (with a warning) if the default cannot load
119
+ * either. Never throws.
128
120
  */
129
121
  getManifest(specialty: string): SpecialtyManifest;
130
122
  /**
131
123
  * Resolve `specialty` to its parsed manifest WITHOUT falling back to
132
- * "coding" — 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
133
127
  * logged (an unknown type here is an expected, callers-handle-it outcome,
134
128
  * not a degraded-boot condition). Used by POST /admin/agents to reject an
135
129
  * unknown requested `type` with 400 rather than silently seeding a
136
- * "coding" agent under a different label.
130
+ * default-type agent under a different label.
137
131
  */
138
132
  tryGetManifest(specialty: string): SpecialtyManifest | undefined;
139
133
  /**
@@ -142,9 +136,17 @@ export interface SpecialtyManifestResolver {
142
136
  * type picker. A directory whose manifest fails to load or parse is
143
137
  * skipped (not thrown) — the picker still renders every other valid type
144
138
  * rather than 500ing the whole /admin/agents/new page over one bad
145
- * 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.
146
142
  */
147
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;
148
150
  }
149
151
 
150
152
  export class SpecialtyRegistry implements SpecialtyManifestResolver {
@@ -157,8 +159,8 @@ export class SpecialtyRegistry implements SpecialtyManifestResolver {
157
159
 
158
160
  /**
159
161
  * `typesDir` and `defaultTypeName` default to SHIPWRIGHT_SPECIALTIES_DIR /
160
- * SHIPWRIGHT_DEFAULT_AGENT_TYPE, then to today's repo-root specialties/
161
- * and "coding". An injected `read`/`list` takes precedence over `typesDir`.
162
+ * SHIPWRIGHT_DEFAULT_AGENT_TYPE, then to the shipped agent-types/
163
+ * and "specialist". An injected `read`/`list` takes precedence over `typesDir`.
162
164
  */
163
165
  constructor(
164
166
  opts: {
@@ -179,42 +181,68 @@ export class SpecialtyRegistry implements SpecialtyManifestResolver {
179
181
  }
180
182
 
181
183
  getManifest(specialty: string): SpecialtyManifest {
182
- const resolved = this.tryLoad(specialty);
184
+ const resolved = this.tryLoadSafe(specialty);
183
185
  if (resolved) return resolved;
184
186
 
185
- // Unknown type — warn once and fall back to "coding". The fallback load is
186
- // deliberately NOT wrapped in tryLoad: if "coding" can't load, that's a
187
- // hard startup bug and must surface, not silently become a no-op.
188
187
  this.warn(
189
188
  `SpecialtyRegistry: unknown agent specialty "${specialty}" — ` +
190
189
  `falling back to "${this.defaultTypeName}" manifest`,
191
190
  );
192
- if (specialty === this.defaultTypeName) {
193
- // Guard against infinite intent: the default itself failed to load.
194
- throw missingDefaultSpecialtyError(this.defaultTypeName, this.typesDir);
195
- }
196
- try {
197
- return this.loadOrThrow(this.defaultTypeName);
198
- } catch (err) {
199
- throw missingDefaultSpecialtyError(
200
- this.defaultTypeName,
201
- this.typesDir,
202
- err,
203
- );
191
+ if (specialty !== this.defaultTypeName) {
192
+ const fallback = this.tryLoadSafe(this.defaultTypeName);
193
+ if (fallback) return fallback;
204
194
  }
195
+ // Even the default is unloadable (bad SHIPWRIGHT_SPECIALTIES_DIR /
196
+ // SHIPWRIGHT_DEFAULT_AGENT_TYPE override). Degrade to an empty manifest
197
+ // rather than failing boot; the warning names the knobs to fix.
198
+ this.warn(
199
+ `SpecialtyRegistry: the default specialty "${this.defaultTypeName}" could not be loaded from "${this.typesDir}" — ` +
200
+ "using a built-in manifest with no crons. Check SHIPWRIGHT_SPECIALTIES_DIR and SHIPWRIGHT_DEFAULT_AGENT_TYPE.",
201
+ );
202
+ return parseSpecialtyManifest(BUILT_IN_FALLBACK_MANIFEST_YAML);
205
203
  }
206
204
 
207
205
  /**
208
- * Resolve `specialty` without falling back to "coding" — see the interface
206
+ * Resolve `specialty` without falling back to the default type — see the interface
209
207
  * doc comment. Thin public wrapper around the existing tryLoad() used
210
208
  * internally by getManifest().
211
209
  */
212
210
  tryGetManifest(specialty: string): SpecialtyManifest | undefined {
213
- 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;
214
218
  }
215
219
 
216
220
  listTypes(): SpecialtyOption[] {
217
- 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
+ }
218
246
  const options: SpecialtyOption[] = [];
219
247
  for (const specialty of specialties) {
220
248
  const manifest = this.tryLoad(specialty);
@@ -227,6 +255,15 @@ export class SpecialtyRegistry implements SpecialtyManifestResolver {
227
255
  return options;
228
256
  }
229
257
 
258
+ /** tryLoad, but an invalid (unparseable) manifest also yields undefined. */
259
+ private tryLoadSafe(specialty: string): SpecialtyManifest | undefined {
260
+ try {
261
+ return this.tryLoad(specialty);
262
+ } catch {
263
+ return undefined;
264
+ }
265
+ }
266
+
230
267
  /** Load + cache a manifest, returning undefined only if the file is missing. */
231
268
  private tryLoad(specialty: string): SpecialtyManifest | undefined {
232
269
  const cached = this.cache.get(specialty);
@@ -240,13 +277,6 @@ export class SpecialtyRegistry implements SpecialtyManifestResolver {
240
277
  return this.parseAndCache(specialty, raw);
241
278
  }
242
279
 
243
- /** Load + cache a manifest, propagating any read/parse error. */
244
- private loadOrThrow(specialty: string): SpecialtyManifest {
245
- const cached = this.cache.get(specialty);
246
- if (cached) return cached;
247
- return this.parseAndCache(specialty, this.read(specialty));
248
- }
249
-
250
280
  private parseAndCache(specialty: string, raw: string): SpecialtyManifest {
251
281
  const manifest = parseSpecialtyManifest(raw);
252
282
  this.cache.set(specialty, manifest);