@app-vitals/agent-runtime 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.
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Platform-only boot against the REAL shipped agent-types/ directory with an
3
+ * empty environment (PCD-3.1). Env is passed explicitly: the test preload
4
+ * presets fixture env vars on process.env, so nothing here reads it.
5
+ */
6
+
7
+ import { describe, expect, it } from "bun:test";
8
+ import { existsSync } from "node:fs";
9
+ import { join } from "node:path";
10
+ import { SpecialtyRegistry } from "@app-vitals/agent-admin/specialty-manifest-loader";
11
+ import {
12
+ resolveDefaultSpecialtyName,
13
+ resolveSpecialtiesDir,
14
+ SHIPPED_AGENT_TYPES_DIR,
15
+ } from "@app-vitals/agent-lib/default-specialty";
16
+ import { resolveTemplatesDir } from "./setup.ts";
17
+
18
+ const EMPTY_ENV: Record<string, string | undefined> = {};
19
+
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");
34
+ });
35
+
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);
47
+ });
48
+
49
+ it("runtime resolveTemplatesDir agrees: the default type's shipped templates, no warning", () => {
50
+ const warnings: string[] = [];
51
+ const dir = resolveTemplatesDir(
52
+ undefined,
53
+ join(import.meta.dir, ".."),
54
+ (m) => warnings.push(m),
55
+ EMPTY_ENV,
56
+ );
57
+ expect(warnings).toEqual([]);
58
+ expect(dir).toBe(join(SHIPPED_AGENT_TYPES_DIR, "specialist", "templates"));
59
+ expect(existsSync(join(dir, "CLAUDE.md.template"))).toBe(true);
60
+ });
61
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@app-vitals/agent-runtime",
3
- "version": "0.16.0",
3
+ "version": "0.16.2",
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.0",
44
- "@app-vitals/agent-lib": "0.16.0",
43
+ "@app-vitals/agent-admin": "0.16.2",
44
+ "@app-vitals/agent-lib": "0.16.2",
45
45
  "@octokit/auth-app": "^8.2.0",
46
46
  "@sentry/bun": "^10.63.0",
47
47
  "@slack/bolt": "^5.0.0",
@@ -91,6 +91,48 @@ describe("fixture specialty templates", () => {
91
91
  });
92
92
  });
93
93
 
