@agentplate/cli 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (139) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/LICENSE +21 -0
  3. package/README.md +206 -0
  4. package/agents/architect.md +108 -0
  5. package/agents/builder.md +97 -0
  6. package/agents/coordinator.md +113 -0
  7. package/agents/deployer.md +117 -0
  8. package/agents/devops.md +114 -0
  9. package/agents/lead.md +107 -0
  10. package/agents/merger.md +103 -0
  11. package/agents/reviewer.md +90 -0
  12. package/agents/scout.md +95 -0
  13. package/agents/verifier.md +106 -0
  14. package/package.json +64 -0
  15. package/src/agents/guard-rules.ts +55 -0
  16. package/src/agents/identity.test.ts +161 -0
  17. package/src/agents/identity.ts +229 -0
  18. package/src/agents/manifest.test.ts +260 -0
  19. package/src/agents/manifest.ts +286 -0
  20. package/src/agents/overlay.test.ts +190 -0
  21. package/src/agents/overlay.ts +212 -0
  22. package/src/agents/system-prompt.test.ts +53 -0
  23. package/src/agents/system-prompt.ts +95 -0
  24. package/src/agents/turn-runner.ts +79 -0
  25. package/src/commands/coordinator.test.ts +75 -0
  26. package/src/commands/coordinator.ts +259 -0
  27. package/src/commands/deploy.test.ts +504 -0
  28. package/src/commands/deploy.ts +874 -0
  29. package/src/commands/doctor.test.ts +106 -0
  30. package/src/commands/doctor.ts +208 -0
  31. package/src/commands/init.ts +71 -0
  32. package/src/commands/log.ts +51 -0
  33. package/src/commands/mail.ts +197 -0
  34. package/src/commands/merge.ts +127 -0
  35. package/src/commands/model.ts +58 -0
  36. package/src/commands/prime.ts +61 -0
  37. package/src/commands/reap.ts +87 -0
  38. package/src/commands/serve.ts +61 -0
  39. package/src/commands/setup.ts +48 -0
  40. package/src/commands/ship.test.ts +106 -0
  41. package/src/commands/ship.ts +202 -0
  42. package/src/commands/skill.test.ts +458 -0
  43. package/src/commands/skill.ts +730 -0
  44. package/src/commands/sling.ts +365 -0
  45. package/src/commands/status.ts +60 -0
  46. package/src/commands/stop.ts +56 -0
  47. package/src/commands/tui.ts +199 -0
  48. package/src/commands/worktree.ts +77 -0
  49. package/src/config.test.ts +92 -0
  50. package/src/config.ts +202 -0
  51. package/src/db/sqlite.test.ts +77 -0
  52. package/src/db/sqlite.ts +102 -0
  53. package/src/deploy/audit.test.ts +233 -0
  54. package/src/deploy/audit.ts +245 -0
  55. package/src/deploy/context.test.ts +243 -0
  56. package/src/deploy/context.ts +72 -0
  57. package/src/deploy/registry.test.ts +101 -0
  58. package/src/deploy/registry.ts +86 -0
  59. package/src/deploy/secrets.test.ts +129 -0
  60. package/src/deploy/secrets.ts +69 -0
  61. package/src/deploy/targets/docker-gha.test.ts +323 -0
  62. package/src/deploy/targets/docker-gha.ts +841 -0
  63. package/src/deploy/types.ts +153 -0
  64. package/src/errors.test.ts +42 -0
  65. package/src/errors.ts +69 -0
  66. package/src/events/store.test.ts +183 -0
  67. package/src/events/store.ts +201 -0
  68. package/src/index.ts +137 -0
  69. package/src/insights/quality-gates.ts +73 -0
  70. package/src/json.test.ts +28 -0
  71. package/src/json.ts +50 -0
  72. package/src/logging/color.ts +62 -0
  73. package/src/logging/logger.ts +60 -0
  74. package/src/logging/sanitizer.test.ts +36 -0
  75. package/src/logging/sanitizer.ts +57 -0
  76. package/src/mail/client.test.ts +192 -0
  77. package/src/mail/client.ts +188 -0
  78. package/src/mail/store.test.ts +279 -0
  79. package/src/mail/store.ts +311 -0
  80. package/src/merge/lock.test.ts +88 -0
  81. package/src/merge/lock.ts +84 -0
  82. package/src/merge/queue.test.ts +136 -0
  83. package/src/merge/queue.ts +177 -0
  84. package/src/merge/resolver.test.ts +219 -0
  85. package/src/merge/resolver.ts +274 -0
  86. package/src/paths.ts +36 -0
  87. package/src/providers/apply.test.ts +90 -0
  88. package/src/providers/apply.ts +66 -0
  89. package/src/providers/registry.test.ts +74 -0
  90. package/src/providers/registry.ts +254 -0
  91. package/src/runtimes/claude.ts +313 -0
  92. package/src/runtimes/codex.ts +280 -0
  93. package/src/runtimes/cursor.ts +247 -0
  94. package/src/runtimes/gemini.ts +173 -0
  95. package/src/runtimes/mock.ts +71 -0
  96. package/src/runtimes/opencode.ts +259 -0
  97. package/src/runtimes/registry.test.ts +924 -0
  98. package/src/runtimes/registry.ts +63 -0
  99. package/src/runtimes/resolve.ts +45 -0
  100. package/src/runtimes/types.ts +97 -0
  101. package/src/scaffold.ts +68 -0
  102. package/src/secrets.test.ts +51 -0
  103. package/src/secrets.ts +78 -0
  104. package/src/serve/api.ts +667 -0
  105. package/src/serve/server.test.ts +433 -0
  106. package/src/serve/server.ts +271 -0
  107. package/src/serve/system.ts +90 -0
  108. package/src/serve/weather.ts +140 -0
  109. package/src/sessions/reaper.test.ts +162 -0
  110. package/src/sessions/reaper.ts +149 -0
  111. package/src/sessions/store.test.ts +351 -0
  112. package/src/sessions/store.ts +350 -0
  113. package/src/skills/distiller.test.ts +498 -0
  114. package/src/skills/distiller.ts +426 -0
  115. package/src/skills/feedback.test.ts +300 -0
  116. package/src/skills/feedback.ts +168 -0
  117. package/src/skills/lifecycle.ts +169 -0
  118. package/src/skills/retrieval.test.ts +421 -0
  119. package/src/skills/retrieval.ts +365 -0
  120. package/src/skills/safety.test.ts +335 -0
  121. package/src/skills/safety.ts +216 -0
  122. package/src/skills/store.test.ts +425 -0
  123. package/src/skills/store.ts +684 -0
  124. package/src/skills/types.ts +107 -0
  125. package/src/types.ts +442 -0
  126. package/src/utils/detect.test.ts +35 -0
  127. package/src/utils/detect.ts +82 -0
  128. package/src/version.test.ts +19 -0
  129. package/src/version.ts +7 -0
  130. package/src/wizard/setup.ts +254 -0
  131. package/src/worktree/manager.test.ts +181 -0
  132. package/src/worktree/manager.ts +229 -0
  133. package/templates/overlay.md.tmpl +102 -0
  134. package/ui/dist/assets/index-C7rXIMER.css +1 -0
  135. package/ui/dist/assets/index-W4kbr4by.js +4526 -0
  136. package/ui/dist/favicon.svg +21 -0
  137. package/ui/dist/index.html +16 -0
  138. package/ui/dist/logo-clay.svg +21 -0
  139. package/ui/dist/logo.svg +18 -0
