@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.
- package/CHANGELOG.md +50 -0
- package/agents/coordinator.md +43 -13
- package/agents/lead.md +8 -1
- package/package.json +5 -5
- package/src/agents/capacity.test.ts +55 -0
- package/src/agents/capacity.ts +50 -0
- package/src/agents/drive.test.ts +155 -0
- package/src/agents/drive.ts +200 -0
- package/src/agents/system-prompt.ts +2 -1
- package/src/commands/sling.test.ts +84 -0
- package/src/commands/sling.ts +73 -117
- package/src/commands/spec.test.ts +142 -0
- package/src/commands/spec.ts +192 -0
- package/src/commands/turn.test.ts +101 -0
- package/src/commands/turn.ts +113 -0
- package/src/config.test.ts +18 -0
- package/src/config.ts +6 -1
- package/src/errors.ts +11 -0
- package/src/index.ts +4 -0
- package/src/insights/quality-gates.test.ts +43 -0
- package/src/insights/quality-gates.ts +30 -31
- package/src/merge/auto.test.ts +157 -0
- package/src/merge/auto.ts +118 -0
- package/src/paths.ts +2 -1
- package/src/runtimes/resolve.test.ts +49 -0
- package/src/runtimes/resolve.ts +11 -7
- package/src/sessions/store.test.ts +13 -0
- package/src/sessions/store.ts +20 -0
- package/src/types.ts +16 -1
- package/src/version.ts +1 -1
- package/src/wizard/setup.test.ts +45 -0
- package/src/wizard/setup.ts +119 -6
- 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
|
@@ -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
|
@@ -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
|
|
10
|
-
*
|
|
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 {
|
|
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
|
|
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
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
203
|
-
|
|
216
|
+
const { finalState, exitCode } = await driveTurn({
|
|
217
|
+
root,
|
|
218
|
+
config,
|
|
204
219
|
runtime,
|
|
205
|
-
|
|
206
|
-
|
|
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:
|
|
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 (
|
|
301
|
-
printHint(` turn exited ${
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|