diff --git a/README.md b/README.md index 18a1dd2..dbb818b 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,26 @@ devloop --report-format markdown .specs/change.md 3 devloop --coder claude --reviewer codex .specs/change.md ``` +## Generate A Spec + +`devloop` bundles a reusable [`spec` skill](skills/spec/SKILL.md). The skill has two paths: it interviews from a cold start when the user only has a rough idea, or it distills notes, an issue, a file, URL, or pasted context into a devloop spec. + +```sh +devloop spec +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. With no context, the prompt tells the selected agent to use the bundled cold-start interview path before writing the final spec. + +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..60e9027 --- /dev/null +++ b/skills/spec/SKILL.md @@ -0,0 +1,123 @@ +--- +name: spec +description: Interview from a cold start or distill existing material into one devloop-compatible implementation spec. Use when the user wants a concrete spec for devloop, whether they provide notes, a file, a URL, research, conversation context, or only a rough idea. +--- + +# Spec + +Produce one implementation spec that conforms to the devloop standard. This skill has two modes: + +- Cold start: if the user has not provided enough source material, interview them one question at a time until the implementation target is clear. +- Distill: if the user supplied notes, a file, a URL, research, an issue, or conversation context, compress that material faithfully and flag any remaining gaps. + +The output is the spec that `devloop` will use as its implementation input. + +## Scope Guard + +Write exactly one spec sized for one worktree and one PR. Push back before drafting when the request mixes multiple logical changes, depends on an unresolved preparatory refactor, or would plausibly exceed about 300 meaningful changed lines. + +When the scope is too large, name the overflow, propose the smallest useful slice, and ask the user to confirm the slice before writing. + +## Cold Start Interview + +Use this mode when there is no document, URL, issue, or concrete context to distill. + +- Ask one question at a time. +- Ask why before what. +- Skip obvious questions the repository or prior answers already settle. +- Push vague answers toward observable behavior and acceptance criteria. +- Name contradictions and ask which statement is true. +- Stop only when the spec can be written without TODOs, TBDs, or invented requirements. + +Cover these points: + +1. The actual problem and when it hurts. +2. The desired observable outcome. +3. Happy path behavior. +4. Edge cases and failure modes. +5. Files, commands, APIs, UI surfaces, or workflows in scope. +6. Explicitly out-of-scope work. +7. Hard constraints, existing conventions, and test expectations. + +If the environment cannot ask interactive questions, write a draft with explicit `> **GAP:** ...` markers rather than inventing missing facts. + +## Distill Existing Material + +Resolve the input before drafting: + +- File path: read it. +- URL: fetch it if the environment allows web access; otherwise record the URL in Notes as a source to verify. +- Pasted text: use it verbatim. +- Current conversation: use only the part clearly about this task; ask for the boundary if the conversation covers unrelated work. + +Do not fill unsupported sections with plausible detail. Every line must trace to supplied context, repository evidence, or an explicit user answer. + +## 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. +- Acceptance criteria must be singular, verifiable, and observable. +- Include concrete paths, commands, APIs, and behaviors when the context provides them. + +## Gaps + +When required information is still missing, replace that section's placeholder with: + +```markdown +> **GAP:** +``` + +Keep every standard section present, remove leftover placeholders, and list the count and names of remaining gaps in Notes. + +## 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 20979ef..8c0aaa7 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); @@ -92,3 +100,26 @@ function displayPath( function resultLine(label: string, value: string) { return `${`${label}:`.padEnd(10)}${value}`; } + +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 d73631f..dab01e7 100644 --- a/src/devloop.ts +++ b/src/devloop.ts @@ -105,6 +105,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..309fba5 --- /dev/null +++ b/src/spec.ts @@ -0,0 +1,244 @@ +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 }; + 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] [context...]", + " devloop spec --print-skill", + " devloop spec --skill-path", + "", + "Without context, the bundled skill uses its interview path before writing a spec.", + ].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 produce 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."} + +If the source context is missing or too thin, follow the skill's interview path before drafting. Return only the final 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) { + if (items.length === 0) + return "No source material was provided. Use the cold-start interview path in the bundled skill to discover intent before writing the spec."; + 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..af13e23 --- /dev/null +++ b/tests/spec.test.ts @@ -0,0 +1,199 @@ +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")).toEqual({ + type: "generate", + options: { + agent: "codex", + context: [], + cwd: "/repo", + force: false, + output: undefined, + }, + }); + }); + + test("exposes the bundled skill", async () => { + expect(bundledSpecSkillPath()).toEndWith(path.join("skills", "spec", "SKILL.md")); + expect(await readBundledSpecSkill()).toContain("name: spec"); + expect(await readBundledSpecSkill()).toContain("Cold Start Interview"); + }); +}); + +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(specPrompt({ context: "No source material was provided.", skill: "skill body", today: "2026-05-28" })).toContain("interview 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 from the bundled cold-start interview path without context", async () => { + const cwd = await fixture("cold-start"); + const calls: Array<{ input: string }> = []; + const runner: AgentRunner = async (_cmd, _args, input) => { + calls.push({ input }); + return { code: 0, stdout: specMarkdown("Cold start spec"), stderr: "", output: specMarkdown("Cold start spec") }; + }; + + const result = await generateSpec(baseOptions(cwd, { context: [] }), runner); + + expect(calls[0]!.input).toContain("No source material was provided"); + expect(calls[0]!.input).toContain("Cold Start Interview"); + expect(result.file).toBe(path.join(cwd, ".specs", "2026-05-28-cold-start-spec.md")); + }); + + 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)); +}