From a4ae43d45cb9e8e3cbfb01ebde03e94bf67fd3a2 Mon Sep 17 00:00:00 2001 From: satyaborg Date: Thu, 28 May 2026 12:33:04 +1000 Subject: [PATCH] feat: bundle spec generation skill --- README.md | 19 ++++ skills/spec/SKILL.md | 88 ++++++++++++++++ src/cli.ts | 31 ++++++ src/devloop.ts | 1 + src/spec.ts | 241 +++++++++++++++++++++++++++++++++++++++++++ templates/spec.md | 4 +- tests/spec.test.ts | 173 +++++++++++++++++++++++++++++++ 7 files changed, 554 insertions(+), 3 deletions(-) create mode 100644 skills/spec/SKILL.md create mode 100644 src/spec.ts create mode 100644 tests/spec.test.ts diff --git a/README.md b/README.md index fcfc15e..8e73b74 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,25 @@ devloop --tui .specs/change.md devloop --report-format markdown .specs/change.md 3 ``` +## Generate A Spec + +`devloop` bundles a reusable [`spec` skill](skills/spec/SKILL.md). The CLI can load that skill and ask a coding agent to turn notes, an issue, a file, or pasted context into a devloop spec: + +```sh +devloop spec "add retry behavior to the chat sender" +devloop spec --agent claude --output .specs/chat-retry.md notes.md +devloop spec --agent my-agent ./research.md +``` + +`--agent codex` is the default. `--agent claude` uses Claude's stdin mode. Any other `--agent` value is treated as a command that accepts the generated prompt on stdin and returns the markdown spec on stdout. + +For agent-native skill systems: + +```sh +devloop spec --skill-path +devloop spec --print-skill +``` + ## Write A Spec Start with [`templates/spec.md`](templates/spec.md). A good spec is short and concrete: diff --git a/skills/spec/SKILL.md b/skills/spec/SKILL.md new file mode 100644 index 0000000..301ad01 --- /dev/null +++ b/skills/spec/SKILL.md @@ -0,0 +1,88 @@ +--- +name: spec +description: Distill existing material into one devloop-compatible implementation spec. Use when the user provides notes, a file, a URL, research, or conversation context and wants a concrete spec that a coding agent can implement and another agent can review. +--- + +# Spec + +Distill the provided context into one implementation spec that conforms to the devloop standard. The user has already supplied the thinking in a document, notes, URL, issue, or conversation. Compress it faithfully, flag missing details, and do not invent behavior that is not present in the source material. + +## Get The Context + +Resolve the input before drafting: + +- File path: read it. +- URL: fetch it if your environment allows web access; otherwise keep the URL in Notes as a source to verify. +- Pasted text: use it verbatim. +- Empty request: use the current conversation if it clearly concerns one task; ask which task if the boundary is ambiguous. + +If a referenced path or URL cannot be loaded, stop and report that instead of guessing. + +## Standard + +Every section appears in this order. Frontmatter uses exactly these fields: + +```markdown +--- +status: draft +type: feat|fix|chore +created: YYYY-MM-DD +pr: null +--- + +# + +## Problem + + +## Outcome + + +## Scope +- In: +- Out: + +## Behavior +Happy path: +1. +2. + +Edge cases: +- : + +## Acceptance criteria +1. + +## Test plan +- Red: +- Green: +- Full: +- Coverage: <100% coverage command, or why unavailable> + +## Constraints +- Must: +- Avoid: +- Existing convention: + +## Notes + +``` + +- `created` is today's date. +- Infer `type`: `feat` for new capability, `fix` for broken behavior, and `chore` for maintenance, docs, tests, dependencies, or refactors. +- The H1 is a concise title, not a sentence. +- Use concrete paths, commands, APIs, and observable behavior when the context provides them. + +## Gaps + +Do not fill unsupported sections with plausible detail. If required information is missing, replace the placeholder with: + +```markdown +> **GAP:** +``` + +Keep the section present, remove any leftover placeholder, and continue drafting. End with the count and names of remaining gaps so the user can decide whether to fill them. + +## Output + +When a caller provides an output path, write the spec there. Otherwise, write only the markdown spec to stdout or save it under `.specs/YYYY-MM-DD-.md` in the target repository. Do not wrap the spec in a code fence unless the caller explicitly asks for a fenced snippet. diff --git a/src/cli.ts b/src/cli.ts index d32a124..65bb75f 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -8,9 +8,17 @@ import { type Event, type Sink, } from "./devloop.ts"; +import { + bundledSpecSkillPath, + generateSpec, + parseSpecArgs, + readBundledSpecSkill, +} from "./spec.ts"; import { createTuiSink } from "./tui.ts"; const argv = process.argv.slice(2); +if (argv[0] === "spec") await runSpecCommand(argv.slice(1)); + if (argv.length === 0 || argv.includes("-h") || argv.includes("--help")) { console.log(welcome()); process.exit(0); @@ -81,3 +89,26 @@ function displayPath( ) { return hasWorktreeInfo(result) ? resultPath(result, file) : file; } + +async function runSpecCommand(argv: string[]) { + const parsed = parseSpecArgs(argv); + if (typeof parsed === "string") { + const help = argv.includes("-h") || argv.includes("--help"); + console[help ? "log" : "error"](parsed); + process.exit(help ? 0 : 2); + } + + try { + if (parsed.type === "print-skill") console.log(await readBundledSpecSkill()); + else if (parsed.type === "skill-path") console.log(bundledSpecSkillPath()); + else { + const result = await generateSpec(parsed.options); + console.log(`spec: ${result.file}`); + console.log(`agent: ${result.agent}`); + } + process.exit(0); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(2); + } +} diff --git a/src/devloop.ts b/src/devloop.ts index 9ac3323..3e62ca1 100644 --- a/src/devloop.ts +++ b/src/devloop.ts @@ -100,6 +100,7 @@ Usage: devloop [options] [max=5] Common commands: + devloop spec "add retry behavior to the chat sender" devloop .specs/change.md devloop --tui .specs/change.md devloop --plain .specs/change.md diff --git a/src/spec.ts b/src/spec.ts new file mode 100644 index 0000000..9c373ea --- /dev/null +++ b/src/spec.ts @@ -0,0 +1,241 @@ +import { mkdir, readFile, stat, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +export type GenerateSpecOptions = { + agent: string; + context: string[]; + cwd: string; + force: boolean; + output?: string; + today?: string; +}; + +export type SpecCommand = + | { type: "generate"; options: GenerateSpecOptions } + | { type: "print-skill" } + | { type: "skill-path" }; + +export type AgentResult = { + code: number; + stdout: string; + stderr: string; + output: string; +}; + +export type AgentRunner = ( + cmd: string, + args: string[], + input: string, + cwd: string, +) => Promise; + +export type GeneratedSpec = { + agent: string; + command: string[]; + file: string; +}; + +export function parseSpecArgs( + argv: string[], + cwd = process.cwd(), +): SpecCommand | string { + let agent = "codex"; + let force = false; + let output = ""; + let action: "print-skill" | "skill-path" | "" = ""; + const context: string[] = []; + + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]!; + if (arg === "--agent") { + const value = argv[++i]; + if (!value) return `--agent requires a value\n${specUsage()}`; + agent = value; + } else if (arg === "--output" || arg === "-o") { + const value = argv[++i]; + if (!value) return `--output requires a value\n${specUsage()}`; + output = value; + } else if (arg === "--force") force = true; + else if (arg === "--print-skill") action = "print-skill"; + else if (arg === "--skill-path") action = "skill-path"; + else if (arg === "-h" || arg === "--help") return specUsage(); + else if (arg.startsWith("--")) return `unknown option: ${arg}\n${specUsage()}`; + else context.push(arg); + } + + if (action) return { type: action }; + if (context.length === 0) return specUsage(); + return { + type: "generate", + options: { + agent, + context, + cwd, + force, + output: output || undefined, + }, + }; +} + +export function specUsage() { + return [ + "usage: devloop spec [--agent codex|claude|] [--output spec.md] [--force] ", + " devloop spec --print-skill", + " devloop spec --skill-path", + ].join("\n"); +} + +export function bundledSpecSkillPath() { + return path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "..", + "skills", + "spec", + "SKILL.md", + ); +} + +export async function readBundledSpecSkill() { + return readFile(bundledSpecSkillPath(), "utf8"); +} + +export async function generateSpec( + options: GenerateSpecOptions, + runner: AgentRunner = runAgent, +): Promise { + const skill = await readBundledSpecSkill(); + const context = await resolveContext(options.context, options.cwd); + const today = options.today ?? new Date().toISOString().slice(0, 10); + const invocation = agentInvocation(options.agent, options.cwd); + const result = await runner( + invocation.cmd, + invocation.args, + specPrompt({ + context, + output: options.output, + skill, + today, + }), + options.cwd, + ); + if (result.code !== 0) + throw new Error(result.stderr.trim() || result.stdout.trim() || "spec agent failed"); + const markdown = extractGeneratedSpec(result.stdout || result.output); + const file = await generatedSpecPath({ + cwd: options.cwd, + force: options.force, + markdown, + output: options.output, + today, + }); + await mkdir(path.dirname(file), { recursive: true }); + if ((await stat(file).catch(() => false)) && !options.force) + throw new Error(`spec already exists: ${file}`); + await writeFile(file, markdown.endsWith("\n") ? markdown : `${markdown}\n`); + return { agent: options.agent, command: [invocation.cmd, ...invocation.args], file }; +} + +export function agentInvocation(agent: string, cwd: string) { + if (agent === "codex") return { cmd: "codex", args: ["exec", "-s", "read-only", "-C", cwd, "-"] }; + if (agent === "claude") return { cmd: "claude", args: ["-p", "--add-dir", cwd] }; + return { cmd: agent, args: [] }; +} + +export function specPrompt(input: { + context: string; + output?: string; + skill: string; + today: string; +}) { + return `Use this bundled devloop skill to generate one implementation spec. + +Current date: ${input.today} +${input.output ? `Output path: ${input.output}` : "Output path: choose a .specs/YYYY-MM-DD-.md path if you write a file; otherwise return markdown on stdout."} + +Return only the markdown spec. Do not wrap it in a code fence. + +Bundled skill: +${input.skill} + +Context: +${input.context}`; +} + +export function extractGeneratedSpec(output: string) { + const trimmed = output.trim(); + const fenced = trimmed.match(/^```(?:markdown|md)?\s*\n([\s\S]*?)\n```$/i); + const candidate = (fenced?.[1] ?? trimmed).trim(); + const start = candidate.indexOf("---"); + if (start < 0) throw new Error("agent output must include spec frontmatter"); + return candidate.slice(start).trim(); +} + +async function resolveContext(items: string[], cwd: string) { + const resolved = await Promise.all(items.map((item) => contextBlock(item, cwd))); + return resolved.join("\n\n---\n\n"); +} + +async function contextBlock(item: string, cwd: string) { + const file = path.resolve(cwd, item); + const info = await stat(file).catch(() => undefined); + if (info?.isFile()) return `Source file: ${file}\n\n${await readFile(file, "utf8")}`; + return `Context:\n${item}`; +} + +async function generatedSpecPath(input: { + cwd: string; + force: boolean; + markdown: string; + output?: string; + today: string; +}) { + if (input.output) return path.resolve(input.cwd, input.output); + const title = input.markdown.match(/^#\s+(.+)$/m)?.[1] ?? "spec"; + const slug = slugify(title) || "spec"; + const file = path.join(input.cwd, ".specs", `${input.today}-${slug}.md`); + return input.force ? file : nextAvailablePath(file); +} + +async function nextAvailablePath(file: string) { + const extension = path.extname(file); + const base = file.slice(0, -extension.length); + let index = 2; + let candidate = file; + while (await stat(candidate).catch(() => false)) { + candidate = `${base}-${index}${extension}`; + index++; + } + return candidate; +} + +function slugify(value: string) { + return value + .toLowerCase() + .replace(/'/g, "") + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +async function runAgent( + cmd: string, + args: string[], + input: string, + cwd: string, +): Promise { + const proc = Bun.spawn([cmd, ...args], { + cwd, + env: Bun.env, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + }); + proc.stdin.write(input); + proc.stdin.end(); + const [stdout, stderr, code] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + return { code, stdout, stderr, output: `${stdout}${stderr}` }; +} diff --git a/templates/spec.md b/templates/spec.md index 6ccdcc6..34d6251 100644 --- a/templates/spec.md +++ b/templates/spec.md @@ -1,8 +1,6 @@ --- status: draft -type: null -slug: null -breaking: null +type: feat|fix|chore created: YYYY-MM-DD pr: null --- diff --git a/tests/spec.test.ts b/tests/spec.test.ts new file mode 100644 index 0000000..3858663 --- /dev/null +++ b/tests/spec.test.ts @@ -0,0 +1,173 @@ +import { afterAll, describe, expect, test } from "bun:test"; +import { chmod, mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { + agentInvocation, + bundledSpecSkillPath, + extractGeneratedSpec, + generateSpec, + parseSpecArgs, + readBundledSpecSkill, + specPrompt, + specUsage, + type AgentRunner, + type GenerateSpecOptions, +} from "../src/spec.ts"; + +const root = await mkdtemp(path.join(tmpdir(), "devloop-spec-test.")); + +afterAll(async () => rm(root, { recursive: true, force: true })); + +describe("spec command parsing", () => { + test("parses generation options and utility actions", () => { + expect(parseSpecArgs(["--agent", "claude", "--output", ".specs/x.md", "--force", "notes"], "/repo")).toEqual({ + type: "generate", + options: { + agent: "claude", + context: ["notes"], + cwd: "/repo", + force: true, + output: ".specs/x.md", + }, + }); + expect(parseSpecArgs(["--print-skill"], "/repo")).toEqual({ type: "print-skill" }); + expect(parseSpecArgs(["--skill-path"], "/repo")).toEqual({ type: "skill-path" }); + expect(parseSpecArgs(["--agent"], "/repo")).toContain("--agent requires a value"); + expect(parseSpecArgs(["-o"], "/repo")).toContain("--output requires a value"); + expect(parseSpecArgs(["--wat"], "/repo")).toContain("unknown option"); + expect(parseSpecArgs(["--help"], "/repo")).toBe(specUsage()); + expect(parseSpecArgs([], "/repo")).toBe(specUsage()); + }); + + test("exposes the bundled skill", async () => { + expect(bundledSpecSkillPath()).toEndWith(path.join("skills", "spec", "SKILL.md")); + expect(await readBundledSpecSkill()).toContain("name: spec"); + }); +}); + +describe("spec prompt helpers", () => { + test("builds agent-specific invocations", () => { + expect(agentInvocation("codex", "/repo")).toEqual({ cmd: "codex", args: ["exec", "-s", "read-only", "-C", "/repo", "-"] }); + expect(agentInvocation("claude", "/repo")).toEqual({ cmd: "claude", args: ["-p", "--add-dir", "/repo"] }); + expect(agentInvocation("my-agent", "/repo")).toEqual({ cmd: "my-agent", args: [] }); + }); + + test("builds prompts and extracts markdown specs", () => { + expect(specPrompt({ context: "notes", output: ".specs/x.md", skill: "skill body", today: "2026-05-28" })).toContain("Output path: .specs/x.md"); + expect(specPrompt({ context: "notes", skill: "skill body", today: "2026-05-28" })).toContain("choose a .specs/YYYY-MM-DD-.md path"); + expect(extractGeneratedSpec("```markdown\n---\nstatus: draft\n---\n\n# Chat retries\n```")).toBe("---\nstatus: draft\n---\n\n# Chat retries"); + expect(extractGeneratedSpec("preface\n---\nstatus: draft\n---\n\n# Chat retries")).toBe("---\nstatus: draft\n---\n\n# Chat retries"); + expect(() => extractGeneratedSpec("no spec here")).toThrow("agent output must include spec frontmatter"); + }); +}); + +describe("spec generation", () => { + test("generates with codex, file context, and requested output", async () => { + const cwd = await fixture("codex-output"); + await writeFile(path.join(cwd, "notes.md"), "Retry failed chat sends.\n"); + const calls: Array<{ cmd: string; args: string[]; input: string; cwd: string }> = []; + const runner: AgentRunner = async (cmd, args, input, runCwd) => { + calls.push({ cmd, args, input, cwd: runCwd }); + return { code: 0, stdout: specMarkdown("Chat retries"), stderr: "", output: specMarkdown("Chat retries") }; + }; + + const result = await generateSpec(baseOptions(cwd, { context: ["notes.md", "Keep the existing CLI shape."], output: ".specs/chat-retry.md" }), runner); + + expect(result).toEqual({ + agent: "codex", + command: ["codex", "exec", "-s", "read-only", "-C", cwd, "-"], + file: path.join(cwd, ".specs/chat-retry.md"), + }); + expect(calls[0]).toMatchObject({ cmd: "codex", args: ["exec", "-s", "read-only", "-C", cwd, "-"], cwd }); + expect(calls[0]!.input).toContain("Source file:"); + expect(calls[0]!.input).toContain("Retry failed chat sends."); + expect(calls[0]!.input).toContain("Context:\nKeep the existing CLI shape."); + expect(calls[0]!.input).toContain("Current date: 2026-05-28"); + expect(await readFile(result.file, "utf8")).toBe(`${specMarkdown("Chat retries")}\n`); + }); + + test("generates dated paths and suffixes existing files", async () => { + const cwd = await fixture("dated-output"); + await mkdir(path.join(cwd, ".specs"), { recursive: true }); + await writeFile(path.join(cwd, ".specs", "2026-05-28-chat-retries.md"), "exists\n"); + const runner: AgentRunner = async () => ({ code: 0, stdout: specMarkdown("Chat retries"), stderr: "", output: specMarkdown("Chat retries") }); + + const result = await generateSpec(baseOptions(cwd, { agent: "claude", context: ["Add retries."] }), runner); + + expect(result.command).toEqual(["claude", "-p", "--add-dir", cwd]); + expect(result.file).toBe(path.join(cwd, ".specs", "2026-05-28-chat-retries-2.md")); + expect(await readFile(result.file, "utf8")).toContain("# Chat retries"); + }); + + test("refuses explicit overwrites and reports agent failures", async () => { + const cwd = await fixture("errors"); + const file = path.join(cwd, ".specs", "exists.md"); + await mkdir(path.dirname(file), { recursive: true }); + await writeFile(file, "exists\n"); + const ok: AgentRunner = async () => ({ code: 0, stdout: specMarkdown("Overwrite spec"), stderr: "", output: specMarkdown("Overwrite spec") }); + const fail: AgentRunner = async () => ({ code: 1, stdout: "", stderr: "agent failed\n", output: "agent failed\n" }); + + await expect(generateSpec(baseOptions(cwd, { output: ".specs/exists.md" }), ok)).rejects.toThrow("spec already exists"); + await expect(generateSpec(baseOptions(cwd), fail)).rejects.toThrow("agent failed"); + const forced = await generateSpec(baseOptions(cwd, { force: true, output: ".specs/exists.md" }), ok); + expect(await readFile(forced.file, "utf8")).toContain("# Overwrite spec"); + }); + + test("runs a custom agent command through stdin", async () => { + const cwd = await fixture("custom-agent"); + const agent = path.join(cwd, "agent.sh"); + await writeFile( + agent, + [ + "#!/usr/bin/env bash", + "set -euo pipefail", + "cat > prompt.log", + "printf '%s\\n' '---' 'status: draft' 'type: feat' 'created: 2026-05-28' 'pr: null' '---' '' '# Custom agent spec'", + "", + ].join("\n"), + ); + await chmod(agent, 0o755); + + const result = await generateSpec(baseOptions(cwd, { agent, context: ["Use a custom agent."] })); + + expect(result.command).toEqual([agent]); + expect(await exists(path.join(cwd, "prompt.log"))).toBe(true); + expect(result.file).toBe(path.join(cwd, ".specs", "2026-05-28-custom-agent-spec.md")); + expect(await readFile(result.file, "utf8")).toContain("# Custom agent spec"); + }); +}); + +async function fixture(name: string) { + const dir = path.join(root, name); + await mkdir(dir, { recursive: true }); + return dir; +} + +function baseOptions(cwd: string, overrides: Partial = {}): GenerateSpecOptions { + return { + agent: "codex", + context: ["Add a spec generator."], + cwd, + force: false, + today: "2026-05-28", + ...overrides, + }; +} + +function specMarkdown(title: string) { + return [ + "---", + "status: draft", + "type: feat", + "created: 2026-05-28", + "pr: null", + "---", + "", + `# ${title}`, + ].join("\n"); +} + +async function exists(file: string) { + return Boolean(await stat(file).catch(() => false)); +}