@agentplate/cli 1.0.0 → 1.2.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.
@@ -0,0 +1,84 @@
1
+ /**
2
+ * `agentplate sling` — unit tests for the spec-contract path.
3
+ *
4
+ * These cover the two pure pieces that fix the launch/mail race without spinning
5
+ * up a full spawn: `readSpecContract` (the --spec guard) and `dispatchBody` (which
6
+ * inlines the contract into the agent's first prompt). Real temp files, no mocks.
7
+ */
8
+
9
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
10
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import { join } from "node:path";
13
+ import { ValidationError } from "../errors.ts";
14
+ import type { OverlayConfig } from "../types.ts";
15
+ import { dispatchBody, readSpecContract } from "./sling.ts";
16
+
17
+ let dir: string;
18
+
19
+ beforeEach(() => {
20
+ dir = mkdtempSync(join(tmpdir(), "agentplate-sling-spec-"));
21
+ });
22
+
23
+ afterEach(() => {
24
+ rmSync(dir, { recursive: true, force: true });
25
+ });
26
+
27
+ describe("readSpecContract", () => {
28
+ test("returns empty string when no --spec is given (spec is optional)", () => {
29
+ expect(readSpecContract(undefined, "task-1")).toBe("");
30
+ });
31
+
32
+ test("returns the file content when the spec exists and is non-empty", () => {
33
+ const f = join(dir, "task-1.md");
34
+ writeFileSync(f, "Goal: build the thing\n", "utf8");
35
+ expect(readSpecContract(f, "task-1")).toBe("Goal: build the thing\n");
36
+ });
37
+
38
+ test("throws when the --spec file is missing (points at `spec write`)", () => {
39
+ const missing = join(dir, "absent.md");
40
+ expect(() => readSpecContract(missing, "task-1")).toThrow(ValidationError);
41
+ try {
42
+ readSpecContract(missing, "task-1");
43
+ } catch (e) {
44
+ expect((e as Error).message).toContain("agentplate spec write task-1");
45
+ }
46
+ });
47
+
48
+ test("throws when the --spec file is empty/whitespace (blank contract)", () => {
49
+ const f = join(dir, "blank.md");
50
+ writeFileSync(f, " \n\t\n", "utf8");
51
+ expect(() => readSpecContract(f, "task-1")).toThrow(ValidationError);
52
+ });
53
+ });
54
+
55
+ const baseCfg: OverlayConfig = {
56
+ agentName: "lead-task-1",
57
+ capability: "lead",
58
+ taskId: "task-1",
59
+ specPath: ".agentplate/specs/task-1.md",
60
+ branchName: "agentplate/lead-task-1",
61
+ worktreePath: "/tmp/wt",
62
+ parentAgent: "coordinator",
63
+ depth: 1,
64
+ fileScope: [],
65
+ baseDefinition: "",
66
+ canSpawn: true,
67
+ qualityGates: [],
68
+ constraints: [],
69
+ };
70
+
71
+ describe("dispatchBody", () => {
72
+ test("inlines the spec contract into the dispatch when present", () => {
73
+ const body = dispatchBody("task-1", "lead", baseCfg, "Goal: build the thing");
74
+ expect(body).toContain("=== SPEC");
75
+ expect(body).toContain("Goal: build the thing");
76
+ expect(body).toContain("Task: task-1");
77
+ });
78
+
79
+ test("omits the SPEC block when there is no spec body", () => {
80
+ const body = dispatchBody("task-1", "lead", baseCfg, "");
81
+ expect(body).not.toContain("=== SPEC");
82
+ expect(body).toContain("Task: task-1");
83
+ });
84
+ });
@@ -6,28 +6,27 @@
6
6
  * the agent identity + session row → dispatch the task over mail → run the first
7
7
  * headless turn → observe the agent's terminal mail to transition the session.
8
8
  *
