@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.
- package/default-specialty.shipped.integration.test.ts +61 -0
- package/package.json +3 -3
- package/setup.fixture.integration.test.ts +42 -0
- package/setup.integration.test.ts +49 -53
- package/setup.ts +71 -59
- package/setup.unit.test.ts +133 -187
- package/workspace/SPECIALTY.md.template +5 -36
- package/workspace/SPECIALTY.md.template.content.test.ts +21 -30
|
@@ -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.
|
|
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.
|
|
44
|
-
"@app-vitals/agent-lib": "0.16.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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("
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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 `<
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
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
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
|
113
|
-
// repo root
|
|
114
|
-
//
|
|
115
|
-
|
|
116
|
-
const dir =
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
(
|
|
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
|
|
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
|
|
383
|
-
*
|
|
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
|
-
*
|
|
390
|
-
*
|
|
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
|
-
|
|
447
|
-
"claude",
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
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
|
|
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
|
|
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
|
|
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;
|
package/setup.unit.test.ts
CHANGED
|
@@ -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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
38
|
-
|
|
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
|
-
|
|
61
|
+
plugins,
|
|
62
|
+
repoRoot,
|
|
42
63
|
"/tmp/nonexistent-manifest.json",
|
|
43
|
-
|
|
64
|
+
extra,
|
|
44
65
|
);
|
|
45
66
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
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("
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
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
|
|
149
|
-
const
|
|
150
|
-
|
|
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(
|
|
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
|
-
//
|
|
206
|
-
//
|
|
207
|
-
|
|
208
|
-
|
|
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(
|
|
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
|
|
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
|
|
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
|
-
|
|
336
|
-
|
|
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
|
-
|
|
343
|
-
expect(unset).
|
|
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, "
|
|
290
|
+
const dir = join(root, "types", "foo");
|
|
351
291
|
mkdirSync(join(dir, "tpl"), { recursive: true });
|
|
352
|
-
writeFileSync(
|
|
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("
|
|
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
|
-
{
|
|
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
|
-
###
|
|
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 —
|
|
2
|
+
* SPECIALTY.md.template content regression guard — PCD-2.2
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* the
|
|
7
|
-
*
|
|
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 —
|
|
23
|
-
it("
|
|
24
|
-
expect(readTemplate()).
|
|
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(
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
});
|