@app-vitals/agent-runtime 0.16.1 → 1.0.0

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.
package/Dockerfile CHANGED
@@ -175,7 +175,7 @@ RUN if [ -f bun.lock ]; then bun install --frozen-lockfile; else bun install; fi
175
175
  COPY runtime/ ./runtime/
176
176
  COPY admin/ ./admin/
177
177
  COPY lib/ ./lib/
178
- # Agent-type definitions (setup.ts reads identity.templatesDir from them).
178
+ # Agent-type definitions (setup.ts reads each type's templates/ from them).
179
179
  COPY agent-types/ ./agent-types/
180
180
  # Composition root spawned by runtime/entrypoint.ts (../index.ts).
181
181
  COPY index.ts ./index.ts
@@ -7,43 +7,29 @@
7
7
  import { describe, expect, it } from "bun:test";
8
8
  import { existsSync } from "node:fs";
9
9
  import { join } from "node:path";
10
- import { SpecialtyRegistry } from "@app-vitals/agent-admin/specialty-manifest-loader";
10
+ import { SpecialtyTypes } from "@app-vitals/agent-admin/specialty-types";
11
11
  import {
12
12
  resolveDefaultSpecialtyName,
13
- resolveSpecialtiesDir,
14
13
  SHIPPED_AGENT_TYPES_DIR,
15
14
  } from "@app-vitals/agent-lib/default-specialty";
15
+ import { SPECIALIST_TYPE } from "@app-vitals/agent-lib/specialist-type";
16
16
  import { resolveTemplatesDir } from "./setup.ts";
17
17
 
18
18
  const EMPTY_ENV: Record<string, string | undefined> = {};
19
19
 