9
- * Headless spawn-per-turn: this runs the FIRST turn. Subsequent turns are driven
10
- * by new mail (a later refinement); the basic core proves the single-turn loop.
9
+ * Headless spawn-per-turn: this runs the FIRST turn (a fresh runtime session).
10
+ * Follow-up turns are run by `agentplate turn <agent>`, which resumes the same
11
+ * session (warm start). Both share the {@link driveTurn} core.
11
12
  */
12
13
 
13
14
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
14
15
  import { Command } from "commander";
15
- import { createIdentity, updateIdentity } from "../agents/identity.ts";
16
+ import { assertCapacity } from "../agents/capacity.ts";
17
+ import { driveTurn } from "../agents/drive.ts";
18
+ import { createIdentity } from "../agents/identity.ts";
16
19
  import { buildDefaultManifest, getDefinition, loadManifest } from "../agents/manifest.ts";
17
20
  import { writeOverlay } from "../agents/overlay.ts";
18
- import { runTurn } from "../agents/turn-runner.ts";
19
21
  import { findProjectRoot, isInitialized, loadConfig } from "../config.ts";
20
22
  import { ValidationError } from "../errors.ts";
21
23
  import { createEventStore } from "../events/store.ts";
22
- import { runQualityGates } from "../insights/quality-gates.ts";
23
24
  import { jsonOutput } from "../json.ts";
24
25
  import { brand, muted, printHint, printInfo, printSuccess } from "../logging/color.ts";
25
26
  import { createMailClient } from "../mail/client.ts";
