@app-vitals/agent-runtime 0.16.0 → 0.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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.1",
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.1",
44
+ "@app-vitals/agent-lib": "0.16.1",
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");
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
@@ -59,10 +63,6 @@ const SHIPWRIGHT_MARKETPLACE = "shipwright";
59
63
  // Default plugin installed on all Shipwright agents.
60
64
  const DEFAULT_PLUGINS: readonly string[] = ["shipwright"];
61
65
 
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
66
  // The persona templates a type's templatesDir must provide or it is not usable.
67
67
  // Everything else (VOICE, mise, requirements, BOOTSTRAP) falls back per file to
68
68
  // the built-in workspace/ dir, and SPECIALTY.md.template is optional (ARD-6.2).
@@ -75,12 +75,17 @@ const REQUIRED_TEMPLATES: readonly string[] = [
75
75
  /**
76
76
  * Resolves the workspace template directory for a specialty (ARD-6.1).
77
77
  *
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.
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.
84
89
  */
85
90
  export function resolveTemplatesDir(
86
91
  requestedType: string | undefined,
@@ -89,19 +94,12 @@ export function resolveTemplatesDir(
89
94
  env: Record<string, string | undefined> = process.env,
90
95
  ): string {
91
96
  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
- );
97
+ const defaultSpecialty = resolveDefaultSpecialtyName(env);
98
+ const specialty = requestedType || defaultSpecialty;
99
+ const specialtiesDir = resolveSpecialtiesDir(env);
100
+
101
+ const lookup = (name: string): string => {
102
+ const manifestPath = join(specialtiesDir, name, "manifest.yaml");
105
103
  const manifest = Bun.YAML.parse(readFileSync(manifestPath, "utf8")) as {
106
104
  identity?: { templatesDir?: unknown };
107
105
  };
@@ -109,27 +107,49 @@ export function resolveTemplatesDir(
109
107
  if (typeof templatesDir !== "string" || templatesDir === "") {
110
108
  throw new Error("manifest has no identity.templatesDir");
111
109
  }
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);
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
+ const dir =
115
+ [
116
+ resolve(repoRoot, templatesDir),
117
+ resolve(dirname(specialtiesDir), templatesDir),
118
+ ].find((candidate) => existsSync(candidate)) ??
119
+ resolve(dirname(manifestPath), templatesDir);
119
120
  if (!statSync(dir).isDirectory()) {
120
121
  throw new Error(`${dir} is not a directory`);
121
122
  }
122
123
  const missing = REQUIRED_TEMPLATES.filter(
123
- (name) => !existsSync(join(dir, name)),
124
+ (file) => !existsSync(join(dir, file)),
124
125
  );
125
126
  if (missing.length > 0) {
126
127
  throw new Error(`${dir} is missing templates: ${missing.join(", ")}`);
127
128
  }
128
129
  return dir;
130
+ };
131
+
132
+ try {
133
+ return lookup(specialty);
129
134
  } catch (err) {
135
+ if (specialty !== defaultSpecialty) {
136
+ warn(
137
+ `[setup] could not resolve workspace templates for specialty "${specialty}" ` +
138
+ `(${(err as Error).message}) — falling back to the default type "${defaultSpecialty}"`,
139
+ );
140
+ try {
141
+ return lookup(defaultSpecialty);
142
+ } catch (defaultErr) {
143
+ warn(
144
+ `[setup] could not resolve workspace templates for default specialty "${defaultSpecialty}" ` +
145
+ `(${(defaultErr as Error).message}) — falling back to the built-in workspace templates`,
146
+ );
147
+ return WORKSPACE_TEMPLATE_DIR;
148
+ }
149
+ }
130
150
  warn(
131
151
  `[setup] could not resolve workspace templates for specialty "${specialty}" ` +
132
- `(${(err as Error).message}) — falling back to "${DEFAULT_SPECIALTY_NAME}" templates`,
152
+ `(${(err as Error).message}) — falling back to the built-in workspace templates`,
133
153
  );
134
154
  return WORKSPACE_TEMPLATE_DIR;
135
155
  }
@@ -281,9 +281,49 @@ describe("resolveTemplatesDir option resolution (AEP-1.2)", () => {
281
281
  expect(warnings).toEqual([]);
282
282
  });
283
283
 
284
- it("resolves an absent type to the built-in templates with no env set", async () => {
284
+ it("resolves an absent type as the shipped specialist type with no env and no warning", async () => {
285
285
  const { resolveTemplatesDir } = await import("./setup.ts");
286
- const dir = resolveTemplatesDir(undefined, "/nonexistent", () => {}, {});
286
+ const warnings: string[] = [];
287
+ const dir = resolveTemplatesDir(
288
+ undefined,
289
+ join(import.meta.dir, ".."),
290
+ (m) => warnings.push(m),
291
+ {},
292
+ );
293
+ expect(warnings).toEqual([]);
294
+ expect(dir).toBe(
295
+ join(import.meta.dir, "..", "agent-types", "specialist", "templates"),
296
+ );
297
+ });
298
+
299
+ it("warns and falls back to the default type's templates for an unknown requested type", async () => {
300
+ const { resolveTemplatesDir } = await import("./setup.ts");
301
+ const warnings: string[] = [];
302
+ const dir = resolveTemplatesDir(
303
+ "renamed-type",
304
+ join(import.meta.dir, ".."),
305
+ (m) => warnings.push(m),
306
+ {},
307
+ );
308
+ expect(warnings).toHaveLength(1);
309
+ expect(warnings[0]).toContain('"renamed-type"');
310
+ expect(warnings[0]).toContain('"specialist"');
311
+ expect(dir).toBe(
312
+ join(import.meta.dir, "..", "agent-types", "specialist", "templates"),
313
+ );
314
+ });
315
+
316
+ it("falls back to the built-in templates when the default type cannot load either", async () => {
317
+ const { resolveTemplatesDir } = await import("./setup.ts");
318
+ const warnings: string[] = [];
319
+ const dir = resolveTemplatesDir(
320
+ "renamed-type",
321
+ "/nonexistent",
322
+ (m) => warnings.push(m),
323
+ { SHIPWRIGHT_SPECIALTIES_DIR: "/nonexistent/types" },
324
+ );
325
+ expect(warnings).toHaveLength(2);
326
+ expect(warnings[1]).toContain("built-in");
287
327
  expect(dir).toMatch(/workspace$/);
288
328
  });
289
329
 
@@ -347,9 +387,12 @@ describe("SHIPWRIGHT_REPO_ROOT override (PCB-1.1)", () => {
347
387
  describe("resolveTemplatesDir", () => {
348
388
  const makeRoot = () => {
349
389
  const root = mkdtempSync(join(tmpdir(), "repo-root-"));
350
- const dir = join(root, "specialties", "foo");
390
+ const dir = join(root, "types", "foo");
351
391
  mkdirSync(join(dir, "tpl"), { recursive: true });
352
- writeFileSync(join(dir, "manifest.yaml"), "identity:\n templatesDir: tpl\n");
392
+ writeFileSync(
393
+ join(dir, "manifest.yaml"),
394
+ "identity:\n templatesDir: tpl\n",
395
+ );
353
396
  for (const f of [
354
397
  "CLAUDE.md.template",
355
398
  "SOUL.md.template",
@@ -360,7 +403,7 @@ describe("SHIPWRIGHT_REPO_ROOT override (PCB-1.1)", () => {
360
403
  return { root, tpl: join(dir, "tpl") };
361
404
  };
362
405
 
363
- it("looks up specialties under SHIPWRIGHT_REPO_ROOT when set", () => {
406
+ it("resolves templatesDir against SHIPWRIGHT_REPO_ROOT when set", () => {
364
407
  const { root, tpl } = makeRoot();
365
408
  try {
366
409
  const warnings: string[] = [];
@@ -368,7 +411,10 @@ describe("SHIPWRIGHT_REPO_ROOT override (PCB-1.1)", () => {
368
411
  "foo",
369
412
  undefined,
370
413
  (m) => warnings.push(m),
371
- { SHIPWRIGHT_REPO_ROOT: root },
414
+ {
415
+ SHIPWRIGHT_REPO_ROOT: root,
416
+ SHIPWRIGHT_SPECIALTIES_DIR: join(root, "types"),
417
+ },
372
418
  );
373
419
  expect(warnings).toEqual([]);
374
420
  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
  });