@agentplate/cli 1.0.0 → 1.1.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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,32 @@ All notable changes to Agentplate are documented here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/), and the project aims to adhere to
5
5
  [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## [1.1.0] — 2026-06-02
8
+
9
+ ### Added
10
+
11
+ - **`agentplate spec` command** (`write` / `show` / `list` / `path`) — a
12
+ first-class, role-clean way to author the dispatch **contract** a lead or worker
13
+ launches with, written to `.agentplate/specs/<taskId>.md`.
14
+
15
+ ### Fixed
16
+
17
+ - **Coordinator→lead contract race.** A slung agent reads its inbox once at launch
18
+ and starts immediately, so a brief mailed *after* `sling` arrived too late and the
19
+ agent worked from inherited (wrong) branch content. Contracts are now delivered
20
+ **in-band at launch**: `sling --spec` validates the spec exists and is non-empty
21
+ (failing loudly otherwise) and **inlines** its content into the agent's first
22
+ prompt. Coordinator/lead guidance now requires authoring the spec before slinging
23
+ and forbids delivering a contract by mail afterward.
24
+
25
+ ### Changed
26
+
27
+ - Updated root dependencies to current majors (`@clack/prompts` 1.x, `commander`
28
+ 15, `typescript` 6, `biome` 2.4). Dependabot now batches only minor/patch updates,
29
+ so majors arrive as individual reviewable PRs.
30
+ - Repository hardening for public contributions: Code of Conduct, CODEOWNERS,
31
+ Dependabot config, and branch protection on `main`.
32
+
7
33
  ## [1.0.0] — 2026-06-01
8
34
 
9
35
  Initial public release of Agentplate as `@agentplate/cli`.
@@ -6,12 +6,16 @@ orchestrator for a run. You take the overall goal, break it into major slices,
6
6
  run to completion. You sit at the top of the hierarchy (depth 0): you spawn
7
7
  **leads**, and leads spawn the leaf workers.
8
8
 
9
- **You are a dispatcher, not an implementer.** Never edit, write, or create files
10
- yourself, and never run the build/tests to "just fix" something — every change is
11
- made by an agent you `agentplate sling`. Always **fan out**: decompose the goal
12
- into independent, parallel slices and dispatch a lead per slice; for anything
13
- beyond a single trivial change, dispatch **at least two leads** so work proceeds
14
- in parallel. If you find yourself about to touch a file, sling an agent instead.
9
+ **You are a dispatcher, not an implementer.** Never edit the codebase or run the
10
+ build/tests to "just fix" something — every change to the **work product** is made
11
+ by an agent you `agentplate sling`. The one artifact you *do* author is the **spec**
12
+ for each slice: a spec is a dispatch input (`.agentplate/specs/<taskId>.md`), not
13
+ the work product, so writing it with `agentplate spec write` is dispatching, not
14
+ implementing do it freely. Always **fan out**: decompose the goal into
15
+ independent, parallel slices and dispatch a lead per slice; for anything beyond a
16
+ single trivial change, dispatch **at least two leads** so work proceeds in
17
+ parallel. If you find yourself about to touch a file *in the codebase*, sling an
18
+ agent instead.
15
19
 
16
20
  The reusable HOW lives in this file. The per-run WHAT (the goal, the task set,
17
21
  your agent name) comes from your overlay instruction file (`CLAUDE.md`,
@@ -34,23 +38,42 @@ coordinator is the run's nerve center; do not go quiet while children work.
34
38
 
35
39
  ## Dispatching Leads
36
40
 
37
- You dispatch one lead per major slice with `agentplate sling`, naming yourself as
38
- the parent:
41
+ For each slice, **author the spec first, then sling against it.** The spec is the
42
+ contract — goal, the exact base branch/content to work from, scope/files,
43
+ constraints, acceptance criteria. It must exist *before* you sling, because
44
+ `--spec` is loaded into the lead's task **at launch**:
39
45
 
40
46
  ```bash
47
+ # 1. Write the contract (here from a heredoc on stdin; --body/--file also work).
48
+ agentplate spec write <taskId> --stdin <<'SPEC'
49
+ # <taskId>
50
+ Goal: …
51
+ Base branch / starting content: …
52
+ Scope (files this slice owns): …
53
+ Constraints: …
54
+ Acceptance criteria: …
55
+ SPEC
56
+
57
+ # 2. Dispatch the lead against it, naming yourself as the parent.
41
58
  agentplate sling <taskId> --capability lead --parent <self> \
42
59
  --spec .agentplate/specs/<taskId>.md
43
60
  ```
44
61
 
62
+ **Never deliver a lead's contract by mail after slinging.** A slung lead reads its
63
+ inbox once at launch and then starts working; a brief mailed a few seconds later
64
+ arrives too late, and the lead proceeds from inherited (wrong) branch content. The
65
+ contract goes in the **spec, at launch** — mail to a lead is only for *mid-run*
66
+ direction once it is already working. (`sling` refuses a missing or empty `--spec`,
67
+ so a contract can never be silently dropped.)
68
+
45
69
  Discipline when dispatching:
46
70
 
47
71
  - **One owner per slice.** Each lead owns a coherent, independent slice with its
48
72
  own area of the codebase, so leads' teams do not collide.
49
73
  - **Disjoint slices.** Carve the work so two leads are not editing the same files
50
74
  in parallel. Cross-slice integration is your concern, not theirs.
51
- - **Specs first.** Make sure each slice has a spec the lead can dispatch against
52
- (`agentplate spec write <taskId>` if you need to author one). Leads delegate
53
- against specs.
75
+ - **Specs first.** Every slice gets a spec authored with `agentplate spec write`
76
+ *before* its lead is slung; leads delegate against that spec. No spec, no sling.
54
77
  - **Respect depth.** You spawn leads only. Leads spawn the leaf workers
55
78
  (scout/builder/reviewer/merger). Do not spawn leaf workers directly except for
56
79
  a quick read-only scout when you need to scope the run yourself.
@@ -73,8 +96,9 @@ Discipline when dispatching:
73
96
  - **Up to the operator (or orchestrator):** `--type status` for run-level
74
97
  progress; `--type escalation` for decisions that need a human or a higher-level
75
98
  call; `--type result` for the final outcome of the run.
76
- - **Down to leads:** answer their questions and issue direction with
77
- `agentplate mail send --to <lead>`.
99
+ - **Down to leads:** answer their questions and issue *mid-run* direction with
100
+ `agentplate mail send --to <lead>`. Never use mail to deliver the initial
101
+ contract — that belongs in the spec the lead launched with.
78
102
 
79
103
  ## Completion Protocol
80
104
 
package/agents/lead.md CHANGED
@@ -33,6 +33,11 @@ agentplate sling <taskId> --capability builder --parent <self> \
33
33
  --files src/foo.ts,src/foo.test.ts --spec .agentplate/specs/<taskId>.md
34
34
  ```
35
35
 
36
+ Author each child's spec with `agentplate spec write` *before* you sling it — the
37
+ spec loads at launch, so it is the only race-free way to hand a child its contract.
38
+ Never mail a child its task after slinging (it has already read its inbox once and
39
+ started); mail is for mid-run direction only.
40
+
36
41
  Capabilities you may spawn: `scout`, `builder`, `reviewer`, `merger`.
37
42
 
38
43
  Discipline when delegating:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentplate/cli",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -50,15 +50,15 @@
50
50
  "prepack": "bun run build:ui"
51
51
  },
52
52
  "dependencies": {
53
- "@clack/prompts": "^0.11.0",
53
+ "@clack/prompts": "^1.5.0",
54
54
  "chalk": "^5.6.2",
55
- "commander": "^14.0.3",
55
+ "commander": "^15.0.0",
56
56
  "js-yaml": "^4.1.1"
57
57
  },
58
58
  "devDependencies": {
59
- "@biomejs/biome": "2.3.15",
59
+ "@biomejs/biome": "2.4.16",
60
60
  "@types/bun": "latest",
61
61
  "@types/js-yaml": "^4.0.9",
62
- "typescript": "^5.9.0"
62
+ "typescript": "^6.0.3"
63
63
  }
64
64
  }
@@ -62,7 +62,8 @@ export function buildCoordinatorSystemPrompt(ctx: CoordinatorPromptContext): str
62
62
  "Key commands:",
63
63
  "",
64
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\``,
65
+ "- Author a task's spec FIRST (the contract; loaded at launch — never mail it after): `agentplate spec write <task-id> --stdin`",
66
+ `- Dispatch a lead against it: \`agentplate sling <task-id> --capability lead --parent ${ctx.agentName} --spec .agentplate/specs/<task-id>.md\``,
66
67
  "- Fleet status: `agentplate status`",
67
68
  "- Merge completed work: `agentplate merge --all`",
68
69
  "",
@@ -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
+ });
@@ -123,6 +123,10 @@ export function createSlingCommand(): Command {
123
123
  const mail = createMailClient(root);
124
124
  const events = createEventStore(eventsDbPath(root));
125
125
  try {
126
+ // Validate + load the spec contract up front (fails loudly on a missing or
127
+ // empty --spec rather than launching the agent contract-less).
128
+ const specBody = readSpecContract(opts.spec, taskId);
129
+
126
130
  // Resolve the run this agent belongs to.
127
131
  const runId = resolveRun(store, root, opts);
128
132
 
@@ -191,7 +195,7 @@ export function createSlingCommand(): Command {
191
195
  from: opts.parent ?? "operator",
192
196
  to: name,
193
197
  subject: `Dispatch: ${taskId}`,
194
- body: dispatchBody(taskId, capability, overlayConfig),
198
+ body: dispatchBody(taskId, capability, overlayConfig, specBody),
195
199
  type: "dispatch",
196
200
  });
197
201
 
@@ -325,7 +329,35 @@ function resolveRun(
325
329
  return run.id;
326
330
  }
327
331
 
328
- function dispatchBody(taskId: string, capability: Capability, cfg: OverlayConfig): string {
332
+ /**
333
+ * Resolve the contract a `--spec` points to, or "" when no spec was given.
334
+ *
335
+ * `--spec` is the race-free channel for an agent's contract: its content is
336
+ * inlined into the dispatch and loaded at launch, unlike a brief mailed *after*
337
+ * `sling` (which lands only after the agent has read its inbox once and started).
338
+ * A missing or empty spec is therefore a hard error — launching an agent with a
339
+ * blank contract makes it fall back to inherited (wrong) branch content.
340
+ */
341
+ export function readSpecContract(specPath: string | undefined, taskId: string): string {
342
+ if (!specPath) return "";
343
+ if (!existsSync(specPath)) {
344
+ throw new ValidationError(
345
+ `--spec file not found: ${specPath}. Author it first with \`agentplate spec write ${taskId}\`.`,
346
+ );
347
+ }
348
+ const body = readFileSync(specPath, "utf8");
349
+ if (body.trim().length === 0) {
350
+ throw new ValidationError(`--spec file is empty: ${specPath}. The contract would be blank.`);
351
+ }
352
+ return body;
353
+ }
354
+
355
+ export function dispatchBody(
356
+ taskId: string,
357
+ capability: Capability,
358
+ cfg: OverlayConfig,
359
+ specBody: string,
360
+ ): string {
329
361
  const lines = [
330
362
  `You are ${cfg.agentName}, a ${capability} agent.`,
331
363
  `Task: ${taskId}`,
@@ -333,7 +365,13 @@ function dispatchBody(taskId: string, capability: Capability, cfg: OverlayConfig
333
365
  cfg.fileScope.length ? `File scope: ${cfg.fileScope.join(", ")}` : undefined,
334
366
  `Your full instructions are in ${cfg.worktreePath}.`,
335
367
  ];
336
- return lines.filter(Boolean).join("\n");
368
+ let body = lines.filter(Boolean).join("\n");
369
+ // Inline the spec contract so it is in the agent's first prompt — not merely a
370
+ // path it has to open. This is the in-band contract; do not rely on a later mail.
371
+ if (specBody.trim()) {
372
+ body += `\n\n=== SPEC (your contract — work from this, not inherited branch content) ===\n${specBody.trim()}\n=== END SPEC ===`;
373
+ }
374
+ return body;
337
375
  }
338
376
 
339
377
  function buildInitialPrompt(injected: string, instructionPath: string): string {
@@ -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
+ }
package/src/index.ts CHANGED
@@ -30,6 +30,7 @@ import { createSetupCommand } from "./commands/setup.ts";
30
30
  import { createShipCommand } from "./commands/ship.ts";
31
31
  import { createSkillCommand } from "./commands/skill.ts";
32
32
  import { createSlingCommand } from "./commands/sling.ts";
33
+ import { createSpecCommand } from "./commands/spec.ts";
33
34
  import { createStatusCommand } from "./commands/status.ts";
34
35
  import { createStopCommand } from "./commands/stop.ts";
35
36
  import { createTuiCommand } from "./commands/tui.ts";
@@ -95,6 +96,7 @@ function buildProgram(): Command {
95
96
  // Orchestration
96
97
  program.addCommand(createCoordinatorCommand());
97
98
  program.addCommand(createSlingCommand());
99
+ program.addCommand(createSpecCommand());
98
100
  program.addCommand(createStatusCommand());
99
101
  program.addCommand(createMailCommand());
100
102
  program.addCommand(createMergeCommand());