@app-vitals/agent-admin 0.15.0 → 0.16.1

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,51 +374,159 @@ 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(),
379
+ const { prisma, rows } = makeFakePrisma({
380
+ specialty: "specialist",
381
+ existing: [{ id: "sys-1", name: "old-system", system: true }],
381
382
  });
382
383
  const service = new AgentCronJobService(
383
384
  prisma as unknown as PrismaClient,
384
385
  undefined,
385
386
  registry,
386
387
  );
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"]);
400
+ });
387
401
 
388
- await expect(service.reconcileSystemCrons("agent-1")).rejects.toThrow(
389
- resolveDefaultSpecialtyName(),
402
+ test("a valid non-coding manifest reconciles normally", async () => {
403
+ const registry = fakeRegistry({ specialist: [loopCron] });
404
+ const { prisma, rows } = makeFakePrisma({ specialty: "specialist" });
405
+ const service = new AgentCronJobService(
406
+ prisma as unknown as PrismaClient,
407
+ undefined,
408
+ registry,
390
409
  );
410
+
411
+ const result = await service.reconcileSystemCrons("agent-1");
412
+
413
+ expect(result.created).toBe(1);
414
+ expect(registry.lookups).toEqual(["specialist"]);
415
+ expect(Array.from(rows.values())[0]?.name).toBe("shipwright-loop");
391
416
  });
417
+ });
392
418
 
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(),
397
- });
419
+ describe("reconcileSystemCrons (specialtyDefaults hook)", () => {
420
+ const extCron = {
421
+ name: "ext-cron",
422
+ schedule: "*/5 * * * *",
423
+ prompt: "/ext",
424
+ enabled: false,
425
+ };
426
+
427
+ test("hook crons win over the manifest and the manifest is not consulted", async () => {
428
+ const registry = fakeRegistry({ coding: [loopCron] });
429
+ const { prisma, rows } = makeFakePrisma({ specialty: "coding" });
430
+ const service = new AgentCronJobService(
431
+ prisma as unknown as PrismaClient,
432
+ undefined,
433
+ registry,
434
+ (specialty) =>
435
+ specialty === "coding" ? { crons: [extCron] } : undefined,
436
+ );
437
+
438
+ const result = await service.reconcileSystemCrons("agent-1");
439
+
440
+ expect(result.created).toBe(1);
441
+ expect(registry.lookups).toEqual([]);
442
+ const [row] = Array.from(rows.values());
443
+ expect(row?.name).toBe("ext-cron");
444
+ expect(row?.enabled).toBe(false);
445
+ });
446
+
447
+ test("hook returning nothing falls back to the manifest", async () => {
448
+ const registry = fakeRegistry({ coding: [loopCron] });
449
+ const { prisma, rows } = makeFakePrisma({ specialty: "coding" });
398
450
  const service = new AgentCronJobService(
399
451
  prisma as unknown as PrismaClient,
400
452
  undefined,
401
453
  registry,
454
+ () => undefined,
402
455
  );
403
456
 
404
- await expect(service.reconcileSystemCrons("agent-1")).rejects.toThrow(
405
- /SHIPWRIGHT_SPECIALTIES_DIR.*SHIPWRIGHT_DEFAULT_AGENT_TYPE/s,
457
+ await service.reconcileSystemCrons("agent-1");
458
+
459
+ expect(Array.from(rows.values()).map((r) => r.name)).toEqual([
460
+ "shipwright-loop",
461
+ ]);
462
+ });
463
+
464
+ test("defaults without crons fall back to the manifest crons", async () => {
465
+ const registry = fakeRegistry({ coding: [loopCron] });
466
+ const { prisma, rows } = makeFakePrisma({ specialty: "coding" });
467
+ const service = new AgentCronJobService(
468
+ prisma as unknown as PrismaClient,
469
+ undefined,
470
+ registry,
471
+ () => ({ tools: ["Read"] }),
406
472
  );
473
+
474
+ await service.reconcileSystemCrons("agent-1");
475
+
476
+ expect(Array.from(rows.values()).map((r) => r.name)).toEqual([
477
+ "shipwright-loop",
478
+ ]);
407
479
  });
408
480
 
409
- test("a valid non-coding manifest reconciles normally", async () => {
410
- const registry = fakeRegistry({ specialist: [loopCron] });
411
- const { prisma, rows } = makeFakePrisma({ specialty: "specialist" });
481
+ test("reconciles hook crons without any manifest for the specialty", async () => {
482
+ const registry = fakeRegistry({});
483
+ const { prisma, rows } = makeFakePrisma({ specialty: "ext-only" });
412
484
  const service = new AgentCronJobService(
413
485
  prisma as unknown as PrismaClient,
414
486
  undefined,
415
487
  registry,
488
+ () => ({ crons: [extCron] }),
416
489
  );
417
490
 
418
491
  const result = await service.reconcileSystemCrons("agent-1");
419
492
 
420
493
  expect(result.created).toBe(1);
421
- expect(registry.lookups).toEqual(["specialist"]);
422
- expect(Array.from(rows.values())[0]?.name).toBe("shipwright-loop");
494
+ expect(Array.from(rows.values())[0]?.name).toBe("ext-cron");
495
+ });
496
+
497
+ test("preserves row id and enabled state, links parents, and deletes orphans", async () => {
498
+ const registry = fakeRegistry({});
499
+ const { prisma, rows } = makeFakePrisma({
500
+ specialty: "coding",
501
+ existing: [
502
+ { id: "stable-id", name: "ext-cron", enabled: true },
503
+ { id: "orphan", name: "removed-cron" },
504
+ ],
505
+ });
506
+ const service = new AgentCronJobService(
507
+ prisma as unknown as PrismaClient,
508
+ undefined,
509
+ registry,
510
+ () => ({
511
+ crons: [
512
+ extCron,
513
+ {
514
+ name: "ext-child",
515
+ schedule: "* * * * *",
516
+ prompt: "/c",
517
+ parentCron: "ext-cron",
518
+ },
519
+ ],
520
+ }),
521
+ );
522
+
523
+ const result = await service.reconcileSystemCrons("agent-1");
524
+
525
+ expect(result).toEqual({ created: 1, updated: 1, deleted: 1 });
526
+ expect(rows.get("stable-id")?.enabled).toBe(true);
527
+ expect(rows.has("orphan")).toBe(false);
528
+ const child = Array.from(rows.values()).find((r) => r.name === "ext-child");
529
+ expect(child?.parentCronId).toBe("stable-id");
530
+ expect(child?.enabled).toBe(true);
423
531
  });
424
532
  });
package/src/agents-api.ts CHANGED
@@ -156,6 +156,7 @@ import {
156
156
  UpsertChatTokenDailyBodySchema,
157
157
  } from "./openapi-schemas.ts";
158
158
  import type { SelfServeConfig } from "./self-serve-config.ts";
159
+ import type { SpecialtyDefaultsResolver } from "./specialty-defaults.ts";
159
160
  import type { SpecialtyManifestResolver } from "./specialty-manifest-loader.ts";
160
161
  import { managedReconcileTargets } from "./startup-policy-reconcile.ts";
161
162
 
@@ -225,6 +226,8 @@ export interface AdminDeps {
225
226
  * Required (not optional) because the route needs it unconditionally.
226
227
  */
227
228
  specialtyRegistry: SpecialtyManifestResolver;
229
+ /** Extension-supplied specialty defaults, consulted before the manifest. */
230
+ specialtyDefaults?: SpecialtyDefaultsResolver;
228
231
  agentChatTokenService: Pick<
229
232
  AgentChatTokenService,
230
233
  "upsertDailyByModel" | "queryStats"
@@ -1522,6 +1525,7 @@ export function createAdminApp(deps: AdminDeps): OpenAPIHono<AdminAuthEnv> {
1522
1525
  agentPluginService,
1523
1526
  agentMemberService,
1524
1527
  specialtyRegistry,
1528
+ specialtyDefaults,
1525
1529
  agentChatTokenService,
1526
1530
  agentGitHubInstallationsService,
1527
1531
  prisma,
@@ -1930,7 +1934,7 @@ export function createAdminApp(deps: AdminDeps): OpenAPIHono<AdminAuthEnv> {
1930
1934
  // SSP-4.2: platform admins keep today's behavior (and may create
1931
1935
  // unassigned agents). A session user with an account (flag on) may create
1932
1936
  // agents in THEIR account only: accountId comes from the resolved scope —
1933
- // never the body — and the type is forced to "coding".
1937
+ // never the body — and the type is forced to the self-serve specialty.
1934
1938
  let accountId: string | undefined;
1935
1939
  if (c.get("isAdmin") !== true) {
1936
1940
  const email = c.get("callerEmail");
@@ -1965,6 +1969,7 @@ export function createAdminApp(deps: AdminDeps): OpenAPIHono<AdminAuthEnv> {
1965
1969
  agentCronJobService,
1966
1970
  provisioner,
1967
1971
  specialtyRegistry,
1972
+ specialtyDefaults,
1968
1973
  anthropicFederationEnabled: deps.anthropicFederationEnabled,
1969
1974
  },
1970
1975
  {
package/src/agents.ts CHANGED
@@ -23,6 +23,7 @@ import {
23
23
  type SpecialtyManifestResolver,
24
24
  SpecialtyRegistry,
25
25
  } from "./specialty-manifest-loader.ts";
26
+ import type { SpecialtyDefaultsResolver } from "./specialty-defaults.ts";
26
27
  import { ANTHROPIC_CREDENTIALS_BLOCKED_CODE } from "./anthropic-credential-guard.ts";
27
28
  import type { PrismaTransactionClient } from "./prisma-tx.ts";
28
29
 
@@ -784,6 +785,8 @@ export interface CreateAgentDeps {
784
785
  agentCronJobService: Pick<AgentCronJobService, "reconcileSystemCrons">;
785
786
  provisioner: Pick<AgentProvisioner, "canProvision" | "provision">;
786
787
  specialtyRegistry: Pick<SpecialtyManifestResolver, "tryGetManifest">;
788
+ /** Extension-supplied tools/plugins, consulted before the manifest's. */
789
+ specialtyDefaults?: SpecialtyDefaultsResolver;
787
790
  /** WIF-2.1: Anthropic federation is enabled; defaults to false. */
788
791
  anthropicFederationEnabled?: boolean;
789
792
  }
@@ -878,7 +881,10 @@ export async function createAgent(
878
881
  const manifest = specialty
879
882
  ? deps.specialtyRegistry.tryGetManifest(specialty)
880
883
  : undefined;
881
- if (!specialty || !manifest) {
884
+ const extensionDefaults = specialty
885
+ ? deps.specialtyDefaults?.(specialty)
886
+ : undefined;
887
+ if (!specialty || (!manifest && !extensionDefaults)) {
882
888
  return { ok: false, errorCode: "invalid_type" };
883
889
  }
884
890
 
@@ -940,10 +946,14 @@ export async function createAgent(
940
946
  // handling below, and the manifest's members/repos arrays are empty
941
947
  // for every specialty that exists today.
942
948
  try {
943
- for (const pattern of manifest.tools) {
949
+ for (const pattern of extensionDefaults?.tools ??
950
+ manifest?.tools ??
951
+ []) {
944
952
  await deps.agentToolService.add(agent.id, pattern, tx);
945
953
  }
946
- for (const pluginName of manifest.plugins) {
954
+ for (const pluginName of extensionDefaults?.plugins ??
955
+ manifest?.plugins ??
956
+ []) {
947
957
  await deps.agentPluginService.add(
948
958
  agent.id,
949
959
  pluginName,
@@ -1319,6 +1319,76 @@ function baseCreateAgentInput(
1319
1319
  };
1320
1320
  }
1321
1321
 
1322
+ describe("createAgent() specialtyDefaults hook", () => {
1323
+ it("seeds tools and plugins from the hook instead of the manifest", async () => {
1324
+ const { deps, calls } = makeCreateAgentHarness();
1325
+ deps.specialtyDefaults = () => ({
1326
+ tools: ["Grep"],
1327
+ plugins: ["ext-plugin"],
1328
+ });
1329
+
1330
+ const result = await createAgent(deps, baseCreateAgentInput());
1331
+
1332
+ expect(result.ok).toBe(true);
1333
+ expect(calls.toolsAdded.map((t) => t.pattern)).toEqual(["Grep"]);
1334
+ expect(calls.pluginsAdded.map((p) => p.name)).toEqual(["ext-plugin"]);
1335
+ });
1336
+
1337
+ it("falls back to the manifest when the hook returns nothing", async () => {
1338
+ const { deps, calls } = makeCreateAgentHarness();
1339
+ deps.specialtyDefaults = () => undefined;
1340
+
1341
+ await createAgent(deps, baseCreateAgentInput());
1342
+
1343
+ expect(calls.toolsAdded.map((t) => t.pattern)).toEqual(
1344
+ CODING_MANIFEST.tools,
1345
+ );
1346
+ expect(calls.pluginsAdded.map((p) => p.name)).toEqual(
1347
+ CODING_MANIFEST.plugins,
1348
+ );
1349
+ });
1350
+
1351
+ it("falls back per field when the hook omits tools or plugins", async () => {
1352
+ const { deps, calls } = makeCreateAgentHarness();
1353
+ deps.specialtyDefaults = () => ({ plugins: ["ext-plugin"] });
1354
+
1355
+ await createAgent(deps, baseCreateAgentInput());
1356
+
1357
+ expect(calls.toolsAdded.map((t) => t.pattern)).toEqual(
1358
+ CODING_MANIFEST.tools,
1359
+ );
1360
+ expect(calls.pluginsAdded.map((p) => p.name)).toEqual(["ext-plugin"]);
1361
+ });
1362
+
1363
+ it("accepts a specialty known only to the hook", async () => {
1364
+ const { deps, calls } = makeCreateAgentHarness();
1365
+ deps.specialtyDefaults = (s) =>
1366
+ s === "ext-only" ? { tools: ["Read"] } : undefined;
1367
+
1368
+ const result = await createAgent(
1369
+ deps,
1370
+ baseCreateAgentInput({ specialty: "ext-only" }),
1371
+ );
1372
+
1373
+ expect(result.ok).toBe(true);
1374
+ expect(calls.toolsAdded.map((t) => t.pattern)).toEqual(["Read"]);
1375
+ expect(calls.pluginsAdded).toEqual([]);
1376
+ });
1377
+
1378
+ it("still rejects a specialty unknown to both hook and manifest", async () => {
1379
+ const { deps, calls } = makeCreateAgentHarness();
1380
+ deps.specialtyDefaults = () => undefined;
1381
+
1382
+ const result = await createAgent(
1383
+ deps,
1384
+ baseCreateAgentInput({ specialty: "nope" }),
1385
+ );
1386
+
1387
+ expect(result).toEqual({ ok: false, errorCode: "invalid_type" });
1388
+ expect(calls.created).toHaveLength(0);
1389
+ });
1390
+ });
1391
+
1322
1392
  describe("createAgent()", () => {
1323
1393
  it("passes accountId through to agentService.create and tolerates an undefined tx (SSP-4.1)", async () => {
1324
1394
  const { deps, calls } = makeCreateAgentHarness();
@@ -0,0 +1,35 @@
1
+ /**
2
+ * admin/src/specialty-defaults.ts
3
+ *
4
+ * The shape of a specialty's default crons, tools and plugins as supplied by
5
+ * an AdminExtension's specialtyDefaults() hook (admin-extension.ts). The cron
6
+ * reconcile (agent-cron-jobs.ts) and create-time seeding (agents.ts) consult
7
+ * the hook first and fall back to the specialty manifest when it returns
8
+ * nothing. Kept in its own module so those services can name the types without
9
+ * importing admin-extension.ts.
10
+ */
11
+
12
+ /** A default cron template; same fields as a manifest cron, defaults optional. */
13
+ export interface SpecialtyDefaultCron {
14
+ name: string;
15
+ schedule: string;
16
+ prompt: string;
17
+ preCheck?: string;
18
+ silent?: boolean;
19
+ /** Initial enabled state for a newly created row; defaults to true. */
20
+ enabled?: boolean;
21
+ /** Name of another cron in this same list. */
22
+ parentCron?: string;
23
+ }
24
+
25
+ /** Any field left undefined falls back to the manifest for that field. */
26
+ export interface SpecialtyDefaults {
27
+ crons?: readonly SpecialtyDefaultCron[];
28
+ tools?: readonly string[];
29
+ plugins?: readonly string[];
30
+ }
31
+
32
+ /** Resolves a specialty name to extension-supplied defaults, if any. */
33
+ export type SpecialtyDefaultsResolver = (
34
+ specialty: string,
35
+ ) => SpecialtyDefaults | undefined;
@@ -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,19 @@ 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. No warning is
133
125
  * logged (an unknown type here is an expected, callers-handle-it outcome,
134
126
  * not a degraded-boot condition). Used by POST /admin/agents to reject an
135
127
  * unknown requested `type` with 400 rather than silently seeding a
136
- * "coding" agent under a different label.
128
+ * default-type agent under a different label.
137
129
  */
138
130
  tryGetManifest(specialty: string): SpecialtyManifest | undefined;
139
131
  /**
@@ -157,8 +149,8 @@ export class SpecialtyRegistry implements SpecialtyManifestResolver {
157
149
 
158
150
  /**
159
151
  * `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`.
152
+ * SHIPWRIGHT_DEFAULT_AGENT_TYPE, then to the shipped agent-types/
153
+ * and "specialist". An injected `read`/`list` takes precedence over `typesDir`.
162
154
  */
163
155
  constructor(
164
156
  opts: {
@@ -179,33 +171,29 @@ export class SpecialtyRegistry implements SpecialtyManifestResolver {
179
171
  }
180
172
 
181
173
  getManifest(specialty: string): SpecialtyManifest {
182
- const resolved = this.tryLoad(specialty);
174
+ const resolved = this.tryLoadSafe(specialty);
183
175
  if (resolved) return resolved;
184
176
 
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
177
  this.warn(
189
178
  `SpecialtyRegistry: unknown agent specialty "${specialty}" — ` +
190
179
  `falling back to "${this.defaultTypeName}" manifest`,
191
180
  );
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
- );
181
+ if (specialty !== this.defaultTypeName) {
182
+ const fallback = this.tryLoadSafe(this.defaultTypeName);
183
+ if (fallback) return fallback;
204
184
  }
185
+ // Even the default is unloadable (bad SHIPWRIGHT_SPECIALTIES_DIR /
186
+ // SHIPWRIGHT_DEFAULT_AGENT_TYPE override). Degrade to an empty manifest
187
+ // rather than failing boot; the warning names the knobs to fix.
188
+ this.warn(
189
+ `SpecialtyRegistry: the default specialty "${this.defaultTypeName}" could not be loaded from "${this.typesDir}" — ` +
190
+ "using a built-in manifest with no crons. Check SHIPWRIGHT_SPECIALTIES_DIR and SHIPWRIGHT_DEFAULT_AGENT_TYPE.",
191
+ );
192
+ return parseSpecialtyManifest(BUILT_IN_FALLBACK_MANIFEST_YAML);
205
193
  }
206
194
 
207
195
  /**
208
- * Resolve `specialty` without falling back to "coding" — see the interface
196
+ * Resolve `specialty` without falling back to the default type — see the interface
209
197
  * doc comment. Thin public wrapper around the existing tryLoad() used
210
198
  * internally by getManifest().
211
199
  */
@@ -227,6 +215,15 @@ export class SpecialtyRegistry implements SpecialtyManifestResolver {
227
215
  return options;
228
216
  }
229
217
 
218
+ /** tryLoad, but an invalid (unparseable) manifest also yields undefined. */
219
+ private tryLoadSafe(specialty: string): SpecialtyManifest | undefined {
220
+ try {
221
+ return this.tryLoad(specialty);
222
+ } catch {
223
+ return undefined;
224
+ }
225
+ }
226
+
230
227
  /** Load + cache a manifest, returning undefined only if the file is missing. */
231
228
  private tryLoad(specialty: string): SpecialtyManifest | undefined {
232
229
  const cached = this.cache.get(specialty);
@@ -240,13 +237,6 @@ export class SpecialtyRegistry implements SpecialtyManifestResolver {
240
237
  return this.parseAndCache(specialty, raw);
241
238
  }
242
239
 
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
240
  private parseAndCache(specialty: string, raw: string): SpecialtyManifest {
251
241
  const manifest = parseSpecialtyManifest(raw);
252
242
  this.cache.set(specialty, manifest);