@@ -0,0 +1,190 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, readFileSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { ValidationError } from "../errors.ts";
6
+ import type { OverlayConfig } from "../types.ts";
7
+ import { generateOverlay, writeOverlay } from "./overlay.ts";
8
+
9
+ /**
10
+ * Build a fully-populated OverlayConfig, allowing per-test overrides. Defaults
11
+ * exercise the "rich" path (non-empty lists, siblings, spawner); individual
12
+ * tests narrow to the empty/leaf cases as needed.
13
+ */
14
+ function makeConfig(overrides: Partial<OverlayConfig> = {}): OverlayConfig {
15
+ return {
16
+ agentName: "builder-alpha",
17
+ capability: "builder",
18
+ taskId: "task-123",
19
+ specPath: ".agentplate/specs/task-123.md",
20
+ branchName: "agent/builder-alpha/task-123",
21
+ worktreePath: "/repo/.agentplate/worktrees/builder-alpha",
22
+ parentAgent: "lead-one",
23
+ depth: 1,
24
+ fileScope: ["src/foo.ts", "src/foo.test.ts"],
25
+ baseDefinition: "# Builder\nYou implement features. UNIQUE_BASE_MARKER.",
26
+ canSpawn: false,
27
+ qualityGates: [
28
+ { name: "test", command: "bun test", description: "all tests pass" },
29
+ { name: "lint", command: "biome check .", description: "lint clean" },
30
+ ],
31
+ constraints: ["Only touch your file scope", "Never push to main"],
32
+ siblings: ["builder-beta", "builder-gamma"],
33
+ ...overrides,
34
+ };
35
+ }
36
+
37
+ describe("generateOverlay", () => {
38
+ test("substitutes every placeholder (no unresolved tokens remain)", () => {
39
+ const out = generateOverlay(makeConfig());
40
+ // The single most important invariant: nothing left to substitute.
41
+ expect(out).not.toContain("{{");
42
+ expect(out).not.toContain("}}");
43
+ });
44
+
45
+ test("injects core assignment values", () => {
46
+ const out = generateOverlay(makeConfig());
47
+ expect(out).toContain("builder-alpha");
48
+ expect(out).toContain("task-123");
49
+ expect(out).toContain("builder"); // capability
50
+ expect(out).toContain("agent/builder-alpha/task-123"); // branch
51
+ expect(out).toContain("/repo/.agentplate/worktrees/builder-alpha"); // worktree
52
+ expect(out).toContain("lead-one"); // parent
53
+ expect(out).toContain(".agentplate/specs/task-123.md"); // spec
54
+ });
55
+
56
+ test("embeds the base definition verbatim", () => {
57
+ const out = generateOverlay(makeConfig());
58
+ expect(out).toContain("UNIQUE_BASE_MARKER");
59
+ });
60
+
61
+ test("renders file scope and constraints as bullet lists", () => {
62
+ const out = generateOverlay(makeConfig());
63
+ expect(out).toContain("- `src/foo.ts`");
64
+ expect(out).toContain("- `src/foo.test.ts`");
65
+ expect(out).toContain("- `Only touch your file scope`");
66
+ expect(out).toContain("- `Never push to main`");
67
+ });
68
+
69
+ test("renders quality gates as a numbered checklist with commands", () => {
70
+ const out = generateOverlay(makeConfig());
71
+ expect(out).toContain("1. **test:** `bun test` — all tests pass");
72
+ expect(out).toContain("2. **lint:** `biome check .` — lint clean");
73
+ });
74
+
75
+ test("empty list-shaped fields collapse to (none)", () => {
76
+ const out = generateOverlay(
77
+ makeConfig({ fileScope: [], constraints: [], qualityGates: [], siblings: [] }),
78
+ );
79
+ // All four empty sections render the sentinel; assert it appears for each.
80
+ // (At least four occurrences: file scope, constraints, gates, siblings.)
81
+ const occurrences = out.split("(none)").length - 1;
82
+ expect(occurrences).toBeGreaterThanOrEqual(4);
83
+ });
84
+
85
+ test("missing specPath renders (none)", () => {
86
+ const out = generateOverlay(makeConfig({ specPath: undefined }));
87
+ expect(out).toContain("**Spec:** (none)");
88
+ });
89
+
90
+ test("renders an Applicable Skills section heading", () => {
91
+ const out = generateOverlay(makeConfig());
92
+ expect(out).toContain("## Applicable Skills");
93
+ });
94
+
95
+ test("undefined skillsOverlay renders the (none yet) default", () => {
96
+ const out = generateOverlay(makeConfig({ skillsOverlay: undefined }));
97
+ expect(out).toContain("(none yet)");
98
+ });
99
+
100
+ test("provided skillsOverlay is rendered verbatim", () => {
101
+ const out = generateOverlay(makeConfig({ skillsOverlay: "SKILL_BLOCK_XYZ" }));
102
+ expect(out).toContain("SKILL_BLOCK_XYZ");
103
+ expect(out).not.toContain("(none yet)");
104
+ });
105
+
106
+ test("provided skillsOverlay appears inside the Applicable Skills section", () => {
107
+ // The retrieval renderer emits a self-contained block that already carries
108
+ // the heading; the template substitutes it verbatim.
109
+ const marker = "## Applicable Skills\n\nSKILL_BLOCK_INSIDE_SECTION";
110
+ const out = generateOverlay(makeConfig({ skillsOverlay: marker }));
111
+ const headingIdx = out.indexOf("## Applicable Skills");
112
+ const markerIdx = out.indexOf("SKILL_BLOCK_INSIDE_SECTION");
113
+ // The marker body must appear AFTER the section heading (and at all).
114
+ expect(headingIdx).toBeGreaterThanOrEqual(0);
115
+ expect(markerIdx).toBeGreaterThan(headingIdx);
116
+ });
117
+
118
+ test("siblings section names each sibling and warns about rebasing", () => {
119
+ const out = generateOverlay(makeConfig());
120
+ expect(out).toContain("- builder-beta");
121
+ expect(out).toContain("- builder-gamma");
122
+ expect(out.toLowerCase()).toContain("rebase");
123
+ });
124
+
125
+ test("leaf agent (canSpawn=false) states the prohibition", () => {
126
+ const out = generateOverlay(makeConfig({ canSpawn: false }));
127
+ expect(out).toContain("may NOT spawn");
128
+ expect(out).not.toContain("agentplate sling <task-id>");
129
+ });
130
+
131
+ test("spawner (canSpawn=true) shows a sling example with incremented depth", () => {
132
+ const out = generateOverlay(makeConfig({ canSpawn: true, depth: 1 }));
133
+ expect(out).toContain("agentplate sling <task-id>");
134
+ expect(out).toContain("--depth 2"); // depth + 1
135
+ expect(out).toContain("--parent builder-alpha");
136
+ });
137
+
138
+ test("null parentAgent falls back to coordinator", () => {
139
+ const out = generateOverlay(makeConfig({ parentAgent: null }));
140
+ expect(out).toContain("**Parent:** coordinator");
141
+ // And there is no literal "null" leaking into the parent line.
142
+ expect(out).not.toContain("**Parent:** null");
143
+ });
144
+
145
+ test("uses the agentplate mail CLI (not ap) for the communication protocol", () => {
146
+ const out = generateOverlay(makeConfig());
147
+ expect(out).toContain("agentplate mail check --agent builder-alpha");
148
+ expect(out).toContain("agentplate mail send");
149
+ });
150
+ });
151
+
152
+ describe("writeOverlay", () => {
153
+ let tempRoot: string;
154
+
155
+ beforeEach(() => {
156
+ // Real temp dir that contains the required worktree marker segment so the
157
+ // guard is satisfied for the happy path.
158
+ tempRoot = mkdtempSync(join(tmpdir(), "agentplate-overlay-"));
159
+ });
160
+
161
+ afterEach(() => {
162
+ rmSync(tempRoot, { recursive: true, force: true });
163
+ });
164
+
165
+ test("writes the rendered overlay under a real worktree path", () => {
166
+ const worktreePath = join(tempRoot, ".agentplate", "worktrees", "builder-alpha");
167
+ const instructionPath = join(".claude", "CLAUDE.md");
168
+ const config = makeConfig({ worktreePath });
169
+
170
+ const written = writeOverlay(config, instructionPath);
171
+
172
+ expect(written).toBe(join(worktreePath, instructionPath));
173
+ const onDisk = readFileSync(written, "utf8");
174
+ // Round-trips the generated content (creating parent dirs along the way).
175
+ expect(onDisk).toBe(generateOverlay(config));
176
+ expect(onDisk).toContain("builder-alpha");
177
+ expect(onDisk).not.toContain("{{");
178
+ });
179
+
180
+ test("throws ValidationError when the path is not a Agentplate worktree", () => {
181
+ // A plausible-but-wrong target: a real project root, no /.agentplate/worktrees/.
182
+ const config = makeConfig({ worktreePath: join(tempRoot, "some-project") });
183
+ expect(() => writeOverlay(config, "CLAUDE.md")).toThrow(ValidationError);
184
+ });
185
+
186
+ test("guard message explains why the write was refused", () => {
187
+ const config = makeConfig({ worktreePath: "/Users/me/Projects/real-app" });
188
+ expect(() => writeOverlay(config, "CLAUDE.md")).toThrow(/worktree/i);
189
+ });
190
+ });
@@ -0,0 +1,212 @@
1
+ /**
2
+ * Dynamic overlay generator.
3
+ *
4
+ * Every spawned agent gets two instruction layers: a reusable base `.md`
5
+ * definition (the HOW) and a per-task overlay (the WHAT). This module renders the
6
+ * overlay by substituting `{{PLACEHOLDER}}` tokens in
7
+ * `templates/overlay.md.tmpl` with values from an {@link OverlayConfig}, then
8
+ * writes the result into the agent's worktree at the runtime's instruction path.
9
+ *
10
+ * Design notes:
11
+ * - Rendering is pure and synchronous: read template, substitute, return string.
12
+ * This keeps `generateOverlay` trivial to unit-test and free of side effects.
13
+ * - List-shaped fields (file scope, constraints, quality gates, siblings) are
14
+ * rendered as readable markdown so the agent reads prose, not raw arrays. Empty
15
+ * lists collapse to a clear "(none)" sentinel rather than a blank section.
16
+ * - `writeOverlay` refuses any target that is not under `/.agentplate/worktrees/`.
17
+ * Writing an overlay to a real project root would clobber the operator's own
18
+ * instruction file (e.g. `.claude/CLAUDE.md`); the guard makes that impossible
19
+ * regardless of caller mistakes.
20
+ */
21
+
22
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
23
+ import { dirname, join } from "node:path";
24
+ import { ValidationError } from "../errors.ts";
25
+ import type { OverlayConfig, QualityGate } from "../types.ts";
26
+
27
+ /** Path segment that marks a directory as a Agentplate-managed worktree. */
28
+ const WORKTREE_MARKER = "/.agentplate/worktrees/";
29
+
30
+ /** Sentinel rendered for any list-shaped field that is empty. */
31
+ const NONE = "(none)";
32
+
33
+ /**
34
+ * Resolve the overlay template path relative to THIS module.
35
+ *
36
+ * `import.meta.dir` is `<repo>/src/agents`; the template lives at
37
+ * `<repo>/templates/overlay.md.tmpl`, i.e. two levels up. Resolving relative to
38
+ * the module (rather than `process.cwd()`) keeps rendering correct no matter
39
+ * where `agentplate` is invoked from.
40
+ */
41
+ function templatePath(): string {
42
+ return join(import.meta.dir, "..", "..", "templates", "overlay.md.tmpl");
43
+ }
44
+
45
+ /**
46
+ * Render a list of strings as a markdown bullet list, or the `(none)` sentinel
47
+ * when the list is empty. Each entry is wrapped in backticks because these are
48
+ * always machine-ish tokens (file paths / agent names).
49
+ */
50
+ function renderBullets(items: readonly string[]): string {
51
+ if (items.length === 0) return NONE;
52
+ return items.map((item) => `- \`${item}\``).join("\n");
53
+ }
54
+
55
+ /**
56
+ * Render sibling agent names as a bullet list with rebase guidance, or `(none)`.
57
+ *
58
+ * Parallel siblings branch off the same pre-merge base, so whoever merges second
59
+ * carries a stale base unless they rebase first. We surface that here so the
60
+ * agent rebases BEFORE signalling it is ready to merge.
61
+ */
62
+ function renderSiblings(siblings: readonly string[] | undefined): string {
63
+ if (!siblings || siblings.length === 0) return NONE;
64
+ const bullets = siblings.map((name) => `- ${name}`).join("\n");
65
+ return [
66
+ "The following sibling agents are working in parallel and may touch nearby code:",
67
+ "",
68
+ bullets,
69
+ "",
70
+ "Rebase your branch onto the latest canonical branch and re-run the quality",
71
+ "gates BEFORE you send your terminal mail — a sibling's work may have landed",
72
+ "while you were busy, and merging from a stale base can revert it.",
73
+ ].join("\n");
74
+ }
75
+
76
+ /**
77
+ * Render the quality gates as a numbered checklist, or `(none)` when no gates are
78
+ * configured. Each gate shows its name, the exact command to run, and (when
79
+ * present) its description.
80
+ */
81
+ function renderQualityGates(gates: readonly QualityGate[]): string {
82
+ if (gates.length === 0) return NONE;
83
+ return gates
84
+ .map((gate, i) => {
85
+ const suffix = gate.description ? ` — ${gate.description}` : "";
86
+ return `${i + 1}. **${gate.name}:** \`${gate.command}\`${suffix}`;
87
+ })
88
+ .join("\n");
89
+ }
90
+
91
+ /**
92
+ * Render the can-spawn section. Spawners get a concrete `agentplate sling` example
93
+ * (with the depth pre-incremented); leaf agents get an explicit prohibition so
94
+ * there is no ambiguity.
95
+ */
96
+ function renderCanSpawn(config: OverlayConfig): string {
97
+ if (!config.canSpawn) {
98
+ return "You may NOT spawn sub-workers. You are a leaf agent.";
99
+ }
100
+ return [
101
+ "You may spawn sub-workers with `agentplate sling`. Example:",
102
+ "",
103
+ "```bash",
104
+ "agentplate sling <task-id> --capability builder --name <worker-name> \\",
105
+ ` --parent ${config.agentName} --depth ${config.depth + 1}`,
106
+ "```",
107
+ ].join("\n");
108
+ }
109
+
110
+ /**
111
+ * Render the spec line shown in the assignment header: the path when one was
112
+ * provided, otherwise an explicit `(none)` so the agent knows to ask for detail.
113
+ */
114
+ function renderSpecPath(specPath: string | undefined): string {
115
+ return specPath && specPath.length > 0 ? specPath : NONE;
116
+ }
117
+
118
+ /**
119
+ * Generate a per-task overlay by substituting every `{{PLACEHOLDER}}` token in
120
+ * the template.
121
+ *
122
+ * @param config - The overlay inputs (the WHAT) for this agent/task.
123
+ * @returns The fully rendered overlay markdown.
124
+ */
125
+ export function generateOverlay(config: OverlayConfig): string {
126
+ const template = readTemplate();
127
+
128
+ // Map of placeholder -> replacement. Every `{{TOKEN}}` in the template MUST
129
+ // have an entry here; the loop below replaces all occurrences of each.
130
+ const replacements: Record<string, string> = {
131
+ AGENT_NAME: config.agentName,
132
+ CAPABILITY: config.capability,
133
+ TASK_ID: config.taskId,
134
+ BRANCH_NAME: config.branchName,
135
+ WORKTREE_PATH: config.worktreePath,
136
+ PARENT_AGENT: config.parentAgent ?? "coordinator",
137
+ DEPTH: String(config.depth),
138
+ FILE_SCOPE: renderBullets(config.fileScope),
139
+ SPEC_PATH: renderSpecPath(config.specPath),
140
+ CAN_SPAWN: renderCanSpawn(config),
141
+ QUALITY_GATES: renderQualityGates(config.qualityGates),
142
+ CONSTRAINTS: renderBullets(config.constraints),
143
+ SIBLINGS: renderSiblings(config.siblings),
144
+ // The skills block is self-contained (it carries its own "## Applicable
145
+ // Skills" heading). When no skills were retrieved, render that heading with
146
+ // a friendly placeholder so the section is always present and consistent.
147
+ SKILLS: config.skillsOverlay ?? "## Applicable Skills\n\n(none yet)",
148
+ BASE_DEFINITION: config.baseDefinition,
149
+ };
150
+
151
+ let result = template;
152
+ for (const [token, value] of Object.entries(replacements)) {
153
+ // Replace ALL occurrences — some tokens (e.g. AGENT_NAME, WORKTREE_PATH)
154
+ // appear more than once. A global string split/join avoids regex-escaping
155
+ // the value (replacement text may contain `$` from commands/paths).
156
+ result = result.split(`{{${token}}}`).join(value);
157
+ }
158
+
159
+ return result;
160
+ }
161
+
162
+ /**
163
+ * Generate the overlay and write it into the agent's worktree at
164
+ * `<worktreePath>/<instructionPath>`, creating parent directories as needed.
165
+ *
166
+ * Guard: refuses any `worktreePath` that is not a Agentplate worktree (does not
167
+ * contain `/.agentplate/worktrees/`). This prevents the overlay from ever
168
+ * overwriting an instruction file at a real project root.
169
+ *
170
+ * @param config - The overlay inputs for this agent/task.
171
+ * @param instructionPath - Relative path within the worktree to write to
172
+ * (e.g. the runtime's `.claude/CLAUDE.md`).
173
+ * @returns The absolute path of the file that was written.
174
+ * @throws {ValidationError} If `worktreePath` is not under `/.agentplate/worktrees/`.
175
+ */
176
+ export function writeOverlay(config: OverlayConfig, instructionPath: string): string {
177
+ // Normalize separators so the marker check works on any platform's paths.
178
+ const normalizedWorktree = config.worktreePath.replaceAll("\\", "/");
179
+ if (!normalizedWorktree.includes(WORKTREE_MARKER)) {
180
+ throw new ValidationError(
181
+ `Refusing to write overlay outside a Agentplate worktree: "${config.worktreePath}" ` +
182
+ `does not contain "${WORKTREE_MARKER}". Overlays must target an agent worktree, ` +
183
+ "never a real project root (it would overwrite the operator's instruction file).",
184
+ );
185
+ }
186
+
187
+ const content = generateOverlay(config);
188
+ const outputPath = join(config.worktreePath, instructionPath);
189
+ // writeFileSync does not create intermediate directories, so make them first.
190
+ // Synchronous I/O keeps writeOverlay's contract simple (returns the path, no
191
+ // Promise) and matches the rest of Agentplate's file handling (config, secrets).
192
+ mkdirSync(dirname(outputPath), { recursive: true });
193
+ writeFileSync(outputPath, content);
194
+ return outputPath;
195
+ }
196
+
197
+ /**
198
+ * Read the overlay template from disk synchronously.
199
+ *
200
+ * Separated from {@link generateOverlay} so the (rare) missing-template failure
201
+ * surfaces as a typed {@link ValidationError} with the resolved path, which is
202
+ * far easier to diagnose than a raw ENOENT. A synchronous `readFileSync` keeps
203
+ * the whole generator synchronous; the template is a small committed asset that
204
+ * always exists in a real install.
205
+ */
206
+ function readTemplate(): string {
207
+ const path = templatePath();
208
+ if (!existsSync(path)) {
209
+ throw new ValidationError(`Overlay template not found: ${path}`);
210
+ }
211
+ return readFileSync(path, "utf8");
212
+ }
@@ -0,0 +1,53 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { buildCoordinatorSystemPrompt, writeCoordinatorSystemPrompt } from "./system-prompt.ts";
6
+
7
+ const ctx = {
8
+ projectName: "demo",
9
+ runId: "run-abc",
10
+ agentName: "coordinator",
11
+ canonicalBranch: "main",
12
+ instructionPath: "GEMINI.md",
13
+ };
14
+
15
+ describe("buildCoordinatorSystemPrompt", () => {
16
+ test("includes the run context + key CLI verbs", () => {
17
+ const text = buildCoordinatorSystemPrompt(ctx);
18
+ expect(text).toContain("demo");
19
+ expect(text).toContain("run-abc");
20
+ expect(text).toContain("agentplate mail check --agent coordinator");
21
+ expect(text).toContain("agentplate sling");
22
+ // Appends the bundled coordinator base definition.
23
+ expect(text.toLowerCase()).toContain("coordinator");
24
+ });
25
+
26
+ test("is provider-agnostic: references the runtime's overlay file, not CLAUDE.md", () => {
27
+ expect(buildCoordinatorSystemPrompt(ctx)).toContain("GEMINI.md");
28
+ expect(buildCoordinatorSystemPrompt({ ...ctx, instructionPath: "AGENTS.md" })).toContain(
29
+ "AGENTS.md",
30
+ );
31
+ });
32
+
33
+ test("mandates dispatch-only fan-out (never edit; multiple agents)", () => {
34
+ const text = buildCoordinatorSystemPrompt(ctx).toLowerCase();
35
+ expect(text).toContain("never edit");
36
+ expect(text).toContain("at least two leads");
37
+ expect(text).toContain("dispatcher, not an implementer");
38
+ });
39
+ });
40
+
41
+ describe("writeCoordinatorSystemPrompt", () => {
42
+ test("writes the prompt under the agent state dir", () => {
43
+ const root = mkdtempSync(join(tmpdir(), "agentplate-sp-"));
44
+ try {
45
+ const { path, text } = writeCoordinatorSystemPrompt(root, ctx);
46
+ expect(existsSync(path)).toBe(true);
47
+ expect(readFileSync(path, "utf8")).toBe(text);
48
+ expect(path).toContain(join(".agentplate", "agents", "coordinator"));
49
+ } finally {
50
+ rmSync(root, { recursive: true, force: true });
51
+ }
52
+ });
53
+ });
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Assemble a persistent agent's interactive system prompt.
3
+ *
4
+ * For attended sessions (e.g. `coordinator start`) we don't write a worktree
5
+ * overlay — the agent runs at the project root and is primed via the runtime's
6
+ * `--append-system-prompt`. This combines the reusable base definition
7
+ * (`agents/<capability>.md`) with a short run-context header so the live agent
8
+ * knows the project, its run id, and the exact CLI verbs to use.
9
+ */
10
+
11
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
12
+ import { dirname } from "node:path";
13
+ import { agentStateDir, packageAgentDefPath } from "../paths.ts";
14
+ import type { Capability } from "../types.ts";
15
+
16
+ export interface CoordinatorPromptContext {
17
+ projectName: string;
18
+ runId: string;
19
+ agentName: string;
20
+ canonicalBranch: string;
21
+ /**
22
+ * The runtime's overlay instruction file (e.g. `.claude/CLAUDE.md`,
23
+ * `AGENTS.md`, `GEMINI.md`) so the prompt is provider-agnostic instead of
24
+ * hardcoding a Claude path.
25
+ */
26
+ instructionPath: string;
27
+ }
28
+
29
+ /** Read a bundled base agent definition (empty string if missing). */
30
+ function readBaseDefinition(capability: Capability): string {
31
+ const path = packageAgentDefPath(`${capability}.md`);
32
+ return existsSync(path) ? readFileSync(path, "utf8") : "";
33
+ }
34
+
35
+ /** Build the coordinator's interactive system prompt text. */
36
+ export function buildCoordinatorSystemPrompt(ctx: CoordinatorPromptContext): string {
37
+ const header = [
38
+ "# Run context",
39
+ "",
40
+ `You are **${ctx.agentName}**, the COORDINATOR for the Agentplate project ` +
41
+ `**${ctx.projectName}**.`,
42
+ `Active run: \`${ctx.runId}\` · canonical branch: \`${ctx.canonicalBranch}\`.`,
43
+ "",
44
+ "You run as an interactive session: the operator chats with you here. Your ONLY",
45
+ "job is to HIRE and COORDINATE a team of agents. You are a dispatcher, not an",
46
+ "implementer.",
47
+ "",
48
+ "## Hard rules (always apply, every provider)",
49
+ "",
50
+ "1. **Never edit, write, or create files yourself, and never run the build/tests",
51
+ " to 'just fix it'.** You have no implementation role — every change is made by",
52
+ " an agent you dispatch. If you catch yourself about to edit a file, sling an",
53
+ " agent instead.",
54
+ "2. **Always fan out into multiple agents.** Decompose the goal into INDEPENDENT,",
55
+ " parallel slices and dispatch one lead per slice. For anything beyond a single",
56
+ " trivial change, dispatch **at least TWO leads** so work proceeds in parallel.",
57
+ "3. **Spawn ONLY with `agentplate sling`** (run it through your shell/Bash tool).",
58
+ " Do NOT use any built-in sub-agent / Task / Workflow tool — agents created that",
59
+ " way bypass Agentplate's session store, mail bus, and merge queue, so they",
60
+ " never appear in `ap serve`/`ap tui` and their work is not tracked or merged.",
61
+ "",
62
+ "Key commands:",
63
+ "",
64
+ `- Check mail: \`agentplate mail check --agent ${ctx.agentName}\``,
65
+ `- Dispatch a lead: \`agentplate sling <task-id> --capability lead --parent ${ctx.agentName} --spec .agentplate/specs/<task-id>.md\``,
66
+ "- Fleet status: `agentplate status`",
67
+ "- Merge completed work: `agentplate merge --all`",
68
+ "",
69
+ `Your per-run goal and constraints live in your overlay instruction file ` +
70
+ `(\`${ctx.instructionPath}\`) and the task tracker — read them first.`,
71
+ "",
72
+ "Begin by greeting the operator and asking what to build, then DECOMPOSE the goal",
73
+ "into parallel slices and dispatch a lead for each. The reusable role definition",
74
+ "follows.",
75
+ "",
76
+ "---",
77
+ "",
78
+ ].join("\n");
79
+ return `${header}${readBaseDefinition("coordinator")}`;
80
+ }
81
+
82
+ /**
83
+ * Write the assembled system prompt to the agent's state dir and return its path.
84
+ * (Persisted so it can be inspected; the runtime receives the text, not the path.)
85
+ */
86
+ export function writeCoordinatorSystemPrompt(
87
+ root: string,
88
+ ctx: CoordinatorPromptContext,
89
+ ): { path: string; text: string } {
90
+ const text = buildCoordinatorSystemPrompt(ctx);
91
+ const path = `${agentStateDir(root, ctx.agentName)}/system-prompt.md`;
92
+ mkdirSync(dirname(path), { recursive: true });
93
+ writeFileSync(path, text, "utf8");
94
+ return { path, text };
95
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Spawn-per-turn engine.
3
+ *
4
+ * Runs ONE headless turn of an agent: build the runtime's argv, spawn it in the
5
+ * worktree, drain its output (parsing events when the runtime supports it, so we
6
+ * can capture the resume session id and feed the event store), and return when
7
+ * the process exits. The orchestration layer decides when to run the next turn
8
+ * (driven by mail) — this module owns a single turn only.
9
+ */
10
+
11
+ import type { AgentEvent, AgentRuntime } from "../runtimes/types.ts";
12
+ import { resolveArgv } from "../utils/detect.ts";
13
+
14
+ export interface RunTurnOptions {
15
+ runtime: AgentRuntime;
16
+ /** Worktree directory the turn runs in. */
17
+ worktreePath: string;
18
+ /** Concrete model id. */
19
+ model: string;
20
+ /** The user-turn text (dispatch/mail/nudge). */
21
+ prompt: string;
22
+ /** Provider env vars (merged over process.env for the child). */
23
+ env?: Record<string, string>;
24
+ /** Prior runtime session id, to resume across turns. */
25
+ resumeSessionId?: string;
26
+ /** Called for each parsed event (e.g. to record tool calls). */
27
+ onEvent?: (event: AgentEvent) => void;
28
+ }
29
+
30
+ export interface TurnResult {
31
+ exitCode: number;
32
+ /** Runtime session id captured from the event stream (for --resume). */
33
+ runtimeSessionId: string | null;
34
+ /** Captured stderr (already bounded by the child). */
35
+ stderr: string;
36
+ }
37
+
38
+ /** Run a single headless turn and resolve when the child process exits. */
39
+ export async function runTurn(opts: RunTurnOptions): Promise<TurnResult> {
40
+ const argv = opts.runtime.buildDirectSpawn({
41
+ cwd: opts.worktreePath,
42
+ model: opts.model,
43
+ instructionPath: opts.runtime.instructionPath,
44
+ resumeSessionId: opts.resumeSessionId,
45
+ prompt: opts.prompt,
46
+ env: opts.env,
47
+ });
48
+
49
+ // Let the runtime contribute env beyond the resolved provider key — e.g.
50
+ // OpenCode injects OPENCODE_PERMISSION so an unattended worker auto-approves
51
+ // tool actions instead of deadlocking on a permission prompt. For runtimes that
52
+ // add nothing this equals `opts.env`, so the behavior is unchanged.
53
+ const runtimeEnv = opts.runtime.buildEnv({ model: opts.model, env: opts.env });
54
+ const proc = Bun.spawn(resolveArgv(argv), {
55
+ cwd: opts.worktreePath,
56
+ env: { ...process.env, ...runtimeEnv },
57
+ stdout: "pipe",
58
+ stderr: "pipe",
59
+ stdin: "ignore",
60
+ });
61
+
62
+ // Read stderr concurrently so a full pipe buffer can't deadlock the child.
63
+ const stderrPromise = new Response(proc.stderr).text();
64
+
65
+ let runtimeSessionId: string | null = null;
66
+ if (opts.runtime.parseEvents) {
67
+ for await (const event of opts.runtime.parseEvents(proc.stdout)) {
68
+ if (event.sessionId) runtimeSessionId = event.sessionId;
69
+ opts.onEvent?.(event);
70
+ }
71
+ } else {
72
+ // No event parser (e.g. the mock runtime): just drain stdout.
73
+ await new Response(proc.stdout).text();
74
+ }
75
+
76
+ const stderr = await stderrPromise;
77
+ const exitCode = await proc.exited;
78
+ return { exitCode, runtimeSessionId, stderr };
79
+ }