@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 +26 -0
- package/agents/coordinator.md +37 -13
- package/agents/lead.md +5 -0
- package/package.json +5 -5
- package/src/agents/system-prompt.ts +2 -1
- package/src/commands/sling.test.ts +84 -0
- package/src/commands/sling.ts +41 -3
- package/src/commands/spec.test.ts +142 -0
- package/src/commands/spec.ts +192 -0
- package/src/index.ts +2 -0
- package/src/paths.ts +2 -1
- package/src/version.ts +1 -1
- package/src/wizard/setup.ts +4 -4
- package/ui/dist/assets/index-DAq3_wei.css +1 -0
- package/ui/dist/assets/index-DjRGeS6V.js +4227 -0
- package/ui/dist/index.html +2 -2
- package/ui/dist/assets/index-C7rXIMER.css +0 -1
- package/ui/dist/assets/index-W4kbr4by.js +0 -4526
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`.
|
package/agents/coordinator.md
CHANGED
|
@@ -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
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
38
|
-
the
|
|
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.**
|
|
52
|
-
|
|
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.
|
|
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": "^
|
|
53
|
+
"@clack/prompts": "^1.5.0",
|
|
54
54
|
"chalk": "^5.6.2",
|
|
55
|
-
"commander": "^
|
|
55
|
+
"commander": "^15.0.0",
|
|
56
56
|
"js-yaml": "^4.1.1"
|
|
57
57
|
},
|
|
58
58
|
"devDependencies": {
|
|
59
|
-
"@biomejs/biome": "2.
|
|
59
|
+
"@biomejs/biome": "2.4.16",
|
|
60
60
|
"@types/bun": "latest",
|
|
61
61
|
"@types/js-yaml": "^4.0.9",
|
|
62
|
-
"typescript": "^
|
|
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
|
-
|
|
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
|
+
});
|
package/src/commands/sling.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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());
|