26
- import { createMailStore } from "../mail/store.ts";
27
27
  import {
28
28
  currentRunPath,
29
29
  eventsDbPath,
30
- mailDbPath,
31
30
  manifestFilePath,
32
31
  packageAgentDefPath,
33
32
  sessionsDbPath,
@@ -35,7 +34,7 @@ import {
35
34
  import { getRuntime } from "../runtimes/registry.ts";
36
35
  import { resolveModel } from "../runtimes/resolve.ts";
37
36
  import { createSessionStore } from "../sessions/store.ts";
38
- import { retrieveSkillsForSpawn, runSkillFeedbackAndDistill } from "../skills/lifecycle.ts";
37
+ import { retrieveSkillsForSpawn } from "../skills/lifecycle.ts";
39
38
  import type { AgentManifest, AgentSession, Capability, OverlayConfig } from "../types.ts";
40
39
  import { SUPPORTED_CAPABILITIES } from "../types.ts";
41
40
  import { createWorktree } from "../worktree/manager.ts";
@@ -123,9 +122,24 @@ export function createSlingCommand(): Command {
123
122
  const mail = createMailClient(root);
124
123
  const events = createEventStore(eventsDbPath(root));
125
124
  try {
125
+ // Validate + load the spec contract up front (fails loudly on a missing or
126
+ // empty --spec rather than launching the agent contract-less).
127
+ const specBody = readSpecContract(opts.spec, taskId);
128
+
126
129
  // Resolve the run this agent belongs to.
127
130
  const runId = resolveRun(store, root, opts);
128
131
 
132
+ // Enforce orchestration capacity BEFORE any worktree/session is created,
133
+ // so a runaway fan-out is refused cleanly instead of spawning unbounded.
134
+ const parentAgent = opts.parent ?? null;
135
+ assertCapacity({
136
+ depth: Number(opts.depth ?? "0"),
137
+ active: store.countActive(runId),
138
+ parentAgent,
139
+ parentActiveChildren: parentAgent ? store.countActiveByParent(parentAgent, runId) : 0,
140
+ limits: config.agents,
141
+ });
142
+
129
143
  const name = uniqueName(store, opts.name ?? `${capability}-${taskId}`);
130
144
  const branchName = `agentplate/${name}`;
131
145
 
@@ -191,94 +205,25 @@ export function createSlingCommand(): Command {
191
205
  from: opts.parent ?? "operator",
192
206
  to: name,
193
207
  subject: `Dispatch: ${taskId}`,
194
- body: dispatchBody(taskId, capability, overlayConfig),
208
+ body: dispatchBody(taskId, capability, overlayConfig, specBody),
195
209
  type: "dispatch",
196
210
  });
197
211
 
198
- // 5. Run the first headless turn.
199
- const resolved = resolveModel(config, root, def.model);
200
- store.updateSessionState(session.id, "working");
212
+ // 5. Run the first turn. Follow-up turns warm-start (resume) via
213
+ // `agentplate turn`; driveTurn owns state + skills + auto-merge.
214
+ const resolved = resolveModel(config, root, def.model, capability);
201
215
  const prompt = buildInitialPrompt(mail.checkInject(name), runtime.instructionPath);
202
- let sawError = false;
203
- const turn = await runTurn({
216
+ const { finalState, exitCode } = await driveTurn({
217
+ root,
218
+ config,
204
219
  runtime,
205
- worktreePath: worktree.path,
206
- model: resolved.model,
220
+ store,
221
+ events,
222
+ mail,
223
+ session,
224
+ model: resolved,
207
225
  prompt,
208
- env: resolved.env,
209
- onEvent: (event) => {
210
- if (event.error || event.type === "error") sawError = true;
211
- // Prefer the error message (so a failed agent's reason is visible in
212
- // the feed/logs), else the token/cost JSON the Costs page aggregates.
213
- const detail = event.error
214
- ? event.error
215
- : event.usage
216
- ? JSON.stringify({ tokens: event.usage.tokens, cost: event.usage.costUsd })
217
- : null;
218
- events.record({
219
- agentName: name,
220
- runId,
221
- type: event.type,
222
- tool: event.tool ?? null,
223
- detail,
224
- });
225
- // Bump last_activity on every streamed event so a long but active
226
- // turn keeps itself fresh and is never reaped as "idle".
227
- store.touch(session.id);
228
- },
229
226
  });
230
- if (turn.runtimeSessionId) store.setRuntimeSessionId(session.id, turn.runtimeSessionId);
231
-
232
- // A non-zero exit with no error event means the runtime failed via stderr
233
- // (e.g. Pi's "No API key found for anthropic"). Record that stderr so the
234
- // failure reason is visible in the feed/logs instead of a blank "failed".
235
- if (turn.exitCode !== 0 && !sawError) {
236
- const reason = turn.stderr.trim();
237
- if (reason) {
238
- events.record({
239
- agentName: name,
240
- runId,
241
- type: "error",
242
- tool: null,
243
- detail: reason.length > 1000 ? `${reason.slice(0, 1000)}…` : reason,
244
- });
245
- }
246
- }
247
-
248
- // 6. Observe terminal mail to transition the session.
249
- const finalState = resolveFinalState(root, name, capability, turn.exitCode);
250
- store.updateSessionState(session.id, finalState);
251
- store.touch(session.id);
252
- updateIdentity(root, name, {
253
- taskId,
254
- summary: `${capability} ran a turn for ${taskId} → ${finalState}`,
255
- });
256
-
257
- // 7. Self-improving loop: score quality gates, append outcomes to
258
- // applied skills, and distill a skill when the work passed. Only
259
- // runs for a completed task; best-effort (never fails the spawn).
260
- if (finalState === "completed" && config.skills.enabled) {
261
- try {
262
- const gateOutcome = await runQualityGates(
263
- config.project.qualityGates ?? [],
264
- worktree.path,
265
- );
266
- await runSkillFeedbackAndDistill({
267
- root,
268
- agentName: name,
269
- capability,
270
- taskId,
271
- worktreePath: worktree.path,
272
- baseRef: config.project.canonicalBranch,
273
- runtime,
274
- outcomeStatus: gateOutcome?.status ?? null,
275
- skills: config.skills,
276
- model: resolved.model,
277
- });
278
- } catch {
279
- // Skill loop is advisory; a failure here must not fail the spawn.
280
- }
281
- }
282
227
 
283
228
  if (useJson) {
284
229
  jsonOutput({
@@ -289,7 +234,7 @@ export function createSlingCommand(): Command {
289
234
  branchName,
290
235
  worktreePath: worktree.path,
291
236
  state: finalState,
292
- exitCode: turn.exitCode,
237
+ exitCode: exitCode,
293
238
  });
294
239
  return;
295
240
  }
@@ -297,8 +242,8 @@ export function createSlingCommand(): Command {
297
242
  printInfo(` task: ${taskId}`);
298
243
  printInfo(` branch: ${branchName}`);
299
244
  printInfo(` worktree:${muted(` ${worktree.path}`)}`);
300
- if (turn.exitCode !== 0) {
301
- printHint(` turn exited ${turn.exitCode}; see \`agentplate mail list --from ${name}\``);
245
+ if (exitCode !== 0) {
246
+ printHint(` turn exited ${exitCode}; see \`agentplate mail list --from ${name}\``);
302
247
  }
303
248
  } finally {
304
249
  events.close();
@@ -325,7 +270,35 @@ function resolveRun(
325
270
  return run.id;
326
271
  }
327
272
 
328
- function dispatchBody(taskId: string, capability: Capability, cfg: OverlayConfig): string {
273
+ /**
274
+ * Resolve the contract a `--spec` points to, or "" when no spec was given.
275
+ *
276
+ * `--spec` is the race-free channel for an agent's contract: its content is
277
+ * inlined into the dispatch and loaded at launch, unlike a brief mailed *after*
278
+ * `sling` (which lands only after the agent has read its inbox once and started).
279
+ * A missing or empty spec is therefore a hard error — launching an agent with a
280
+ * blank contract makes it fall back to inherited (wrong) branch content.
281
+ */
282
+ export function readSpecContract(specPath: string | undefined, taskId: string): string {
283
+ if (!specPath) return "";
284
+ if (!existsSync(specPath)) {
285
+ throw new ValidationError(
286
+ `--spec file not found: ${specPath}. Author it first with \`agentplate spec write ${taskId}\`.`,
287
+ );
288
+ }
289
+ const body = readFileSync(specPath, "utf8");
290
+ if (body.trim().length === 0) {
291
+ throw new ValidationError(`--spec file is empty: ${specPath}. The contract would be blank.`);
292
+ }
293
+ return body;
294
+ }
295
+
296
+ export function dispatchBody(
297
+ taskId: string,
298
+ capability: Capability,
299
+ cfg: OverlayConfig,
300
+ specBody: string,
301
+ ): string {
329
302
  const lines = [
330
303
  `You are ${cfg.agentName}, a ${capability} agent.`,
331
304
  `Task: ${taskId}`,
@@ -333,33 +306,16 @@ function dispatchBody(taskId: string, capability: Capability, cfg: OverlayConfig
333
306
  cfg.fileScope.length ? `File scope: ${cfg.fileScope.join(", ")}` : undefined,
334
307
  `Your full instructions are in ${cfg.worktreePath}.`,
335
308
  ];
336
- return lines.filter(Boolean).join("\n");
309
+ let body = lines.filter(Boolean).join("\n");
310
+ // Inline the spec contract so it is in the agent's first prompt — not merely a
311
+ // path it has to open. This is the in-band contract; do not rely on a later mail.
312
+ if (specBody.trim()) {
313
+ body += `\n\n=== SPEC (your contract — work from this, not inherited branch content) ===\n${specBody.trim()}\n=== END SPEC ===`;
314
+ }
315
+ return body;
337
316
  }
338
317
 
339
318
  function buildInitialPrompt(injected: string, instructionPath: string): string {
340
319
  const header = `Read your instructions at ${instructionPath}, then begin your task.`;
341
320
  return injected ? `${injected}\n\n${header}` : header;
342
321
  }
343
-
344
- /** Terminal mail types that mark a capability's work complete. */
345
- function terminalTypesFor(capability: Capability): string[] {
346
- return capability === "merger" ? ["merged", "merge_failed"] : ["worker_done"];
347
- }
348
-
349
- function resolveFinalState(
350
- root: string,
351
- name: string,
352
- capability: Capability,
353
- exitCode: number,
354
- ): AgentSession["state"] {
355
- const terminal = terminalTypesFor(capability);
356
- const store = createMailStore(mailDbPath(root));
357
- try {
358
- const sent = store.list({ from: name });
359
- if (sent.some((m) => terminal.includes(m.type))) return "completed";
360
- } finally {
361
- store.close();
362
- }
363
- if (exitCode === 0) return "idle";
364
- return "failed";
365
- }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * `agentplate spec` command tests.
3
+ *
4
+ * Real temp `.agentplate/` tree (no mocks): a real `config.yaml` so
5
+ * `isInitialized` passes, real filesystem reads/writes. The action functions
6
+ * resolve the project root via `findProjectRoot()`, which honors
7
+ * `setProjectRootOverride`, so each test points Agentplate at its own temp root
8
+ * and drives the exported `run*` functions directly. `resolveSpecBody` takes an
9
+ * injected stdin reader so the `--stdin` path needs no real pipe.
10
+ */
11
+
12
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
13
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
14
+ import { tmpdir } from "node:os";
15
+ import { join } from "node:path";
16
+ import {
17
+ AGENTPLATE_DIR,
18
+ CONFIG_FILE,
19
+ DEFAULT_CONFIG,
20
+ serializeConfig,
21
+ setProjectRootOverride,
22
+ } from "../config.ts";
23
+ import { NotFoundError, ValidationError } from "../errors.ts";
24
+ import { specPath } from "../paths.ts";
25
+ import { resolveSpecBody, runList, runShow, runWrite } from "./spec.ts";
26
+
27
+ let root: string;
28
+
29
+ function initRoot(): string {
30
+ const dir = mkdtempSync(join(tmpdir(), "agentplate-spec-cmd-"));
31
+ mkdirSync(join(dir, AGENTPLATE_DIR), { recursive: true });
32
+ writeFileSync(join(dir, AGENTPLATE_DIR, CONFIG_FILE), serializeConfig(DEFAULT_CONFIG), "utf8");
33
+ return dir;
34
+ }
35
+
36
+ /** Capture everything written to stdout while `fn` runs (awaits async `fn`). */
37
+ async function captureStdout(fn: () => void | Promise<void>): Promise<string> {
38
+ const original = process.stdout.write.bind(process.stdout);
39
+ let out = "";
40
+ process.stdout.write = (chunk: string | Uint8Array): boolean => {
41
+ out += typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8");
42
+ return true;
43
+ };
44
+ try {
45
+ await fn();
46
+ } finally {
47
+ process.stdout.write = original;
48
+ }
49
+ return out;
50
+ }
51
+
52
+ /** Run `fn`, capture its stdout, and parse the single JSON envelope it prints. */
53
+ async function captureJson(fn: () => void | Promise<void>): Promise<{ data: unknown }> {
54
+ return JSON.parse((await captureStdout(fn)).trim());
55
+ }
56
+
57
+ beforeEach(() => {
58
+ root = initRoot();
59
+ setProjectRootOverride(root);
60
+ });
61
+
62
+ afterEach(() => {
63
+ setProjectRootOverride(null);
64
+ rmSync(root, { recursive: true, force: true });
65
+ });
66
+
67
+ describe("resolveSpecBody", () => {
68
+ test("throws when no body source is given", async () => {
69
+ await expect(resolveSpecBody({})).rejects.toBeInstanceOf(ValidationError);
70
+ });
71
+
72
+ test("throws when more than one source is given", async () => {
73
+ await expect(resolveSpecBody({ body: "x", stdin: true })).rejects.toBeInstanceOf(
74
+ ValidationError,
75
+ );
76
+ });
77
+
78
+ test("returns the inline --body", async () => {
79
+ expect(await resolveSpecBody({ body: "Goal: ship it" })).toBe("Goal: ship it");
80
+ });
81
+
82
+ test("reads --stdin via the injected reader", async () => {
83
+ expect(await resolveSpecBody({ stdin: true }, async () => "from stdin")).toBe("from stdin");
84
+ });
85
+
86
+ test("reads --file from disk", async () => {
87
+ const f = join(root, "contract.md");
88
+ writeFileSync(f, "Goal: from file", "utf8");
89
+ expect(await resolveSpecBody({ file: f })).toBe("Goal: from file");
90
+ });
91
+
92
+ test("throws when --file does not exist", async () => {
93
+ await expect(resolveSpecBody({ file: join(root, "nope.md") })).rejects.toBeInstanceOf(
94
+ ValidationError,
95
+ );
96
+ });
97
+
98
+ test("refuses a blank body", async () => {
99
+ await expect(resolveSpecBody({ body: " \n " })).rejects.toBeInstanceOf(ValidationError);
100
+ });
101
+ });
102
+
103
+ describe("spec write", () => {
104
+ test("writes the spec to the canonical path with a trailing newline", async () => {
105
+ await runWrite("task-a", { body: "Goal: A" }, false);
106
+ const path = specPath(root, "task-a");
107
+ expect(existsSync(path)).toBe(true);
108
+ expect(readFileSync(path, "utf8")).toBe("Goal: A\n");
109
+ });
110
+
111
+ test("reports created then updated, and overwrites content", async () => {
112
+ const created = await captureJson(() => runWrite("task-b", { body: "v1" }, true));
113
+ expect((created.data as { action: string }).action).toBe("created");
114
+ const updated = await captureJson(() => runWrite("task-b", { body: "v2" }, true));
115
+ expect((updated.data as { action: string }).action).toBe("updated");
116
+ expect(readFileSync(specPath(root, "task-b"), "utf8")).toBe("v2\n");
117
+ });
118
+
119
+ test("refuses an empty body (never writes a blank contract)", async () => {
120
+ await expect(runWrite("task-c", { body: " " }, false)).rejects.toBeInstanceOf(ValidationError);
121
+ expect(existsSync(specPath(root, "task-c"))).toBe(false);
122
+ });
123
+ });
124
+
125
+ describe("spec show / list", () => {
126
+ test("show throws NotFoundError when the spec is absent", () => {
127
+ expect(() => runShow("ghost", false)).toThrow(NotFoundError);
128
+ });
129
+
130
+ test("show prints the stored body", async () => {
131
+ await runWrite("task-d", { body: "Goal: D" }, false);
132
+ expect(await captureStdout(() => runShow("task-d", false))).toContain("Goal: D");
133
+ });
134
+
135
+ test("list returns every task id that has a spec (json, sorted)", async () => {
136
+ await runWrite("beta", { body: "b" }, false);
137
+ await runWrite("alpha", { body: "a" }, false);
138
+ const out = await captureJson(() => runList(true));
139
+ const ids = (out.data as Array<{ taskId: string }>).map((r) => r.taskId);
140
+ expect(ids).toEqual(["alpha", "beta"]);
141
+ });
142
+ });
@@ -0,0 +1,192 @@
1
+ /**
2
+ * `agentplate spec` — author and read the task specs that drive dispatch.
3
+ *
4
+ * A spec is the **contract** a dispatcher hands a lead/worker: the per-task WHAT
5
+ * (goal, scope, constraints, acceptance criteria, the branch to work from, …).
6
+ * It lives as markdown at `.agentplate/specs/<taskId>.md` and is loaded into the
7
+ * agent's task **at launch** via `agentplate sling --spec`. This is the only
8
+ * race-free channel for a contract: mailing a brief after `sling` arrives after
9
+ * the agent has already read its inbox once and started.
10
+ *
11
+ * Authoring a spec is a **dispatch action**, not implementation — it writes a
12
+ * dispatch input under `.agentplate/specs/`, never the codebase. The coordinator
13
+ * (which must not touch the work product) uses this freely.
14
+ *
15
+ * write <taskId> — write/overwrite the spec (body from --stdin | --body | --file)
16
+ * show <taskId> — print a spec (NotFoundError if absent)
17
+ * list — list task ids that have a spec
18
+ * path <taskId> — print the resolved spec path (for scripting / --spec)
19
+ *
20
+ * `--json` is read via `command.optsWithGlobals().json === true`, matching the
21
+ * house pattern in `skill.ts` / `mail.ts`.
22
+ */
23
+
24
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
25
+ import { Command } from "commander";
26
+ import { findProjectRoot, isInitialized } from "../config.ts";
27
+ import { NotFoundError, ValidationError } from "../errors.ts";
28
+ import { jsonOutput } from "../json.ts";
29
+ import { brand, muted, printSuccess } from "../logging/color.ts";
30
+ import { specPath, specsDir } from "../paths.ts";
31
+
32
+ /** Resolve the project root, throwing if Agentplate is not initialized there. */
33
+ function requireInit(): string {
34
+ const root = findProjectRoot();
35
+ if (!isInitialized(root)) {
36
+ throw new ValidationError("Not initialized. Run `agentplate setup` first.");
37
+ }
38
+ return root;
39
+ }
40
+
41
+ /** Read the `--json` global flag off the action's trailing Command instance. */
42
+ function wantsJson(command: Command): boolean {
43
+ return command.optsWithGlobals().json === true;
44
+ }
45
+
46
+ interface WriteOptions {
47
+ stdin?: boolean;
48
+ body?: string;
49
+ file?: string;
50
+ json?: boolean;
51
+ }
52
+
53
+ /**
54
+ * Resolve the spec body from exactly one source. Exported for direct unit tests
55
+ * (so we don't need a real stdin to assert the precedence + validation rules).
56
+ */
57
+ export async function resolveSpecBody(
58
+ opts: Pick<WriteOptions, "stdin" | "body" | "file">,
59
+ readStdin: () => Promise<string> = () => Bun.stdin.text(),
60
+ ): Promise<string> {
61
+ const provided = [opts.stdin === true, opts.body != null, opts.file != null].filter(
62
+ Boolean,
63
+ ).length;
64
+ if (provided === 0) {
65
+ throw new ValidationError(
66
+ "spec write needs a body: pass one of --stdin, --body <text>, or --file <path>.",
67
+ );
68
+ }
69
+ if (provided > 1) {
70
+ throw new ValidationError("spec write takes exactly one of --stdin, --body, or --file.");
71
+ }
72
+
73
+ let body: string;
74
+ if (opts.stdin) body = await readStdin();
75
+ else if (opts.body != null) body = opts.body;
76
+ else {
77
+ const file = opts.file as string;
78
+ if (!existsSync(file)) throw new ValidationError(`Spec source file not found: ${file}`);
79
+ body = readFileSync(file, "utf8");
80
+ }
81
+
82
+ if (body.trim().length === 0) {
83
+ throw new ValidationError("Refusing to write an empty spec — the contract would be blank.");
84
+ }
85
+ return body;
86
+ }
87
+
88
+ export async function runWrite(
89
+ taskId: string,
90
+ opts: WriteOptions,
91
+ useJson: boolean,
92
+ ): Promise<void> {
93
+ const root = requireInit();
94
+ const body = await resolveSpecBody(opts);
95
+ const dir = specsDir(root);
96
+ mkdirSync(dir, { recursive: true });
97
+ const path = specPath(root, taskId);
98
+ const existed = existsSync(path);
99
+ const normalized = body.endsWith("\n") ? body : `${body}\n`;
100
+ writeFileSync(path, normalized, "utf8");
101
+
102
+ if (useJson) jsonOutput({ taskId, path, action: existed ? "updated" : "created" });
103
+ else printSuccess(`Spec ${existed ? "updated" : "created"}: ${muted(path)}`);
104
+ }
105
+
106
+ export function runShow(taskId: string, useJson: boolean): void {
107
+ const root = requireInit();
108
+ const path = specPath(root, taskId);
109
+ if (!existsSync(path)) {
110
+ throw new NotFoundError(`No spec for "${taskId}" (expected ${path}).`);
111
+ }
112
+ const body = readFileSync(path, "utf8");
113
+ if (useJson) jsonOutput({ taskId, path, body });
114
+ else process.stdout.write(body.endsWith("\n") ? body : `${body}\n`);
115
+ }
116
+
117
+ export function runList(useJson: boolean): void {
118
+ const root = requireInit();
119
+ const dir = specsDir(root);
120
+ const taskIds = existsSync(dir)
121
+ ? readdirSync(dir)
122
+ .filter((f) => f.endsWith(".md"))
123
+ .map((f) => f.slice(0, -3))
124
+ .sort()
125
+ : [];
126
+ if (useJson) {
127
+ jsonOutput(taskIds.map((taskId) => ({ taskId, path: specPath(root, taskId) })));
128
+ return;
129
+ }
130
+ if (taskIds.length === 0) {
131
+ process.stdout.write(`${muted("No specs yet.")}\n`);
132
+ return;
133
+ }
134
+ for (const taskId of taskIds) process.stdout.write(`${brand(taskId)}\n`);
135
+ }
136
+
137
+ export function runPath(taskId: string, useJson: boolean): void {
138
+ const root = requireInit();
139
+ const path = specPath(root, taskId);
140
+ if (useJson) jsonOutput({ taskId, path });
141
+ else process.stdout.write(`${path}\n`);
142
+ }
143
+
144
+ function writeCommand(): Command {
145
+ return new Command("write")
146
+ .description("Write (or overwrite) a task spec — the dispatch contract")
147
+ .argument("<task-id>", "task identifier")
148
+ .option("--stdin", "read the spec body from stdin")
149
+ .option("--body <text>", "spec body as an inline string")
150
+ .option("--file <path>", "read the spec body from a file")
151
+ .option("--json", "output JSON")
152
+ .action((taskId: string, opts: WriteOptions, command: Command) =>
153
+ runWrite(taskId, opts, wantsJson(command)),
154
+ );
155
+ }
156
+
157
+ function showCommand(): Command {
158
+ return new Command("show")
159
+ .description("Print a task spec")
160
+ .argument("<task-id>", "task identifier")
161
+ .option("--json", "output JSON")
162
+ .action((taskId: string, _opts: { json?: boolean }, command: Command) =>
163
+ runShow(taskId, wantsJson(command)),
164
+ );
165
+ }
166
+
167
+ function listCommand(): Command {
168
+ return new Command("list")
169
+ .description("List task ids that have a spec")
170
+ .option("--json", "output JSON")
171
+ .action((_opts: { json?: boolean }, command: Command) => runList(wantsJson(command)));
172
+ }
173
+
174
+ function pathCommand(): Command {
175
+ return new Command("path")
176
+ .description("Print the resolved spec path for a task id")
177
+ .argument("<task-id>", "task identifier")
178
+ .option("--json", "output JSON")
179
+ .action((taskId: string, _opts: { json?: boolean }, command: Command) =>
180
+ runPath(taskId, wantsJson(command)),
181
+ );
182
+ }
183
+
184
+ /** Build the `agentplate spec` command tree. */
185
+ export function createSpecCommand(): Command {
186
+ return new Command("spec")
187
+ .description("Author and read task specs (the dispatch contract)")
188
+ .addCommand(writeCommand())
189
+ .addCommand(showCommand())
190
+ .addCommand(listCommand())
191
+ .addCommand(pathCommand());
192
+ }