94
+ describe("coding is not special-cased (PCD-2.2)", () => {
95
+ const writeCodingManifest = (templatesDir: string) => {
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");
107
+ mkdirSync(tpl, { recursive: true });
108
+ for (const f of [
109
+ "CLAUDE.md.template",
110
+ "SOUL.md.template",
111
+ "IDENTITY.md.template",
112
+ ])
113
+ writeFileSync(join(tpl, f), "x");
114
+ const warnings: string[] = [];
115
+ const dir = resolveTemplatesDir("coding", root, (m) => warnings.push(m), {
116
+ SHIPWRIGHT_SPECIALTIES_DIR: writeCodingManifest(tpl),
117
+ });
118
+ expect(dir).toBe(tpl);
119
+ expect(warnings).toEqual([]);
120
+ });
121
+
122
+ it("falls back to the built-in dir with a warning when coding is unresolvable", () => {
123
+ const warnings: string[] = [];
124
+ const dir = resolveTemplatesDir("coding", root, (m) => warnings.push(m), {
125
+ SHIPWRIGHT_SPECIALTIES_DIR: join(root, "no-specialties"),
126
+ });
127
+ expect(dir).toMatch(/workspace$/);
128
+ // Unknown "coding" warns and tries the default type, which is also absent
129
+ // here, so a second warning precedes the built-in fallback.
130
+ expect(warnings).toHaveLength(2);
131
+ expect(warnings[0]).toContain('"coding"');
132
+ expect(warnings[1]).toContain("built-in");
133
+ });
134
+ });
135
+
94
136
  describe("no default specialty configured", () => {
95
137
  it("names the env vars when the default specialty's templates cannot be found", () => {
96
138
  const emptyDir = join(root, "empty-templates");
@@ -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
@@ -22,6 +22,10 @@ import {
22
22
  } from "node:fs";
23
23
  import { dirname, join, resolve } from "node:path";
24
24
  import type { AgentPlugin } from "@app-vitals/agent-admin";
25
+ import {
26
+ resolveDefaultSpecialtyName,
27
+ resolveSpecialtiesDir,
28
+ } from "@app-vitals/agent-lib/default-specialty";
25
29
 
26
30
  // ---------------------------------------------------------------------------
27
31
  // Template loading
@@ -53,16 +57,6 @@ export function resolveShipwrightRepoRoot(
53
57
  return env.SHIPWRIGHT_REPO_ROOT || DEFAULT_SHIPWRIGHT_REPO_ROOT;
54
58
  }
55
59
 
56
- // Local marketplace name — matches the `name` field in .claude-plugin/marketplace.json.
57
- const SHIPWRIGHT_MARKETPLACE = "shipwright";
58
-
59
- // Default plugin installed on all Shipwright agents.
60
- const DEFAULT_PLUGINS: readonly string[] = ["shipwright"];
61
-
62
- // The universal fallback type — mirrors admin's DEFAULT_SPECIALTY_NAME. Its
63
- // templates are the built-in workspace/ dir, so it never needs a manifest lookup.
64
- const DEFAULT_SPECIALTY_NAME = "coding";
65
-
66
60
  // The persona templates a type's templatesDir must provide or it is not usable.
67
61
  // Everything else (VOICE, mise, requirements, BOOTSTRAP) falls back per file to
68
62
  // the built-in workspace/ dir, and SPECIALTY.md.template is optional (ARD-6.2).
@@ -75,12 +69,17 @@ const REQUIRED_TEMPLATES: readonly string[] = [
75
69
  /**
76
70
  * Resolves the workspace template directory for a specialty (ARD-6.1).
77
71
  *
78
- * Reads `identity.templatesDir` from `<repoRoot>/specialties/<specialty>/manifest.yaml`
79
- * (relative paths resolve against the repo root, matching how the manifests
80
- * spell them). Returns the built-in workspace/ dir for `coding`, an absent
81
- * type, and any type that does not resolve (no manifest, unparseable, or the
82
- * directory is missing or lacks a required template) — logging a warning for the latter. Never throws:
83
- * a template lookup must not fail boot.
72
+ * Reads `identity.templatesDir` from `<specialtiesDir>/<specialty>/manifest.yaml`,
73
+ * where specialtiesDir is SHIPWRIGHT_SPECIALTIES_DIR or the shipped
74
+ * `agent-types/` (shared helper: @app-vitals/agent-lib/default-specialty). A
75
+ * relative templatesDir resolves against the repo root, then the specialties
76
+ * dir's parent (how the shipped manifests spell it: `agent-types/<t>/templates/`),
77
+ * then the manifest's own directory. An absent type resolves as the default
78
+ * type (`specialist`, or SHIPWRIGHT_DEFAULT_AGENT_TYPE). Returns the built-in
79
+ * (generic) workspace/ dir for any type that does not resolve (no manifest,
80
+ * unparseable, or the directory is missing or lacks a required template),
81
+ * logging a warning. An unknown requested type first falls back to the default
82
+ * type's templates (with a warning) before the built-in dir. Never throws: a template lookup must not fail boot.
84
83
  */
85
84
  export function resolveTemplatesDir(
86
85
  requestedType: string | undefined,
@@ -89,19 +88,12 @@ export function resolveTemplatesDir(
89
88
  env: Record<string, string | undefined> = process.env,
90
89
  ): string {
91
90
  const repoRoot = repoRootOverride ?? resolveShipwrightRepoRoot(env);
92
- const specialty =
93
- requestedType ||
94
- env.SHIPWRIGHT_DEFAULT_AGENT_TYPE ||
95
- DEFAULT_SPECIALTY_NAME;
96
- if (specialty === DEFAULT_SPECIALTY_NAME) {
97
- return WORKSPACE_TEMPLATE_DIR;
98
- }
99
- try {
100
- const manifestPath = join(
101
- env.SHIPWRIGHT_SPECIALTIES_DIR || join(repoRoot, "specialties"),
102
- specialty,
103
- "manifest.yaml",
104
- );
91
+ const defaultSpecialty = resolveDefaultSpecialtyName(env);
92
+ const specialty = requestedType || defaultSpecialty;
93
+ const specialtiesDir = resolveSpecialtiesDir(env);
94
+
95
+ const lookup = (name: string): string => {
96
+ const manifestPath = join(specialtiesDir, name, "manifest.yaml");
105
97
  const manifest = Bun.YAML.parse(readFileSync(manifestPath, "utf8")) as {
106
98
  identity?: { templatesDir?: unknown };
107
99
  };
@@ -109,27 +101,49 @@ export function resolveTemplatesDir(
109
101
  if (typeof templatesDir !== "string" || templatesDir === "") {
110
102
  throw new Error("manifest has no identity.templatesDir");
111
103
  }
112
- // Manifests written for the monorepo spell templatesDir relative to the
113
- // repo root; a self-contained specialty (e.g. the platform test fixture)
114
- // spells it relative to its own directory.
115
- const fromRepoRoot = resolve(repoRoot, templatesDir);
116
- const dir = existsSync(fromRepoRoot)
117
- ? fromRepoRoot
118
- : resolve(dirname(manifestPath), templatesDir);
104
+ // Manifests spell templatesDir relative to the repo root, or (shipped
105
+ // agent-types/, where the repo root may not be the image root) relative to
106
+ // the specialties dir's parent; a self-contained specialty (e.g. the
107
+ // platform test fixture) spells it relative to its own directory.
108
+ const dir =
109
+ [
110
+ resolve(repoRoot, templatesDir),
111
+ resolve(dirname(specialtiesDir), templatesDir),
112
+ ].find((candidate) => existsSync(candidate)) ??
113
+ resolve(dirname(manifestPath), templatesDir);
119
114
  if (!statSync(dir).isDirectory()) {
120
115
  throw new Error(`${dir} is not a directory`);
121
116
  }
122
117
  const missing = REQUIRED_TEMPLATES.filter(
123
- (name) => !existsSync(join(dir, name)),
118
+ (file) => !existsSync(join(dir, file)),
124
119
  );
125
120
  if (missing.length > 0) {
126
121
  throw new Error(`${dir} is missing templates: ${missing.join(", ")}`);
127
122
  }
128
123
  return dir;
124
+ };
125
+
126
+ try {
127
+ return lookup(specialty);
129
128
  } catch (err) {
129
+ if (specialty !== defaultSpecialty) {
130
+ warn(
131
+ `[setup] could not resolve workspace templates for specialty "${specialty}" ` +
132
+ `(${(err as Error).message}) — falling back to the default type "${defaultSpecialty}"`,
133
+ );
134
+ try {
135
+ return lookup(defaultSpecialty);
136
+ } catch (defaultErr) {
137
+ warn(
138
+ `[setup] could not resolve workspace templates for default specialty "${defaultSpecialty}" ` +
139
+ `(${(defaultErr as Error).message}) — falling back to the built-in workspace templates`,
140
+ );
141
+ return WORKSPACE_TEMPLATE_DIR;
142
+ }
143
+ }
130
144
  warn(
131
145
  `[setup] could not resolve workspace templates for specialty "${specialty}" ` +
132
- `(${(err as Error).message}) — falling back to "${DEFAULT_SPECIALTY_NAME}" templates`,
146
+ `(${(err as Error).message}) — falling back to the built-in workspace templates`,
133
147
  );
134
148
  return WORKSPACE_TEMPLATE_DIR;
135
149
  }
@@ -379,15 +393,15 @@ export function findStalePluginSpecs(
379
393
  }
380
394
 
381
395
  /**
382
- * Installs the shipwright default plugins plus any agent-specific plugins
383
- * from the config bundle. All commands are idempotent (exit 0 when already
396
+ * Installs the plugins in the agent's own AgentPlugin rows (seeded from the
397
+ * specialty manifest at create time). No plugin is installed by default. All commands are idempotent (exit 0 when already
384
398
  * present / already at latest), so reboots are a silent no-op.
385
399
  *
386
400
  * Non-fatal — a missing `claude` binary or network blip logs and returns
387
401
  * without throwing, so startup is never blocked.
388
402
  *
389
- * Install order: defaults first, then agent-specific plugins.
390
- * Update order: same.
403
+ * The built-in marketplace is only registered when `shipwrightRepoRoot` has a
404
+ * `.claude-plugin/marketplace.json` (the platform image ships none).
391
405
  *
392
406
  * Marketplace registration order:
393
407
  * 1. Extra baked marketplaces (discovered from `extraMarketplacesRoot` or
@@ -443,24 +457,22 @@ export async function installPlugins(
443
457
 
444
458
  // Register the local (built-in) shipwright marketplace so `claude plugin install`
445
459
  // can resolve plugins by name. Idempotent — safe to call on every startup.
446
- const add = await execFn(
447
- "claude",
448
- ["plugin", "marketplace", "add", shipwrightRepoRoot],
449
- { cwd },
450
- );
451
- if (add.exitCode !== 0) {
452
- console.warn(
453
- `[agent] claude plugin marketplace add exited ${add.exitCode}: ${add.stdout}`,
460
+ if (
461
+ existsSync(join(shipwrightRepoRoot, ".claude-plugin", "marketplace.json"))
462
+ ) {
463
+ const add = await execFn(
464
+ "claude",
465
+ ["plugin", "marketplace", "add", shipwrightRepoRoot],
466
+ { cwd },
454
467
  );
468
+ if (add.exitCode !== 0) {
469
+ console.warn(
470
+ `[agent] claude plugin marketplace add exited ${add.exitCode}: ${add.stdout}`,
471
+ );
472
+ }
455
473
  }
456
474
 
457
- const defaultPluginSpecs = DEFAULT_PLUGINS.map(
458
- (name) => `${name}@${SHIPWRIGHT_MARKETPLACE}`,
459
- );
460
- const agentPluginSpecs = agentPlugins.map(
461
- (p) => `${p.plugin}@${p.marketplace}`,
462
- );
463
- const allSpecs = [...defaultPluginSpecs, ...agentPluginSpecs];
475
+ const allSpecs = agentPlugins.map((p) => `${p.plugin}@${p.marketplace}`);
464
476
 
465
477
  // Uninstall any plugin whose recorded installPath no longer exists. This
466
478
  // clears stale manifest entries left over from a different-HOME container
@@ -482,7 +494,7 @@ export async function installPlugins(
482
494
  }
483
495
  }
484
496
 
485
- // Install all plugins (defaults then agent-specific).
497
+ // Install all plugins .
486
498
  // Skip any stale spec whose uninstall failed — install is idempotent and
487
499
  // would silently succeed without fixing the stale path, hiding the breakage.
488
500
  for (const spec of allSpecs) {
@@ -497,7 +509,7 @@ export async function installPlugins(
497
509
  }
498
510
  }
499
511
 
500
- // Update all plugins (defaults then agent-specific).
512
+ // Update all plugins .
501
513
  // Skip specs whose uninstall failed for the same reason as the install loop.
502
514
  for (const spec of allSpecs) {
503
515
  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
  });
@@ -281,9 +176,49 @@ describe("resolveTemplatesDir option resolution (AEP-1.2)", () => {
281
176
  expect(warnings).toEqual([]);
282
177
  });
283
178
 
284
- it("resolves an absent type to the built-in templates with no env set", async () => {
179
+ it("resolves an absent type as the shipped specialist type with no env and no warning", async () => {
285
180
  const { resolveTemplatesDir } = await import("./setup.ts");
286
- const dir = resolveTemplatesDir(undefined, "/nonexistent", () => {}, {});
181
+ const warnings: string[] = [];
182
+ const dir = resolveTemplatesDir(
183
+ undefined,
184
+ join(import.meta.dir, ".."),
185
+ (m) => warnings.push(m),
186
+ {},
187
+ );
188
+ expect(warnings).toEqual([]);
189
+ expect(dir).toBe(
190
+ join(import.meta.dir, "..", "agent-types", "specialist", "templates"),
191
+ );
192
+ });
193
+
194
+ it("warns and falls back to the default type's templates for an unknown requested type", async () => {
195
+ const { resolveTemplatesDir } = await import("./setup.ts");
196
+ const warnings: string[] = [];
197
+ const dir = resolveTemplatesDir(
198
+ "renamed-type",
199
+ join(import.meta.dir, ".."),
200
+ (m) => warnings.push(m),
201
+ {},
202
+ );
203
+ expect(warnings).toHaveLength(1);
204
+ expect(warnings[0]).toContain('"renamed-type"');
205
+ expect(warnings[0]).toContain('"specialist"');
206
+ expect(dir).toBe(
207
+ join(import.meta.dir, "..", "agent-types", "specialist", "templates"),
208
+ );
209
+ });
210
+
211
+ it("falls back to the built-in templates when the default type cannot load either", async () => {
212
+ const { resolveTemplatesDir } = await import("./setup.ts");
213
+ const warnings: string[] = [];
214
+ const dir = resolveTemplatesDir(
215
+ "renamed-type",
216
+ "/nonexistent",
217
+ (m) => warnings.push(m),
218
+ { SHIPWRIGHT_SPECIALTIES_DIR: "/nonexistent/types" },
219
+ );
220
+ expect(warnings).toHaveLength(2);
221
+ expect(warnings[1]).toContain("built-in");
287
222
  expect(dir).toMatch(/workspace$/);
288
223
  });
289
224
 
@@ -328,28 +263,36 @@ describe("SHIPWRIGHT_REPO_ROOT override (PCB-1.1)", () => {
328
263
  "/tmp/nonexistent-baked-root",
329
264
  env,
330
265
  );
331
- return calls[0][3];
266
+ return calls[0]?.[3];
332
267
  };
333
268
 
334
269
  it("installPlugins registers the marketplace from SHIPWRIGHT_REPO_ROOT when set", async () => {
335
- expect(await registerRoot({ SHIPWRIGHT_REPO_ROOT: "/opt/custom" })).toBe(
336
- "/opt/custom",
337
- );
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
+ }
338
276
  });
339
277
 
340
278
  it("installPlugins keeps the computed default when unset or empty", async () => {
341
279
  const unset = await registerRoot({});
342
- expect(unset).toBe(resolveShipwrightRepoRoot({}));
343
- 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
+ );
344
284
  expect(await registerRoot({ SHIPWRIGHT_REPO_ROOT: "" })).toBe(unset);
345
285
  });
346
286
 
347
287
  describe("resolveTemplatesDir", () => {
348
288
  const makeRoot = () => {
349
289
  const root = mkdtempSync(join(tmpdir(), "repo-root-"));
350
- const dir = join(root, "specialties", "foo");
290
+ const dir = join(root, "types", "foo");
351
291
  mkdirSync(join(dir, "tpl"), { recursive: true });
352
- writeFileSync(join(dir, "manifest.yaml"), "identity:\n templatesDir: tpl\n");
292
+ writeFileSync(
293
+ join(dir, "manifest.yaml"),
294
+ "identity:\n templatesDir: tpl\n",
295
+ );
353
296
  for (const f of [
354
297
  "CLAUDE.md.template",
355
298
  "SOUL.md.template",
@@ -360,7 +303,7 @@ describe("SHIPWRIGHT_REPO_ROOT override (PCB-1.1)", () => {
360
303
  return { root, tpl: join(dir, "tpl") };
361
304
  };
362
305
 
363
- it("looks up specialties under SHIPWRIGHT_REPO_ROOT when set", () => {
306
+ it("resolves templatesDir against SHIPWRIGHT_REPO_ROOT when set", () => {
364
307
  const { root, tpl } = makeRoot();
365
308
  try {
366
309
  const warnings: string[] = [];
@@ -368,7 +311,10 @@ describe("SHIPWRIGHT_REPO_ROOT override (PCB-1.1)", () => {
368
311
  "foo",
369
312
  undefined,
370
313
  (m) => warnings.push(m),
371
- { SHIPWRIGHT_REPO_ROOT: root },
314
+ {
315
+ SHIPWRIGHT_REPO_ROOT: root,
316
+ SHIPWRIGHT_SPECIALTIES_DIR: join(root, "types"),
317
+ },
372
318
  );
373
319
  expect(warnings).toEqual([]);
374
320
  expect(dir).toBe(tpl);
@@ -1,37 +1,6 @@
1
- ### Shipwright
2
-
3
- You are a software engineering agent. All planning and code changes use the shipwright workflow:
4
-
5
- - **Planning:** `/shipwright:plan-session`
6
- - **Execution:** `/shipwright:dev-task` (single) or `/shipwright:dev-loop` (continuous)
7
- - **Review:** `/shipwright:review`
8
-
9
- The work queue lives in the hosted task-store service — query it directly:
10
-
11
- ```bash
12
- curl -sf -H "Authorization: Bearer $SHIPWRIGHT_TASK_STORE_TOKEN" \
13
- "$SHIPWRIGHT_TASK_STORE_URL/tasks?ready=true" | jq '.tasks'
14
- ```
15
-
16
- `dev-task`, `review`, `patch`, and `deploy` are **explicit-target-only executors** — none of
17
- them self-discovers work. Candidate selection happens upstream, once per tick, in the
18
- `shipwright-loop` cron: its candidate providers merge dev-task/review/patch/deploy
19
- candidates and pick exactly one winning item via strict age-based FIFO, then dispatch the
20
- matching command with the winning task id or `org/repo#number` embedded directly in the
21
- prompt. `dev-task`, `review`, and `patch` are enabled by default; `deploy` is opt-in. A
22
- standalone phase cron with `shipwright-loop` disabled is **silently inert** — its prompt
23
- carries no target, so every tick responds `[silent]` and does nothing.
24
-
25
- ### Policy
26
-
27
- Agent behavior is controlled by the policy fields on the agent record (review
28
- posting, self-review, confidence and finding limits, worktree cleanup),
29
- delivered via `GET /agents/:id/config` and edited through the agents UI or
30
- `PATCH /agents/:id`.
31
-
32
- ### Reviews
33
-
34
- Review state is tracked in the task store's `PullRequest` record (`staged` +
35
- `commitSha` fields). Review artifacts (PR_REVIEW files and review JSON) live in
36
- `state/reviews/`. See `/shipwright:review` for the full flow.
1
+ ### Specialty
37
2
 
3
+ This workspace was seeded from the platform's generic fallback template because
4
+ no specialty-specific workspace content was available for this agent type.
5
+ Describe this agent's specialty, tools, and workflow here. Existing workspace
6
+ files are never rewritten, so edits to this file persist.
@@ -1,10 +1,10 @@
1
1
  /**
2
- * SPECIALTY.md.template content regression guard — DPF-1.1
2
+ * SPECIALTY.md.template content regression guard — PCD-2.2
3
3
  *
4
- * Verifies the coding specialty's "Shipwright" section describes the
5
- * shipwright-loop-driven, explicit-target-only pipeline model (WLS-6.1), not
6
- * the stale self-discovering-cron model it replaced. Relocated from
7
- * the former CLAUDE template content test when the template was layered (ARD-6.2).
4
+ * The platform's SPECIALTY.md.template is the generic fallback for every agent
5
+ * type, so it must stay a neutral stub. Specialty-specific pipeline content
6
+ * (e.g. the coding specialty's Shipwright workflow) is shipped by the specialty
7
+ * itself through its manifest's identity.templatesDir.
8
8
  *
9
9
  * Content-assertion only: readFileSync, no I/O beyond local file reads.
10
10
  */
@@ -19,32 +19,23 @@ function readTemplate(): string {
19
19
  return readFileSync(TEMPLATE_PATH, "utf8");
20
20
  }
21
21
 
22
- describe("SPECIALTY.md.template — shipwright-loop model", () => {
23
- it("mentions the shipwright-loop cron as the driver", () => {
24
- expect(readTemplate()).toContain("shipwright-loop");
22
+ describe("SPECIALTY.md.template — generic stub", () => {
23
+ it("is a non-empty stub", () => {
24
+ expect(readTemplate().trim().length).toBeGreaterThan(0);
25
25
  });
26
26
 
27
- it("describes dev-task/review/patch/deploy as explicit-target-only executors", () => {
28
- expect(readTemplate().toLowerCase()).toContain("explicit-target-only");
29
- });
30
-
31
- it("mentions strict age-based FIFO work selection", () => {
32
- expect(readTemplate().toLowerCase()).toContain("fifo");
33
- });
34
-
35
- it("notes a standalone phase cron with shipwright-loop disabled is silently inert", () => {
36
- expect(readTemplate().toLowerCase()).toContain("silently inert");
37
- });
38
-
39
- it("does not claim dev-task picks up pending items on a fixed schedule independent of the loop", () => {
40
- expect(readTemplate()).not.toContain(
41
- "picks up the next pending item every 30 minutes",
42
- );
43
- });
44
-
45
- it("does not describe review/patch as independently self-discovering phases", () => {
46
- expect(readTemplate()).not.toContain(
47
- "run as independent phases — review posts",
48
- );
27
+ it.each([
28
+ "shipwright",
29
+ "dev-task",
30
+ "dev-loop",
31
+ "plan-session",
32
+ "task-store",
33
+ "TASK_STORE",
34
+ "PullRequest",
35
+ "explicit-target-only",
36
+ "fifo",
37
+ "curl",
38
+ ])("does not mention Shipwright-pipeline term %p", (term) => {
39
+ expect(readTemplate().toLowerCase()).not.toContain(term.toLowerCase());
49
40
  });
50
41
  });