20
20
  describe("platform-only boot with empty env", () => {
21
- it("SpecialtyRegistry resolves the default specialist type from the shipped agent-types/ without warning", () => {
22
- const warnings: string[] = [];
23
- const registry = new SpecialtyRegistry({
24
- typesDir: resolveSpecialtiesDir(EMPTY_ENV),
25
- defaultTypeName: resolveDefaultSpecialtyName(EMPTY_ENV),
26
- warn: (m) => warnings.push(m),
27
- });
28
- const manifest = registry.getManifest(
29
- resolveDefaultSpecialtyName(EMPTY_ENV),
30
- );
31
- expect(manifest.metadata.name).toBe("specialist");
32
- expect(warnings).toEqual([]);
33
- expect(registry.listTypes().map((t) => t.name)).toContain("specialist");
21
+ it("the default type resolves to the built-in specialist type", () => {
22
+ const defaultName = resolveDefaultSpecialtyName(EMPTY_ENV);
23
+ const types = new SpecialtyTypes({ defaultTypeName: defaultName });
24
+ expect(defaultName).toBe("specialist");
25
+ expect(types.tryGetType(defaultName)).toBe(SPECIALIST_TYPE);
26
+ expect(types.listTypes().map((t) => t.name)).toEqual(["specialist"]);
34
27
  });
35
28
 
36
- it("an unknown type warns and resolves to the shipped specialist manifest", () => {
37
- const warnings: string[] = [];
38
- const registry = new SpecialtyRegistry({
39
- typesDir: SHIPPED_AGENT_TYPES_DIR,
40
- defaultTypeName: "specialist",
41
- warn: (m) => warnings.push(m),
42
- });
43
- expect(registry.getManifest("no-such-type").metadata.name).toBe(
44
- "specialist",
45
- );
46
- expect(warnings).toHaveLength(1);
29
+ it("a stored legacy type still resolves to the specialist floor, but is not a valid new type", () => {
30
+ const types = new SpecialtyTypes({ defaultTypeName: "specialist" });
31
+ expect(types.getType("no-such-type")).toBe(SPECIALIST_TYPE);
32
+ expect(types.tryGetType("no-such-type")).toBeUndefined();
47
33
  });
48
34
 
49
35
  it("runtime resolveTemplatesDir agrees: the default type's shipped templates, no warning", () => {
@@ -56,6 +42,12 @@ describe("platform-only boot with empty env", () => {
56
42
  );
57
43
  expect(warnings).toEqual([]);
58
44
  expect(dir).toBe(join(SHIPPED_AGENT_TYPES_DIR, "specialist", "templates"));
45
+ expect(dir).toBe(
46
+ join(import.meta.dir, "..", SPECIALIST_TYPE.templatesDir).replace(
47
+ /\/$/,
48
+ "",
49
+ ),
50
+ );
59
51
  expect(existsSync(join(dir, "CLAUDE.md.template"))).toBe(true);
60
52
  });
61
53
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@app-vitals/agent-runtime",
3
- "version": "0.16.1",
3
+ "version": "1.0.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/app-vitals/agent.git",
@@ -40,8 +40,8 @@
40
40
  "./workspace-paths": "./workspace-paths.ts"
41
41
  },
42
42
  "dependencies": {
43
- "@app-vitals/agent-admin": "0.16.1",
44
- "@app-vitals/agent-lib": "0.16.1",
43
+ "@app-vitals/agent-admin": "1.0.0",
44
+ "@app-vitals/agent-lib": "1.0.0",
45
45
  "@octokit/auth-app": "^8.2.0",
46
46
  "@sentry/bun": "^10.63.0",
47
47
  "@slack/bolt": "^5.0.0",
@@ -92,18 +92,9 @@ describe("fixture specialty templates", () => {
92
92
  });
93
93
 
94
94
  describe("coding is not special-cased (PCD-2.2)", () => {
95
- const writeCodingManifest = (templatesDir: string) => {
95
+ it("resolves coding through the templates/ convention under the specialties dir", () => {
96
96
  const specialties = join(root, "specialties");
97
- mkdirSync(join(specialties, "coding"), { recursive: true });
98
- writeFileSync(
99
- join(specialties, "coding", "manifest.yaml"),
100
- `identity:\n templatesDir: ${templatesDir}\n`,
101
- );
102
- return specialties;
103
- };
104
-
105
- it("resolves coding through its manifest identity.templatesDir", () => {
106
- const tpl = join(root, "coding-tpl");
97
+ const tpl = join(specialties, "coding", "templates");
107
98
  mkdirSync(tpl, { recursive: true });
108
99
  for (const f of [
109
100
  "CLAUDE.md.template",
@@ -113,7 +104,7 @@ describe("coding is not special-cased (PCD-2.2)", () => {
113
104
  writeFileSync(join(tpl, f), "x");
114
105
  const warnings: string[] = [];
115
106
  const dir = resolveTemplatesDir("coding", root, (m) => warnings.push(m), {
116
- SHIPWRIGHT_SPECIALTIES_DIR: writeCodingManifest(tpl),
107
+ SHIPWRIGHT_SPECIALTIES_DIR: specialties,
117
108
  });
118
109
  expect(dir).toBe(tpl);
119
110
  expect(warnings).toEqual([]);
@@ -894,6 +894,19 @@ describe("findStalePluginSpecs", () => {
894
894
  // these tests depend on ambient sandbox/image state.
895
895
  const NO_EXTRA_MARKETPLACES: string[] = [];
896
896
 
897
+ const SHIPWRIGHT_ROW: AgentPlugin = {
898
+ plugin: "shipwright",
899
+ marketplace: "shipwright",
900
+ };
901
+
902
+ // A repo root carrying a local marketplace (coding image); platform images ship none.
903
+ const repoRoot = (): string => {
904
+ const root = join(testHome, "repo-root");
905
+ mkdirSync(join(root, ".claude-plugin"), { recursive: true });
906
+ writeFileSync(join(root, ".claude-plugin", "marketplace.json"), "{}");
907
+ return root;
908
+ };
909
+
897
910
  describe("installPlugins", () => {
898
911
  it("registers local marketplace then installs shipwright@shipwright using the shipwright marketplace", async () => {
899
912
  const calls: Array<{ cmd: string; args: string[] }> = [];
@@ -909,20 +922,15 @@ describe("installPlugins", () => {
909
922
  await installPlugins(
910
923
  mockExec,
911
924
  testHome,
912
- [],
913
- "/repo/root",
925
+ [SHIPWRIGHT_ROW],
926
+ repoRoot(),
914
927
  join(testHome, "nonexistent.json"),
915
928
  NO_EXTRA_MARKETPLACES,
916
929
  );
917
930
 
918
931
  // marketplace add + 1 install + 1 update = 3 calls (no stale paths)
919
932
  expect(calls).toHaveLength(3);
920
- expect(calls[0].args).toEqual([
921
- "plugin",
922
- "marketplace",
923
- "add",
924
- "/repo/root",
925
- ]);
933
+ expect(calls[0].args).toEqual(["plugin", "marketplace", "add", repoRoot()]);
926
934
  expect(calls[1].args).toEqual([
927
935
  "plugin",
928
936
  "install",
@@ -935,7 +943,7 @@ describe("installPlugins", () => {
935
943
  ]);
936
944
  });
937
945
 
938
- it("installs agent-specific plugins after default plugins", async () => {
946
+ it("installs row plugins in row order", async () => {
939
947
  const calls: Array<{ cmd: string; args: string[] }> = [];
940
948
  const mockExec = async (
941
949
  cmd: string,
@@ -947,6 +955,7 @@ describe("installPlugins", () => {
947
955
  };
948
956
 
949
957
  const agentPlugins: AgentPlugin[] = [
958
+ SHIPWRIGHT_ROW,
950
959
  { marketplace: "my-marketplace", plugin: "custom-plugin" },
951
960
  { marketplace: "other-market", plugin: "another-plugin" },
952
961
  ];
@@ -955,7 +964,7 @@ describe("installPlugins", () => {
955
964
  mockExec,
956
965
  testHome,
957
966
  agentPlugins,
958
- "/repo/root",
967
+ repoRoot(),
959
968
  join(testHome, "nonexistent.json"),
960
969
  NO_EXTRA_MARKETPLACES,
961
970
  );
@@ -963,14 +972,9 @@ describe("installPlugins", () => {
963
972
  // 1 marketplace add + 3 installs + 3 updates = 7 calls
964
973
  expect(calls).toHaveLength(7);
965
974
 
966
- expect(calls[0].args).toEqual([
967
- "plugin",
968
- "marketplace",
969
- "add",
970
- "/repo/root",
971
- ]);
975
+ expect(calls[0].args).toEqual(["plugin", "marketplace", "add", repoRoot()]);
972
976
 
973
- // installs: default first, then agent-specific
977
+ // installs in row order
974
978
  expect(calls[1].args).toEqual([
975
979
  "plugin",
976
980
  "install",
@@ -987,7 +991,7 @@ describe("installPlugins", () => {
987
991
  "another-plugin@other-market",
988
992
  ]);
989
993
 
990
- // updates: default first, then agent-specific
994
+ // updates in row order
991
995
  expect(calls[4].args).toEqual([
992
996
  "plugin",
993
997
  "update",
@@ -1005,7 +1009,7 @@ describe("installPlugins", () => {
1005
1009
  ]);
1006
1010
  });
1007
1011
 
1008
- it("empty agentPlugins installs only default plugins", async () => {
1012
+ it("no plugin rows installs nothing (platform agents)", async () => {
1009
1013
  const calls: Array<{ args: string[] }> = [];
1010
1014
  const mockExec = async (
1011
1015
  _cmd: string,
@@ -1020,15 +1024,12 @@ describe("installPlugins", () => {
1020
1024
  mockExec,
1021
1025
  testHome,
1022
1026
  [],
1023
- "/repo/root",
1027
+ "/repo/root-without-marketplace",
1024
1028
  join(testHome, "nonexistent.json"),
1025
1029
  NO_EXTRA_MARKETPLACES,
1026
1030
  );
1027
1031
 
1028
- // marketplace add + 1 install + 1 update = 3 calls
1029
- expect(calls).toHaveLength(3);
1030
- expect(calls[1].args).toContain("shipwright@shipwright");
1031
- expect(calls[2].args).toContain("shipwright@shipwright");
1032
+ expect(calls).toEqual([]);
1032
1033
  });
1033
1034
 
1034
1035
  it("is non-fatal when the claude binary isn't available", async () => {
@@ -1040,8 +1041,8 @@ describe("installPlugins", () => {
1040
1041
  installPlugins(
1041
1042
  throwingExec,
1042
1043
  testHome,
1043
- [],
1044
- "/repo/root",
1044
+ [SHIPWRIGHT_ROW],
1045
+ repoRoot(),
1045
1046
  join(testHome, "nonexistent.json"),
1046
1047
  NO_EXTRA_MARKETPLACES,
1047
1048
  ),
@@ -1067,8 +1068,8 @@ describe("installPlugins", () => {
1067
1068
  installPlugins(
1068
1069
  mockExec,
1069
1070
  testHome,
1070
- [],
1071
- "/repo/root",
1071
+ [SHIPWRIGHT_ROW],
1072
+ repoRoot(),
1072
1073
  join(testHome, "nonexistent.json"),
1073
1074
  NO_EXTRA_MARKETPLACES,
1074
1075
  ),
@@ -1097,8 +1098,8 @@ describe("installPlugins", () => {
1097
1098
  installPlugins(
1098
1099
  mockExec,
1099
1100
  testHome,
1100
- [],
1101
- "/repo/root",
1101
+ [SHIPWRIGHT_ROW],
1102
+ repoRoot(),
1102
1103
  join(testHome, "nonexistent.json"),
1103
1104
  NO_EXTRA_MARKETPLACES,
1104
1105
  ),
@@ -1123,8 +1124,8 @@ describe("installPlugins", () => {
1123
1124
  installPlugins(
1124
1125
  mockExec,
1125
1126
  testHome,
1126
- [],
1127
- "/repo/root",
1127
+ [SHIPWRIGHT_ROW],
1128
+ repoRoot(),
1128
1129
  join(testHome, "nonexistent.json"),
1129
1130
  NO_EXTRA_MARKETPLACES,
1130
1131
  ),
@@ -1163,8 +1164,8 @@ describe("installPlugins", () => {
1163
1164
  await installPlugins(
1164
1165
  mockExec,
1165
1166
  testHome,
1166
- [],
1167
- "/repo/root",
1167
+ [SHIPWRIGHT_ROW],
1168
+ repoRoot(),
1168
1169
  manifestPath,
1169
1170
  NO_EXTRA_MARKETPLACES,
1170
1171
  );
@@ -1224,20 +1225,15 @@ describe("installPlugins", () => {
1224
1225
  await installPlugins(
1225
1226
  mockExec,
1226
1227
  testHome,
1227
- [],
1228
- "/repo/root",
1228
+ [SHIPWRIGHT_ROW],
1229
+ repoRoot(),
1229
1230
  manifestPath,
1230
1231
  NO_EXTRA_MARKETPLACES,
1231
1232
  );
1232
1233
 
1233
1234
  // marketplace add + uninstall (failed) = 2 calls; install and update are skipped
1234
1235
  expect(calls).toHaveLength(2);
1235
- expect(calls[0].args).toEqual([
1236
- "plugin",
1237
- "marketplace",
1238
- "add",
1239
- "/repo/root",
1240
- ]);
1236
+ expect(calls[0].args).toEqual(["plugin", "marketplace", "add", repoRoot()]);
1241
1237
  expect(calls[1].args).toEqual([
1242
1238
  "plugin",
1243
1239
  "uninstall",
@@ -1277,8 +1273,8 @@ describe("installPlugins", () => {
1277
1273
  await installPlugins(
1278
1274
  mockExec,
1279
1275
  testHome,
1280
- [],
1281
- "/repo/root",
1276
+ [SHIPWRIGHT_ROW],
1277
+ repoRoot(),
1282
1278
  manifestPath,
1283
1279
  NO_EXTRA_MARKETPLACES,
1284
1280
  );
@@ -1392,8 +1388,8 @@ describe("installPlugins — extra marketplace discovery", () => {
1392
1388
  await installPlugins(
1393
1389
  mockExec,
1394
1390
  testHome,
1395
- [],
1396
- "/repo/root",
1391
+ [SHIPWRIGHT_ROW],
1392
+ repoRoot(),
1397
1393
  join(testHome, "nonexistent.json"),
1398
1394
  undefined, // use default discovery — pass conventionRoot via extraMarketplacesRoot
1399
1395
  conventionRoot,
@@ -1403,7 +1399,7 @@ describe("installPlugins — extra marketplace discovery", () => {
1403
1399
  const addCalls = calls.filter((c) => c.args[1] === "marketplace");
1404
1400
  expect(addCalls.length).toBeGreaterThanOrEqual(2);
1405
1401
  expect(addCalls[0].args[3]).toBe(extraMarketDir);
1406
- expect(addCalls[1].args[3]).toBe("/repo/root");
1402
+ expect(addCalls[1].args[3]).toBe(repoRoot());
1407
1403
 
1408
1404
  // All install calls must come after all marketplace add calls
1409
1405
  const installIdx = calls.findIndex((c) => c.args[1] === "install");
@@ -1444,8 +1440,8 @@ describe("installPlugins — extra marketplace discovery", () => {
1444
1440
  await installPlugins(
1445
1441
  mockExec,
1446
1442
  testHome,
1447
- [],
1448
- "/repo/root",
1443
+ [SHIPWRIGHT_ROW],
1444
+ repoRoot(),
1449
1445
  join(testHome, "nonexistent.json"),
1450
1446
  undefined,
1451
1447
  conventionRoot,
@@ -1457,7 +1453,7 @@ describe("installPlugins — extra marketplace discovery", () => {
1457
1453
  const addDirs = addCalls.map((c) => c.args[3]);
1458
1454
  expect(addDirs).toContain(validDir);
1459
1455
  expect(addDirs).not.toContain(invalidDir);
1460
- expect(addDirs).toContain("/repo/root");
1456
+ expect(addDirs).toContain(repoRoot());
1461
1457
  });
1462
1458
 
1463
1459
  it("is backward compatible when no extra marketplace dirs exist", async () => {
@@ -1478,8 +1474,8 @@ describe("installPlugins — extra marketplace discovery", () => {
1478
1474
  await installPlugins(
1479
1475
  mockExec,
1480
1476
  testHome,
1481
- [],
1482
- "/repo/root",
1477
+ [SHIPWRIGHT_ROW],
1478
+ repoRoot(),
1483
1479
  join(testHome, "nonexistent.json"),
1484
1480
  undefined,
1485
1481
  conventionRoot,
@@ -1488,7 +1484,7 @@ describe("installPlugins — extra marketplace discovery", () => {
1488
1484
  // Only the /repo/root marketplace add — same as before this feature
1489
1485
  const addCalls = calls.filter((c) => c.args[1] === "marketplace");
1490
1486
  expect(addCalls).toHaveLength(1);
1491
- expect(addCalls[0].args[3]).toBe("/repo/root");
1487
+ expect(addCalls[0].args[3]).toBe(repoRoot());
1492
1488
  // marketplace add + install + update = 3 calls total
1493
1489
  expect(calls).toHaveLength(3);
1494
1490
  });
package/setup.ts CHANGED
@@ -20,12 +20,13 @@ import {
20
20
  unlinkSync,
21
21
  writeFileSync,
22
22
  } from "node:fs";
23
- import { dirname, join, resolve } from "node:path";
23
+ import { join, resolve } from "node:path";
24
24
  import type { AgentPlugin } from "@app-vitals/agent-admin";
25
25
  import {
26
26
  resolveDefaultSpecialtyName,
27
27
  resolveSpecialtiesDir,
28
28
  } from "@app-vitals/agent-lib/default-specialty";
29
+ import { SPECIALIST_TYPE } from "@app-vitals/agent-lib/specialist-type";
29
30
 
30
31
  // ---------------------------------------------------------------------------
31
32
  // Template loading
@@ -57,12 +58,6 @@ export function resolveShipwrightRepoRoot(
57
58
  return env.SHIPWRIGHT_REPO_ROOT || DEFAULT_SHIPWRIGHT_REPO_ROOT;
58
59
  }
59
60
 
60
- // Local marketplace name — matches the `name` field in .claude-plugin/marketplace.json.
61
- const SHIPWRIGHT_MARKETPLACE = "shipwright";
62
-
63
- // Default plugin installed on all Shipwright agents.
64
- const DEFAULT_PLUGINS: readonly string[] = ["shipwright"];
65
-
66
61
  // The persona templates a type's templatesDir must provide or it is not usable.
67
62
  // Everything else (VOICE, mise, requirements, BOOTSTRAP) falls back per file to
68
63
  // the built-in workspace/ dir, and SPECIALTY.md.template is optional (ARD-6.2).
@@ -75,17 +70,17 @@ const REQUIRED_TEMPLATES: readonly string[] = [
75
70
  /**
76
71
  * Resolves the workspace template directory for a specialty (ARD-6.1).
77
72
  *
78
- * Reads `identity.templatesDir` from `<specialtiesDir>/<specialty>/manifest.yaml`,
79
- * where specialtiesDir is SHIPWRIGHT_SPECIALTIES_DIR or the shipped
80
- * `agent-types/` (shared helper: @app-vitals/agent-lib/default-specialty). A
81
- * relative templatesDir resolves against the repo root, then the specialties
82
- * dir's parent (how the shipped manifests spell it: `agent-types/<t>/templates/`),
83
- * then the manifest's own directory. An absent type resolves as the default
84
- * type (`specialist`, or SHIPWRIGHT_DEFAULT_AGENT_TYPE). Returns the built-in
85
- * (generic) workspace/ dir for any type that does not resolve (no manifest,
86
- * unparseable, or the directory is missing or lacks a required template),
87
- * logging a warning. An unknown requested type first falls back to the default
88
- * type's templates (with a warning) before the built-in dir. Never throws: a template lookup must not fail boot.
73
+ * A type's templates live at `<specialtiesDir>/<specialty>/templates/`, where
74
+ * specialtiesDir is SHIPWRIGHT_SPECIALTIES_DIR or the shipped `agent-types/`
75
+ * (shared helper: @app-vitals/agent-lib/default-specialty). The built-in
76
+ * specialist type additionally resolves its record's `templatesDir`
77
+ * (lib/specialist-type.ts) against the repo root. An absent type resolves as
78
+ * the default type (`specialist`, or SHIPWRIGHT_DEFAULT_AGENT_TYPE). Returns
79
+ * the built-in (generic) workspace/ dir for any type that does not resolve
80
+ * (the directory is missing or lacks a required template), logging a warning.
81
+ * An unknown requested type first falls back to the default type's templates
82
+ * (with a warning) before the built-in dir. Never throws: a template lookup
83
+ * must not fail boot.
89
84
  */
90
85
  export function resolveTemplatesDir(
91
86
  requestedType: string | undefined,
@@ -99,24 +94,13 @@ export function resolveTemplatesDir(
99
94
  const specialtiesDir = resolveSpecialtiesDir(env);
100
95
 
101
96
  const lookup = (name: string): string => {
102
- const manifestPath = join(specialtiesDir, name, "manifest.yaml");
103
- const manifest = Bun.YAML.parse(readFileSync(manifestPath, "utf8")) as {
104
- identity?: { templatesDir?: unknown };
105
- };
106
- const templatesDir = manifest?.identity?.templatesDir;
107
- if (typeof templatesDir !== "string" || templatesDir === "") {
108
- throw new Error("manifest has no identity.templatesDir");
97
+ const candidates = [join(specialtiesDir, name, "templates")];
98
+ if (name === SPECIALIST_TYPE.name) {
99
+ candidates.push(resolve(repoRoot, SPECIALIST_TYPE.templatesDir));
109
100
  }
110
- // Manifests spell templatesDir relative to the repo root, or (shipped
111
- // agent-types/, where the repo root may not be the image root) relative to
112
- // the specialties dir's parent; a self-contained specialty (e.g. the
113
- // platform test fixture) spells it relative to its own directory.
114
101
  const dir =
115
- [
116
- resolve(repoRoot, templatesDir),
117
- resolve(dirname(specialtiesDir), templatesDir),
118
- ].find((candidate) => existsSync(candidate)) ??
119
- resolve(dirname(manifestPath), templatesDir);
102
+ candidates.find((candidate) => existsSync(candidate)) ??
103
+ (candidates[0] as string);
120
104
  if (!statSync(dir).isDirectory()) {
121
105
  throw new Error(`${dir} is not a directory`);
122
106
  }
@@ -171,7 +155,7 @@ function readTemplate(
171
155
  `Missing workspace template: ${path}. The runtime ships no product ` +
172
156
  "templates of its own: set SHIPWRIGHT_SPECIALTIES_DIR to a directory " +
173
157
  "of specialties and SHIPWRIGHT_DEFAULT_AGENT_TYPE to the specialty " +
174
- "whose identity.templatesDir provides it.",
158
+ "whose templates/ directory provides it.",
175
159
  );
176
160
  }
177
161
  }
@@ -399,15 +383,15 @@ export function findStalePluginSpecs(
399
383
  }
400
384
 
401
385
  /**
402
- * Installs the shipwright default plugins plus any agent-specific plugins
403
- * from the config bundle. All commands are idempotent (exit 0 when already
386
+ * Installs the plugins in the agent's own AgentPlugin rows (seeded from the
387
+ * specialty manifest at create time). No plugin is installed by default. All commands are idempotent (exit 0 when already
404
388
  * present / already at latest), so reboots are a silent no-op.
405
389
  *
406
390
  * Non-fatal — a missing `claude` binary or network blip logs and returns
407
391
  * without throwing, so startup is never blocked.
408
392
  *
409
- * Install order: defaults first, then agent-specific plugins.
410
- * Update order: same.
393
+ * The built-in marketplace is only registered when `shipwrightRepoRoot` has a
394
+ * `.claude-plugin/marketplace.json` (the platform image ships none).
411
395
  *
412
396
  * Marketplace registration order:
413
397
  * 1. Extra baked marketplaces (discovered from `extraMarketplacesRoot` or
@@ -463,24 +447,22 @@ export async function installPlugins(
463
447
 
464
448
  // Register the local (built-in) shipwright marketplace so `claude plugin install`
465
449
  // can resolve plugins by name. Idempotent — safe to call on every startup.
466
- const add = await execFn(
467
- "claude",
468
- ["plugin", "marketplace", "add", shipwrightRepoRoot],
469
- { cwd },
470
- );
471
- if (add.exitCode !== 0) {
472
- console.warn(
473
- `[agent] claude plugin marketplace add exited ${add.exitCode}: ${add.stdout}`,
450
+ if (
451
+ existsSync(join(shipwrightRepoRoot, ".claude-plugin", "marketplace.json"))
452
+ ) {
453
+ const add = await execFn(
454
+ "claude",
455
+ ["plugin", "marketplace", "add", shipwrightRepoRoot],
456
+ { cwd },
474
457
  );
458
+ if (add.exitCode !== 0) {
459
+ console.warn(
460
+ `[agent] claude plugin marketplace add exited ${add.exitCode}: ${add.stdout}`,
461
+ );
462
+ }
475
463
  }
476
464
 
477
- const defaultPluginSpecs = DEFAULT_PLUGINS.map(
478
- (name) => `${name}@${SHIPWRIGHT_MARKETPLACE}`,
479
- );
480
- const agentPluginSpecs = agentPlugins.map(
481
- (p) => `${p.plugin}@${p.marketplace}`,
482
- );
483
- const allSpecs = [...defaultPluginSpecs, ...agentPluginSpecs];
465
+ const allSpecs = agentPlugins.map((p) => `${p.plugin}@${p.marketplace}`);
484
466
 
485
467
  // Uninstall any plugin whose recorded installPath no longer exists. This
486
468
  // clears stale manifest entries left over from a different-HOME container
@@ -502,7 +484,7 @@ export async function installPlugins(
502
484
  }
503
485
  }
504
486
 
505
- // Install all plugins (defaults then agent-specific).
487
+ // Install all plugins .
506
488
  // Skip any stale spec whose uninstall failed — install is idempotent and
507
489
  // would silently succeed without fixing the stale path, hiding the breakage.
508
490
  for (const spec of allSpecs) {
@@ -517,7 +499,7 @@ export async function installPlugins(
517
499
  }
518
500
  }
519
501
 
520
- // Update all plugins (defaults then agent-specific).
502
+ // Update all plugins .
521
503
  // Skip specs whose uninstall failed for the same reason as the install loop.
522
504
  for (const spec of allSpecs) {
523
505
  if (failedUninstalls.has(spec)) continue;
@@ -4,7 +4,7 @@
4
4
  * Pure logic tests: no I/O, no real binaries, injected exec doubles only.
5
5
  */
6
6
 
7
- import { describe, expect, it } from "bun:test";
7
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
8
8
  import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
9
9
  import { tmpdir } from "node:os";
10
10
  import { join } from "node:path";
@@ -22,56 +22,64 @@ import {
22
22
  // these tests depend on ambient sandbox/image state.
23
23
  const NO_EXTRA_MARKETPLACES: string[] = [];
24
24
 
25
- describe("installPlugins — local marketplace", () => {
26
- it("registers the marketplace before installing plugins", async () => {
27
- const calls: Array<{ cmd: string; args: string[] }> = [];
28
- const mockExec = async (
29
- cmd: string,
30
- args: string[],
31
- _opts: { cwd: string },
32
- ) => {
33
- calls.push({ cmd, args });
34
- return { stdout: "", exitCode: 0 };
35
- };
25
+ const SHIPWRIGHT_ROW = { plugin: "shipwright", marketplace: "shipwright" };
26
+
27
+ // A repo root that carries a local marketplace (a coding image); the platform
28
+ // image has none.
29
+ const makeMarketplaceRoot = (): string => {
30
+ const root = mkdtempSync(join(tmpdir(), "mkt-root-"));
31
+ mkdirSync(join(root, ".claude-plugin"), { recursive: true });
32
+ writeFileSync(join(root, ".claude-plugin", "marketplace.json"), "{}");
33
+ return root;
34
+ };
35
+
36
+ const recordCalls = () => {
37
+ const calls: Array<{ cmd: string; args: string[] }> = [];
38
+ const exec = async (cmd: string, args: string[], _opts: { cwd: string }) => {
39
+ calls.push({ cmd, args });
40
+ return { stdout: "", exitCode: 0 };
41
+ };
42
+ return { calls, exec };
43
+ };
36
44
 
37
- await installPlugins(
38
- mockExec,
45
+ describe("installPlugins — local marketplace", () => {
46
+ let root: string;
47
+ beforeEach(() => {
48
+ root = makeMarketplaceRoot();
49
+ });
50
+ afterEach(() => rmSync(root, { recursive: true, force: true }));
51
+
52
+ const run = (
53
+ exec: ReturnType<typeof recordCalls>["exec"],
54
+ plugins: Array<{ plugin: string; marketplace: string }>,
55
+ repoRoot = root,
56
+ extra: string[] = NO_EXTRA_MARKETPLACES,
57
+ ) =>
58
+ installPlugins(
59
+ exec,
39
60
  "/tmp/cwd",
40
- [],
41
- "/repo/root",
61
+ plugins,
62
+ repoRoot,
42
63
  "/tmp/nonexistent-manifest.json",
43
- NO_EXTRA_MARKETPLACES,
64
+ extra,
44
65
  );
45
66
 
46
- // First call must be marketplace add
47
- expect(calls[0].args).toEqual([
48
- "plugin",
49
- "marketplace",
50
- "add",
51
- "/repo/root",
52
- ]);
67
+ it("registers the marketplace before installing plugins", async () => {
68
+ const { calls, exec } = recordCalls();
69
+ await run(exec, [SHIPWRIGHT_ROW]);
70
+ expect(calls[0].args).toEqual(["plugin", "marketplace", "add", root]);
53
71
  });
54
72
 
55
- it("installs and updates shipwright@shipwright using the shipwright marketplace name", async () => {
56
- const calls: Array<{ cmd: string; args: string[] }> = [];
57
- const mockExec = async (
58
- cmd: string,
59
- args: string[],
60
- _opts: { cwd: string },
61
- ) => {
62
- calls.push({ cmd, args });
63
- return { stdout: "", exitCode: 0 };
64
- };
65
-
66
- await installPlugins(
67
- mockExec,
68
- "/tmp/cwd",
69
- [],
70
- "/repo/root",
71
- "/tmp/nonexistent-manifest.json",
72
- NO_EXTRA_MARKETPLACES,
73
- );
73
+ it("installs nothing when the agent has no plugin rows", async () => {
74
+ const { calls, exec } = recordCalls();
75
+ await run(exec, []);
76
+ expect(calls.filter((c) => c.args[1] === "install")).toEqual([]);
77
+ expect(calls.filter((c) => c.args[1] === "update")).toEqual([]);
78
+ });
74
79
 
80
+ it("installs and updates shipwright@shipwright for a coding agent's shipwright row", async () => {
81
+ const { calls, exec } = recordCalls();
82
+ await run(exec, [SHIPWRIGHT_ROW]);
75
83
  // marketplace add + install + update = 3 calls
76
84
  expect(calls).toHaveLength(3);
77
85
  expect(calls[1].args).toEqual([
@@ -87,29 +95,14 @@ describe("installPlugins — local marketplace", () => {
87
95
  });
88
96
 
89
97
  it("agent plugins use their own marketplace, not the local one", async () => {
90
- const calls: Array<{ cmd: string; args: string[] }> = [];
91
- const mockExec = async (
92
- cmd: string,
93
- args: string[],
94
- _opts: { cwd: string },
95
- ) => {
96
- calls.push({ cmd, args });
97
- return { stdout: "", exitCode: 0 };
98
- };
99
-
100
- await installPlugins(
101
- mockExec,
102
- "/tmp/cwd",
103
- [{ plugin: "my-plugin", marketplace: "org/my-marketplace" }],
104
- "/repo/root",
105
- "/tmp/nonexistent-manifest.json",
106
- NO_EXTRA_MARKETPLACES,
107
- );
108
-
98
+ const { calls, exec } = recordCalls();
99
+ await run(exec, [
100
+ SHIPWRIGHT_ROW,
101
+ { plugin: "my-plugin", marketplace: "org/my-marketplace" },
102
+ ]);
109
103
  // marketplace add + 2 installs + 2 updates = 5 calls
110
104
  expect(calls).toHaveLength(5);
111
- const specs = calls.slice(1).map((c) => c.args[2]);
112
- expect(specs).toEqual([
105
+ expect(calls.slice(1).map((c) => c.args[2])).toEqual([
113
106
  "shipwright@shipwright",
114
107
  "my-plugin@org/my-marketplace",
115
108
  "shipwright@shipwright",
@@ -117,128 +110,30 @@ describe("installPlugins — local marketplace", () => {
117
110
  ]);
118
111
  });
119
112
 
120
- it("passes the repoRoot to marketplace add", async () => {
121
- const calls: Array<{ cmd: string; args: string[] }> = [];
122
- const mockExec = async (
123
- cmd: string,
124
- args: string[],
125
- _opts: { cwd: string },
126
- ) => {
127
- calls.push({ cmd, args });
128
- return { stdout: "", exitCode: 0 };
129
- };
130
-
131
- await installPlugins(
132
- mockExec,
133
- "/tmp/cwd",
134
- [],
135
- "/custom/repo/root",
136
- "/tmp/nonexistent-manifest.json",
137
- NO_EXTRA_MARKETPLACES,
138
- );
139
-
140
- expect(calls[0].args).toEqual([
141
- "plugin",
142
- "marketplace",
143
- "add",
144
- "/custom/repo/root",
145
- ]);
113
+ it("skips the local marketplace add when the repo root has no marketplace", async () => {
114
+ const { calls, exec } = recordCalls();
115
+ await run(exec, [], "/repo/root");
116
+ expect(calls).toEqual([]);
146
117
  });
147
118
 
148
- it("registers extra marketplaces before the /app marketplace", async () => {
149
- const calls: Array<{ cmd: string; args: string[] }> = [];
150
- const mockExec = async (
151
- cmd: string,
152
- args: string[],
153
- _opts: { cwd: string },
154
- ) => {
155
- calls.push({ cmd, args });
156
- return { stdout: "", exitCode: 0 };
157
- };
158
-
159
- // Pass a non-existent root so discoverBakedMarketplaces can't find dirs —
160
- // we inject via extraMarketplaceDirs directly instead.
161
- await installPlugins(
162
- mockExec,
163
- "/tmp/cwd",
164
- [],
165
- "/repo/root",
166
- "/tmp/nonexistent-manifest.json",
167
- ["/opt/shipwright/marketplaces/my-marketplace"],
168
- );
169
-
170
- // Extra marketplace add must come BEFORE the /repo/root marketplace add
119
+ it("registers extra marketplaces before the local marketplace", async () => {
120
+ const { calls, exec } = recordCalls();
121
+ await run(exec, [], root, ["/opt/shipwright/marketplaces/my-marketplace"]);
171
122
  const addCalls = calls.filter((c) => c.args[1] === "marketplace");
172
123
  expect(addCalls[0].args[3]).toBe(
173
124
  "/opt/shipwright/marketplaces/my-marketplace",
174
125
  );
175
- expect(addCalls[1].args[3]).toBe("/repo/root");
176
- });
177
-
178
- it("is a no-op when no extra marketplace dirs exist", async () => {
179
- const calls: Array<{ cmd: string; args: string[] }> = [];
180
- const mockExec = async (
181
- cmd: string,
182
- args: string[],
183
- _opts: { cwd: string },
184
- ) => {
185
- calls.push({ cmd, args });
186
- return { stdout: "", exitCode: 0 };
187
- };
188
-
189
- await installPlugins(
190
- mockExec,
191
- "/tmp/cwd",
192
- [],
193
- "/repo/root",
194
- "/tmp/nonexistent-manifest.json",
195
- NO_EXTRA_MARKETPLACES,
196
- );
197
-
198
- // Only the /repo/root marketplace add — no extra adds
199
- const addCalls = calls.filter((c) => c.args[1] === "marketplace");
200
- expect(addCalls).toHaveLength(1);
201
- expect(addCalls[0].args[3]).toBe("/repo/root");
126
+ expect(addCalls[1].args[3]).toBe(root);
202
127
  });
203
128
 
204
129
  it("never reads the real BAKED_MARKETPLACES_ROOT when extraMarketplaceDirs is injected (isolation regression)", async () => {
205
- // Regression test for T-002: previously, tests that omitted the
206
- // extraMarketplaceDirs arg silently fell through to
207
- // discoverBakedMarketplaces(BAKED_MARKETPLACES_ROOT), which performs a
208
- // real existsSync/readdirSync against /opt/shipwright/marketplaces. On any
209
- // image/sandbox that bakes a marketplace there (this sandbox does), that
210
- // extra ambient `marketplace add` call would silently appear before the
211
- // expected calls and break exact-match assertions below.
212
- //
213
- // This test asserts the injected empty array wins deterministically,
214
- // regardless of what (if anything) actually exists on disk at
215
- // /opt/shipwright/marketplaces in the environment running the suite.
216
- const calls: Array<{ cmd: string; args: string[] }> = [];
217
- const mockExec = async (
218
- cmd: string,
219
- args: string[],
220
- _opts: { cwd: string },
221
- ) => {
222
- calls.push({ cmd, args });
223
- return { stdout: "", exitCode: 0 };
224
- };
225
-
226
- await installPlugins(
227
- mockExec,
228
- "/tmp/cwd",
229
- [],
230
- "/repo/root",
231
- "/tmp/nonexistent-manifest.json",
232
- NO_EXTRA_MARKETPLACES,
233
- );
234
-
235
- // Exactly one marketplace add (the built-in shipwright one) — no extra
236
- // marketplace add sneaks in from real fs discovery, no matter what's
237
- // actually present on disk at BAKED_MARKETPLACES_ROOT in this environment.
130
+ // The injected empty array must win over real fs discovery of
131
+ // /opt/shipwright/marketplaces, whatever the sandbox has baked there.
132
+ const { calls, exec } = recordCalls();
133
+ await run(exec, [SHIPWRIGHT_ROW]);
238
134
  const addCalls = calls.filter((c) => c.args[1] === "marketplace");
239
135
  expect(addCalls).toHaveLength(1);
240
- expect(addCalls[0].args[3]).toBe("/repo/root");
241
- // Full call count is deterministic: marketplace add + install + update = 3.
136
+ expect(addCalls[0].args[3]).toBe(root);
242
137
  expect(calls).toHaveLength(3);
243
138
  });
244
139
  });
@@ -267,7 +162,7 @@ describe("discoverBakedMarketplaces", () => {
267
162
  });
268
163
 
269
164
  describe("resolveTemplatesDir option resolution (AEP-1.2)", () => {
270
- it("resolves the shipped specialist manifest to agent-types/specialist/templates/ with no warning (SAI-1.2)", async () => {
165
+ it("resolves the shipped specialist type to agent-types/specialist/templates/ with no warning (SAI-1.2)", async () => {
271
166
  const { resolveTemplatesDir } = await import("./setup.ts");
272
167
  const repoRoot = join(import.meta.dir, "..");
273
168
  const warnings: string[] = [];
@@ -339,10 +234,10 @@ describe("resolveTemplatesDir option resolution (AEP-1.2)", () => {
339
234
  SHIPWRIGHT_SPECIALTIES_DIR: "/fixture/types",
340
235
  },
341
236
  );
342
- // Missing fixture manifest → warns naming the type, falls back built-in.
237
+ // Missing fixture templates dir → warns naming the type, falls back built-in.
343
238
  expect(warnings).toHaveLength(1);
344
239
  expect(warnings[0]).toContain('"base"');
345
- expect(warnings[0]).toContain("/fixture/types/base/manifest.yaml");
240
+ expect(warnings[0]).toContain("/fixture/types/base/templates");
346
241
  expect(dir).toMatch(/workspace$/);
347
242
  });
348
243
  });
@@ -368,19 +263,24 @@ describe("SHIPWRIGHT_REPO_ROOT override (PCB-1.1)", () => {
368
263
  "/tmp/nonexistent-baked-root",
369
264
  env,
370
265
  );
371
- return calls[0][3];
266
+ return calls[0]?.[3];
372
267
  };
373
268
 
374
269
  it("installPlugins registers the marketplace from SHIPWRIGHT_REPO_ROOT when set", async () => {
375
- expect(await registerRoot({ SHIPWRIGHT_REPO_ROOT: "/opt/custom" })).toBe(
376
- "/opt/custom",
377
- );
270
+ const custom = makeMarketplaceRoot();
271
+ try {
272
+ expect(await registerRoot({ SHIPWRIGHT_REPO_ROOT: custom })).toBe(custom);
273
+ } finally {
274
+ rmSync(custom, { recursive: true, force: true });
275
+ }
378
276
  });
379
277
 
380
278
  it("installPlugins keeps the computed default when unset or empty", async () => {
381
279
  const unset = await registerRoot({});
382
- expect(unset).toBe(resolveShipwrightRepoRoot({}));
383
- expect(unset).not.toBe("/opt/custom");
280
+ // Only registered when the computed default root carries a marketplace.
281
+ expect(unset === undefined || unset === resolveShipwrightRepoRoot({})).toBe(
282
+ true,
283
+ );
384
284
  expect(await registerRoot({ SHIPWRIGHT_REPO_ROOT: "" })).toBe(unset);
385
285
  });
386
286
 
@@ -388,22 +288,18 @@ describe("SHIPWRIGHT_REPO_ROOT override (PCB-1.1)", () => {
388
288
  const makeRoot = () => {
389
289
  const root = mkdtempSync(join(tmpdir(), "repo-root-"));
390
290
  const dir = join(root, "types", "foo");
391
- mkdirSync(join(dir, "tpl"), { recursive: true });
392
- writeFileSync(
393
- join(dir, "manifest.yaml"),
394
- "identity:\n templatesDir: tpl\n",
395
- );
291
+ mkdirSync(join(dir, "templates"), { recursive: true });
396
292
  for (const f of [
397
293
  "CLAUDE.md.template",
398
294
  "SOUL.md.template",
399
295
  "IDENTITY.md.template",
400
296
  ]) {
401
- writeFileSync(join(dir, "tpl", f), "x");
297
+ writeFileSync(join(dir, "templates", f), "x");
402
298
  }
403
- return { root, tpl: join(dir, "tpl") };
299
+ return { root, tpl: join(dir, "templates") };
404
300
  };
405
301
 
406
- it("resolves templatesDir against SHIPWRIGHT_REPO_ROOT when set", () => {
302
+ it("resolves a type's templates/ under SHIPWRIGHT_SPECIALTIES_DIR when SHIPWRIGHT_REPO_ROOT is set", () => {
407
303
  const { root, tpl } = makeRoot();
408
304
  try {
409
305
  const warnings: string[] = [];
@@ -4,7 +4,7 @@
4
4
  * The platform's SPECIALTY.md.template is the generic fallback for every agent
5
5
  * type, so it must stay a neutral stub. Specialty-specific pipeline content
6
6
  * (e.g. the coding specialty's Shipwright workflow) is shipped by the specialty
7
- * itself through its manifest's identity.templatesDir.
7
+ * itself in its templates/ directory.
8
8
  *
9
9
  * Content-assertion only: readFileSync, no I/O beyond local file reads.
10
10
  */
@@ -1,35 +0,0 @@
1
- # Minimal fixture specialty (AGR-1.9). Platform tests point
2
- # SHIPWRIGHT_SPECIALTIES_DIR at the parent of this directory and
3
- # SHIPWRIGHT_DEFAULT_AGENT_TYPE at "fixture", so the platform test suite needs
4
- # no coding specialty. It is test data, not a product specialty: no crons, no
5
- # plugins, no GitHub scope.
6
- apiVersion: shipwright.dev/v1alpha1
7
- kind: Specialty
8
- metadata:
9
- name: fixture
10
- displayName: Fixture Agent
11
- description: Minimal specialty used by the platform test suite.
12
- version: 1.0.0
13
- skills: []
14
- identity:
15
- # Resolved against the repo root first, then against this manifest's own directory.
16
- templatesDir: templates/
17
- crons: []
18
- plugins:
19
- - fixture-plugin
20
- tools:
21
- - Bash
22
- - WebSearch
23
- env:
24
- required:
25
- - key: CLAUDE_CODE_OAUTH_TOKEN
26
- description: Claude Code OAuth token used to authenticate the agent's Claude sessions.
27
- secret: true
28
- optional:
29
- - key: GH_TOKEN
30
- description: GitHub Personal Access Token.
31
- secret: true
32
- members: []
33
- repos: []
34
- chat: true
35
- voice: true