diff --git a/CHANGELOG.md b/CHANGELOG.md index a80ec16..a7c692c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,9 @@ Each released version is tagged in git (`v0.0.1`, `v0.1.0`, etc.) and includes t ### Added -- **`draftwise clarify []` — surface gaps in an existing product spec before drafting the technical spec.** Reads `product-spec.md` and prints an instruction telling the host coding agent to audit the spec across four categories (ambiguities, untested assumptions, internal contradictions, missing edge cases), present each issue with a specific line/section pointer, walk the PM through them one at a time (defer / reject / answer), and rewrite the file in place — preserving the YAML frontmatter and any sections the PM didn't touch. Hard rule: don't widen scope; if the PM's answer expands the feature, push back — that's a new spec, not a clarification. Same shape as `tech` (auto-picks when one product spec exists; positional slug picks one when many; errors with the available list when ambiguous). Why: drafted specs are uneven — some sections are tight, others have hand-wavy acceptance criteria or untested assumptions buried in scope. Catching those *before* the technical spec gets drafted means the engineering plan isn't built on a shaky foundation, and PMs don't have to re-litigate the same ambiguity in three files. Companion to `draftwise refine` (queued in the same release): `clarify` is the upfront quality check, `refine` rewrites after PM edits. Prompt at `src/ai/prompts/clarify.js`; command at `src/commands/clarify.js`; routed via `COMMANDS` in `src/index.js`. — Ankur +- **`draftwise refine [] [--type=product|tech|tasks]` — re-ground an existing spec while preserving PM hand-edits.** Resolves CLAUDE.md's long-standing "AI-assisted spec merge mode" open question. Different shape from `clarify`: clarify finds gaps and walks the PM through them; refine takes the existing spec as ground truth for what the PM wants and improves how it's written and grounded. Reads the chosen file (`product-spec.md` by default, `technical-spec.md` for `--type=tech`, `tasks.md` for `--type=tasks`), prints it plus the upstream source-of-truth (scanner output for product/tech brownfield, `overview.md` for greenfield, plus the upstream spec for tech/tasks), then prints a three-phase instruction telling the host coding agent to (1) audit each section as strong-leave-alone or weak-rewrite — bar is "actively misleads or under-specifies," not "could be tighter"; (2) re-ground the weak sections against the source-of-truth, removing or marking `(unverified)` any code reference the scanner doesn't surface; (3) save the refined file back, preserving strong sections character-for-character and any YAML frontmatter at the top of `product-spec.md` verbatim. Hard rules in the prompt: no fabricated code references, no scope creep (refining ≠ adding new features / edge cases), don't touch sections that are already strong. Same auto-pick / multi-spec / unknown-slug ergonomics as `tech`. Filters specs by which file the requested type requires (product-spec for `product`, technical-spec for `tech`, tasks for `tasks`); tech/tasks additionally validate that the upstream spec exists. Why: re-running `new` / `tech` / `tasks` on an existing spec only offers Overwrite or Cancel — both blunt. PMs review and edit specs, then want a "tighten this up" pass that doesn't blow away their edits. Refine is that pass. Prompt at `src/ai/prompts/refine.js` (per-type metadata table for file name, source-of-truth labels, and section-preservation rules); command at `src/commands/refine.js`; routed via `COMMANDS` in `src/index.js`. — Ankur + +- **`draftwise clarify []` — surface gaps in an existing product spec before drafting the technical spec.** Reads `product-spec.md` and prints an instruction telling the host coding agent to audit the spec across four categories (ambiguities, untested assumptions, internal contradictions, missing edge cases), present each issue with a specific line/section pointer, walk the PM through them one at a time (defer / reject / answer), and rewrite the file in place — preserving the YAML frontmatter and any sections the PM didn't touch. Hard rule: don't widen scope; if the PM's answer expands the feature, push back — that's a new spec, not a clarification. Same shape as `tech` (auto-picks when one product spec exists; positional slug picks one when many; errors with the available list when ambiguous). Why: drafted specs are uneven — some sections are tight, others have hand-wavy acceptance criteria or untested assumptions buried in scope. Catching those *before* the technical spec gets drafted means the engineering plan isn't built on a shaky foundation, and PMs don't have to re-litigate the same ambiguity in three files. Companion to `draftwise refine` (also in this release): `clarify` is the upfront quality check, `refine` rewrites after PM edits. Prompt at `src/ai/prompts/clarify.js`; command at `src/commands/clarify.js`; routed via `COMMANDS` in `src/index.js`. — Ankur - **`product-spec.md` opens with optional YAML frontmatter declaring `depends_on:` and `related:` slugs; `draftwise list` surfaces them.** `draftwise new` now instructs the host coding agent to list `.draftwise/specs/` first, then write a frontmatter block at the top of `product-spec.md` — `depends_on` for specs that must ship before this one, `related` for same-area specs that aren't a hard dependency. Both keys take a list of existing slugs (the agent is told never to invent slugs that aren't on disk; if there are no other specs yet the agent skips the block entirely). Frontmatter is parsed by a new `src/utils/frontmatter.js` (small wrapper over the `yaml` package — handles CRLF, malformed YAML, and non-object roots by falling back to empty data so a broken spec file doesn't break the whole list). `draftwise list` grew a fourth column — DEPENDS ON — between STATUS and TITLE; a spec with no frontmatter or no `depends_on` shows blank. Why: cross-spec dependencies were a deferred-for-later item in CLAUDE.md, but the cheap version (declarative, in the spec, surfaced in `list`) is enough to answer the actual question PMs ask ("which specs are ready to start?") without needing graph traversal, drift detection, or any of the heavier machinery. Stays in a frontmatter block so it doesn't bleed into the rendered spec body. — Ankur diff --git a/CLAUDE.md b/CLAUDE.md index d4f4134..5a1d07b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -108,6 +108,8 @@ scan: **The constitution is the user's voice + spec-quality dial.** `draftwise init` writes `.draftwise/constitution.md` with five stable sections — Voice, Spec language, Edge case discipline, Project conventions, Domain glossary. Every drafting prompt (`new`, `tech`, `tasks`, `scan`, `explain`, greenfield Phase 3) tells the host agent to read this file before drafting and apply the relevant sections. `scan` and the greenfield Phase 3 also refine the **Project conventions** section from observed code / chosen stack, replacing the placeholder. The file is user-editable and version-controlled — changes are reviewable and travel with the repo. Backward compat: prompts say "skip silently if absent" so projects that ran `init` before this feature landed keep working. Reader at `src/utils/constitution.js` returns null on ENOENT. Replaces the role `principles.js` + `spec-quality.js` used to play before the api-mode drop — those modules injected rules into every system prompt; the constitution is the new home for those rules now that synthesis lives in the host agent. +**Refine, don't clobber.** Re-running `new` / `tech` / `tasks` would overwrite a hand-edited spec; `draftwise refine [] [--type=product|tech|tasks]` is the alternative. Reads the existing file, treats it as ground truth for what the PM wants, prints it plus its source-of-truth (scanner / overview, plus the upstream spec for tech/tasks), and tells the host agent to audit each section as strong-leave-alone or weak-rewrite. The bar for "weak" is "actively misleads or under-specifies," not "could be tighter" — so the agent doesn't churn good prose. Hard rules in the prompt: no fabricated code references, no scope creep (refine ≠ add new features / edge cases), preserve YAML frontmatter on `product-spec.md` verbatim. Same auto-pick / multi-spec / unknown-slug ergonomics as `tech`. Different shape from `clarify`: clarify finds gaps and walks the PM through them; refine takes the existing spec as ground truth and re-grounds the parts that need it. Per-type metadata table (file name, source-of-truth label, section-preservation rule) lives at the top of `src/ai/prompts/refine.js`. + **Cross-spec dependencies live in product-spec frontmatter.** The agent writes an optional YAML block at the top of `product-spec.md` — `depends_on: [...]` for specs that must ship before this one, `related: [...]` for same-area specs that aren't a hard dependency. `draftwise new`'s instruction tells the agent to list `.draftwise/specs/` first and only reference slugs that exist on disk. `draftwise list`'s DEPENDS ON column surfaces the `depends_on` array. This is the cheap version of cross-spec tracking — declarative, in the spec, no graph traversal or drift detection. The richer machinery (drift detection, full graph view, ripple analysis) stays deferred; the frontmatter is enough to answer "which specs are ready to start?" without dragging the rest in. **Single repo, single feature spec at a time.** No cross-spec dependency tracking. No multi-repo. Keep scope tight. @@ -125,6 +127,7 @@ draftwise new "" → conversational drafting → product- draftwise clarify [] → audit a product spec for ambiguities + missing edge cases; rewrite in place draftwise tech [] → technical-spec.md from approved product spec (host agent writes) draftwise tasks [] → ordered tasks.md from technical spec (host agent writes) +draftwise refine [] [--type=product|tech|tasks] → re-ground a spec while preserving PM hand-edits draftwise list → list all specs in .draftwise/specs/ draftwise show [type] → display a spec (type: product | tech | tasks; default: product) draftwise skills install [--provider=...] [--scope=...] [--force] → install standalone skill across harnesses (Claude Code / Cursor / Gemini CLI; bare /draftwise ) @@ -179,9 +182,11 @@ Every command is implemented end-to-end and exercised by a vitest suite. The ori 7. **`tasks []`** ✅ — reads `technical-spec.md`, prints it plus scanner output (brownfield) or the project plan (greenfield) plus an instruction for the host agent to write ordered `tasks.md` (Goal / Files / Depends on / Parallel with / Acceptance). Greenfield front-loads 1-3 scaffolding tasks. (`src/commands/tasks.js`, prompt in `src/ai/prompts/tasks.js`) -8. **`list` and `show [type]`** ✅ — file-system utilities, no AI. (`src/commands/list.js`, `src/commands/show.js`) +8. **`refine [] [--type=product|tech|tasks]`** ✅ — reads the chosen spec file plus its source-of-truth (scanner / overview, plus the upstream spec for tech/tasks) and prints a three-phase instruction telling the host agent to audit each section as strong/weak, re-ground only the weak ones, and write the file back — preserving the strong sections character-for-character and any YAML frontmatter at the top. Different shape from `clarify`: clarify walks the PM through gaps; refine takes the existing spec as ground truth and re-grounds the parts that need it. Same auto-pick / multi-spec / unknown-slug ergonomics as `tech`. (`src/commands/refine.js`, prompt in `src/ai/prompts/refine.js`) + +9. **`list` and `show [type]`** ✅ — file-system utilities, no AI. (`src/commands/list.js`, `src/commands/show.js`) -9. **`scaffold`** ✅ — greenfield-only file scaffolder. Reads `.draftwise/scaffold.json` (written by the host coding agent during init's greenfield handoff), confirms with the user — including a warning that scaffolders like `create-next-app` should run first — then creates each `initial_files` entry with placeholder content (skipping any that already exist). Prints the `setup_commands` as a reminder; doesn't run them. (`src/commands/scaffold.js`) +10. **`scaffold`** ✅ — greenfield-only file scaffolder. Reads `.draftwise/scaffold.json` (written by the host coding agent during init's greenfield handoff), confirms with the user — including a warning that scaffolders like `create-next-app` should run first — then creates each `initial_files` entry with placeholder content (skipping any that already exist). Prints the `setup_commands` as a reminder; doesn't run them. (`src/commands/scaffold.js`) --- diff --git a/README.md b/README.md index a891926..02297bf 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,7 @@ Claude Code namespaces plugin skills as `:`, so the slash form is | `draftwise clarify []` | Audit a product spec for ambiguities, untested assumptions, and missing edge cases; rewrite in place. | | `draftwise tech []` | Technical spec from the product spec, grounded in real files. | | `draftwise tasks []` | Implementation tasks from the tech spec, dependency-ordered. | +| `draftwise refine [] [--type=...]` | Re-ground an existing spec (`product` / `tech` / `tasks`) while preserving PM hand-edits — only weak sections get rewritten. | | `draftwise scaffold --yes` | Create initial files from a greenfield plan. | | `draftwise list` | List all specs in `.draftwise/specs/`. | | `draftwise show [type]` | Show a spec (`product`, `tech`, or `tasks`; default: `product`). | diff --git a/src/ai/prompts/refine.js b/src/ai/prompts/refine.js new file mode 100644 index 0000000..b20ff25 --- /dev/null +++ b/src/ai/prompts/refine.js @@ -0,0 +1,60 @@ +const TYPE_META = { + product: { + file: 'product-spec.md', + sourcesBrownfield: 'the scanner output (the codebase as it exists today)', + sourcesGreenfield: 'overview.md (the project plan + chosen stack)', + forbidNewSections: + 'Don\'t add or remove sections. Preserve any YAML frontmatter (`depends_on:` / `related:`) at the top of the file verbatim.', + }, + tech: { + file: 'technical-spec.md', + sourcesBrownfield: 'the product spec above plus the scanner output', + sourcesGreenfield: 'the product spec above plus overview.md (the project plan + chosen stack)', + forbidNewSections: 'Don\'t add or remove sections.', + }, + tasks: { + file: 'tasks.md', + sourcesBrownfield: 'the technical spec above plus the scanner output', + sourcesGreenfield: 'the technical spec above plus overview.md (the project plan + chosen stack)', + forbidNewSections: + 'Don\'t change the task numbering scheme or drop tasks. You may rewrite task descriptions, but keep the same set of numbered tasks unless one is genuinely redundant.', + }, +}; + +export function buildAgentInstruction(slug, type, projectState = 'brownfield') { + const meta = TYPE_META[type]; + if (!meta) { + throw new Error(`Unknown spec type: ${type}`); + } + const sources = + projectState === 'greenfield' ? meta.sourcesGreenfield : meta.sourcesBrownfield; + + return `The ${meta.file} above already exists in .draftwise/specs/${slug}/${meta.file}. The PM has hand-edited it since the last draft. Refine the file in place — preserve their edits, improve everything else. + +Before refining, read .draftwise/constitution.md if it exists and apply its Voice and Spec language sections${ + type === 'tech' ? ', plus its Edge case discipline section' : '' + }. Skip silently if absent. + +Run a refinement pass in three phases: + +PHASE 1 — Audit each section: + - Walk the spec section by section. For each one, decide: strong or weak. + - **Strong** = specific, grounded in real code/plan, written in active voice, internally consistent. The PM either wrote this themselves or the previous draft already nailed it. Leave it alone. + - **Weak** = vague claims, generic placeholders, contradictions, ungrounded references, copy that reads like a template. These need rewriting. + - Don't rewrite a section just because you'd word it differently. The bar is "this section actively misleads or under-specifies," not "this could be tighter." + +PHASE 2 — Re-ground the weak sections: + - For each weak section, re-ground it against ${sources}. + - If the existing text references a file/route/model/component that doesn't appear in the source-of-truth, either remove the reference or mark it "(unverified)" — never silently keep a fabricated path. + - Match the language style of the strong sections — voice, sentence length, level of specificity. The output should read as one coherent document, not a patchwork. + +PHASE 3 — Write the file back: + - Save the refined spec to .draftwise/specs/${slug}/${meta.file}, replacing the existing file. + - ${meta.forbidNewSections} + - Preserve the strong sections character-for-character. Only the weak sections change. + +Hard rules: +- No fabricated code references. If the source-of-truth doesn't surface it, don't claim it exists. +- No scope creep. Refining means improving how the spec reads and how well it's grounded — not adding new features, edge cases, or requirements that weren't there before. If you spot a genuine gap, list it under "Open questions" (or the equivalent section if it exists) instead of writing new prose. +- Don't touch sections you'd classify as strong. If the whole spec is already strong, say so and exit without writing.`; +} diff --git a/src/commands/refine.js b/src/commands/refine.js new file mode 100644 index 0000000..80bb7d2 --- /dev/null +++ b/src/commands/refine.js @@ -0,0 +1,200 @@ +import { readFile } from 'node:fs/promises'; +import { parseArgs } from 'node:util'; +import { cachedScan as defaultScan } from '../utils/scan-cache.js'; +import { loadConfig as defaultLoadConfig } from '../utils/config.js'; +import { listSpecs as defaultListSpecs } from '../utils/specs.js'; +import { readOverview as defaultReadOverview } from '../utils/overview.js'; +import { requireDraftwiseDir } from '../utils/draftwise-dir.js'; +import { loadScanContext } from '../utils/scan-context.js'; +import { AGENT_HANDOFF_PREFIX } from '../utils/agent-handoff.js'; +import { buildAgentInstruction } from '../ai/prompts/refine.js'; + +export const HELP = `draftwise refine [] [--type=] — re-ground a spec while preserving PM edits + +Usage: + draftwise refine # auto-pick if exactly one product spec exists + draftwise refine # target a specific feature (default: product spec) + draftwise refine --type=tech # refine technical-spec.md + draftwise refine --type=tasks # refine tasks.md + +Reads the chosen spec plus its source-of-truth (scanner data or +overview.md, plus the upstream spec for tech/tasks) and prints an +instruction telling your coding agent to audit each section, re-ground +the weak ones, and rewrite the file in place — leaving strong sections +untouched. Different shape from \`clarify\`: clarify finds gaps and +walks the PM through them; refine takes the existing spec as ground +truth for what the PM wants and improves how it's written and grounded. + +When multiple specs of the requested type exist and no +is supplied, the command errors with the available slugs. +`; + +const TYPES = new Set(['product', 'tech', 'tasks']); +const ARG_OPTIONS = { + type: { type: 'string' }, +}; + +const TYPE_META = { + product: { + fileKey: 'productSpec', + presenceKey: 'hasProductSpec', + label: 'product spec', + headingLabel: 'PRODUCT SPEC', + populateHint: 'draftwise new ""', + }, + tech: { + fileKey: 'technicalSpec', + presenceKey: 'hasTechnicalSpec', + label: 'technical spec', + headingLabel: 'TECHNICAL SPEC', + populateHint: 'draftwise tech', + }, + tasks: { + fileKey: 'tasks', + presenceKey: 'hasTasks', + label: 'tasks file', + headingLabel: 'TASKS', + populateHint: 'draftwise tasks', + }, +}; + +export default async function refineCommand(args = [], deps = {}) { + const cwd = deps.cwd ?? process.cwd(); + const log = deps.log ?? ((msg) => console.error(msg)); + const scan = deps.scan ?? defaultScan; + const loadConfig = deps.loadConfig ?? defaultLoadConfig; + const listSpecs = deps.listSpecs ?? defaultListSpecs; + const readOverview = deps.readOverview ?? defaultReadOverview; + + await requireDraftwiseDir(cwd); + + let parsed; + try { + parsed = parseArgs({ + args, + options: ARG_OPTIONS, + allowPositionals: true, + strict: true, + }); + } catch (err) { + throw new Error(`Invalid arguments to draftwise refine: ${err.message}`, { + cause: err, + }); + } + const requestedSlug = parsed.positionals[0]; + const type = parsed.values.type ?? 'product'; + if (!TYPES.has(type)) { + throw new Error( + `Unknown --type "${type}". Expected one of: product, tech, tasks.`, + ); + } + const meta = TYPE_META[type]; + + const config = await loadConfig(cwd, { log }); + + const specs = (await listSpecs(cwd)).filter((s) => s[meta.presenceKey]); + if (specs.length === 0) { + throw new Error( + `No ${meta.label}s found in .draftwise/specs/. Run \`${meta.populateHint}\` first.`, + ); + } + + let target; + if (requestedSlug) { + target = specs.find((s) => s.slug === requestedSlug); + if (!target) { + const available = specs.map((s) => s.slug).join(', '); + throw new Error( + `No ${meta.label} found for "${requestedSlug}". Available: ${available}`, + ); + } + } else if (specs.length === 1) { + target = specs[0]; + log(`Using the only ${meta.label}: ${target.slug}`); + } else { + const available = specs.map((s) => s.slug).join(', '); + throw new Error( + `Multiple ${meta.label}s exist. Pass one as a positional argument: draftwise refine ${ + type === 'product' ? '' : ` --type=${type}` + }. Available: ${available}`, + ); + } + + const existingSpec = await readFile(target[meta.fileKey], 'utf8'); + if (!existingSpec.trim()) { + throw new Error( + `${target.slug}/${target[meta.fileKey].split(/[\\/]/).pop()} is empty. Run \`${meta.populateHint}\` to populate it.`, + ); + } + + let upstream = null; + if (type === 'tech') { + if (!target.hasProductSpec) { + throw new Error( + `Cannot refine technical spec for "${target.slug}": product-spec.md is missing. The product spec is the source of truth for the tech spec.`, + ); + } + upstream = { + label: 'PRODUCT SPEC (source of truth)', + content: await readFile(target.productSpec, 'utf8'), + }; + } else if (type === 'tasks') { + if (!target.hasTechnicalSpec) { + throw new Error( + `Cannot refine tasks for "${target.slug}": technical-spec.md is missing. The technical spec is the source of truth for tasks.`, + ); + } + upstream = { + label: 'TECHNICAL SPEC (source of truth)', + content: await readFile(target.technicalSpec, 'utf8'), + }; + } + + const needsScanContext = type === 'product' || type === 'tech'; + const scanContext = needsScanContext + ? await loadScanContext({ + cwd, + config, + log, + scan, + readOverview, + commandName: 'refine', + }) + : null; + + log(''); + log(`Handing the ${meta.label} off to your coding agent for refinement.`); + log(AGENT_HANDOFF_PREFIX); + log(''); + log('---'); + log(`SPEC: ${target.slug}`); + log(`TYPE: ${type}`); + log(''); + if (upstream) { + log(upstream.label); + log(upstream.content); + log(''); + } + log(`EXISTING ${meta.headingLabel} (refine this)`); + log(existingSpec); + log(''); + if (scanContext) { + if (config.projectState === 'greenfield') { + log('PROJECT PLAN (overview.md)'); + log(scanContext.overview); + } else { + log('SCANNER OUTPUT'); + log('```json'); + log(JSON.stringify(scanContext.scanForPrompt, null, 2)); + log('```'); + log(''); + log('PACKAGE METADATA'); + log('```json'); + log(JSON.stringify(scanContext.packageMeta, null, 2)); + log('```'); + } + log(''); + } + log('INSTRUCTION'); + log(buildAgentInstruction(target.slug, type, config.projectState)); +} diff --git a/src/index.js b/src/index.js index 80c54c2..869d900 100644 --- a/src/index.js +++ b/src/index.js @@ -12,6 +12,7 @@ const COMMANDS = { clarify: () => import('./commands/clarify.js'), tech: () => import('./commands/tech.js'), tasks: () => import('./commands/tasks.js'), + refine: () => import('./commands/refine.js'), list: () => import('./commands/list.js'), show: () => import('./commands/show.js'), scaffold: () => import('./commands/scaffold.js'), @@ -42,6 +43,7 @@ Commands: clarify [] Audit a product spec for ambiguities + missing edge cases tech [] Draft technical-spec.md from approved product spec tasks [] Generate ordered tasks.md from technical spec + refine [] Refine an existing spec in place — preserves PM edits, re-grounds the rest list List all specs in .draftwise/specs/ show [type] Show a spec (type: product | tech | tasks; default: product) skills Manage the standalone slash-command skill across harnesses diff --git a/test/commands/refine.test.js b/test/commands/refine.test.js new file mode 100644 index 0000000..ee0e29a --- /dev/null +++ b/test/commands/refine.test.js @@ -0,0 +1,240 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtemp, rm, mkdir, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import refineCommand from '../../src/commands/refine.js'; + +const SAMPLE_SCAN = { + root: '/repo', + files: ['src/api/albums.ts'], + packageMeta: { name: 'photos', dependencies: ['next'], devDependencies: [] }, + frameworks: ['Next.js'], + orms: ['Prisma'], + routes: [{ method: 'GET', path: '/albums', file: 'src/api/albums.ts' }], + components: [], + models: [{ name: 'Album', file: 'prisma/schema.prisma', fields: ['id', 'title'] }], +}; + +async function seedSpec(dir, slug, files = {}) { + const specDir = join(dir, '.draftwise', 'specs', slug); + await mkdir(specDir, { recursive: true }); + if (files.product !== undefined) { + await writeFile(join(specDir, 'product-spec.md'), files.product, 'utf8'); + } + if (files.tech !== undefined) { + await writeFile(join(specDir, 'technical-spec.md'), files.tech, 'utf8'); + } + if (files.tasks !== undefined) { + await writeFile(join(specDir, 'tasks.md'), files.tasks, 'utf8'); + } + return specDir; +} + +const baseDeps = (overrides = {}) => ({ + scan: async () => SAMPLE_SCAN, + loadConfig: async () => ({ projectState: 'brownfield' }), + ...overrides, +}); + +describe('draftwise refine', () => { + let dir; + let logs; + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'draftwise-refine-')); + await mkdir(join(dir, '.draftwise')); + logs = []; + }); + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + it('errors if .draftwise/ is missing', async () => { + await rm(join(dir, '.draftwise'), { recursive: true }); + await expect( + refineCommand([], baseDeps({ cwd: dir, log: () => {} })), + ).rejects.toThrow(/Run `draftwise init` first/); + }); + + it('errors if no product specs exist (default type)', async () => { + await expect( + refineCommand([], baseDeps({ cwd: dir, log: () => {} })), + ).rejects.toThrow(/No product specs found/); + }); + + it('errors on unknown --type value', async () => { + await expect( + refineCommand(['--type=summary'], baseDeps({ cwd: dir, log: () => {} })), + ).rejects.toThrow(/Unknown --type/); + }); + + it('rejects unknown flags via parseArgs strict mode', async () => { + await seedSpec(dir, 'alpha', { product: '# Product\n' }); + await expect( + refineCommand(['--bogus'], baseDeps({ cwd: dir, log: () => {} })), + ).rejects.toThrow(/Invalid arguments to draftwise refine/); + }); + + it('auto-picks the only product spec', async () => { + await seedSpec(dir, 'collab-albums', { product: '# Product\n\nThe spec.' }); + await refineCommand( + [], + baseDeps({ cwd: dir, log: (m) => logs.push(m) }), + ); + const out = logs.join('\n'); + expect(out).toContain('Using the only product spec: collab-albums'); + expect(out).toContain('SPEC: collab-albums'); + expect(out).toContain('TYPE: product'); + expect(out).toContain('EXISTING PRODUCT SPEC'); + expect(out).toContain('SCANNER OUTPUT'); + expect(out).toContain('INSTRUCTION'); + expect(out).toContain('refine'); + }); + + it('uses the slug arg to pick a specific product spec', async () => { + await seedSpec(dir, 'alpha', { product: '# A\n' }); + await seedSpec(dir, 'beta', { product: '# B\n' }); + await refineCommand( + ['beta'], + baseDeps({ cwd: dir, log: (m) => logs.push(m) }), + ); + expect(logs.join('\n')).toContain('SPEC: beta'); + }); + + it('errors when an unknown slug is requested', async () => { + await seedSpec(dir, 'alpha', { product: '# A\n' }); + await expect( + refineCommand(['ghost'], baseDeps({ cwd: dir, log: () => {} })), + ).rejects.toThrow(/No product spec found for "ghost"/); + }); + + it('errors when multiple product specs exist and no slug is given', async () => { + await seedSpec(dir, 'alpha', { product: '# A\n' }); + await seedSpec(dir, 'beta', { product: '# B\n' }); + await expect( + refineCommand([], baseDeps({ cwd: dir, log: () => {} })), + ).rejects.toThrow(/Multiple product specs.*Available: alpha, beta/); + }); + + it('errors if the chosen product spec is empty', async () => { + await seedSpec(dir, 'empty', { product: '' }); + await expect( + refineCommand([], baseDeps({ cwd: dir, log: () => {} })), + ).rejects.toThrow(/empty/); + }); + + it('--type=tech filters to specs that have a technical spec and prints product upstream', async () => { + await seedSpec(dir, 'alpha', { product: '# Product\n' }); + await seedSpec(dir, 'beta', { + product: '# Product\n\nBeta product body.', + tech: '# Tech\n\nBeta tech body.', + }); + await refineCommand( + ['--type=tech'], + baseDeps({ cwd: dir, log: (m) => logs.push(m) }), + ); + const out = logs.join('\n'); + expect(out).toContain('Using the only technical spec: beta'); + expect(out).toContain('TYPE: tech'); + expect(out).toContain('PRODUCT SPEC (source of truth)'); + expect(out).toContain('Beta product body.'); + expect(out).toContain('EXISTING TECHNICAL SPEC'); + expect(out).toContain('Beta tech body.'); + expect(out).toContain('SCANNER OUTPUT'); + }); + + it('--type=tasks filters to specs with tasks.md, prints tech upstream, skips scanner', async () => { + await seedSpec(dir, 'alpha', { + product: '# P\n', + tech: '# Tech\n\nAlpha tech body.', + tasks: '# Tasks\n\n1. Do thing.', + }); + let scanCalled = false; + await refineCommand( + ['--type=tasks'], + baseDeps({ + cwd: dir, + log: (m) => logs.push(m), + scan: async () => { + scanCalled = true; + return SAMPLE_SCAN; + }, + }), + ); + const out = logs.join('\n'); + expect(scanCalled).toBe(false); + expect(out).toContain('TYPE: tasks'); + expect(out).toContain('TECHNICAL SPEC (source of truth)'); + expect(out).toContain('Alpha tech body.'); + expect(out).toContain('EXISTING TASKS'); + expect(out).toContain('Do thing.'); + expect(out).not.toContain('SCANNER OUTPUT'); + expect(out).not.toContain('PROJECT PLAN'); + }); + + it('greenfield product: skips scanner, dumps PROJECT PLAN', async () => { + await seedSpec(dir, 'collab-albums', { product: '# Product\n' }); + let scanCalled = false; + await refineCommand( + [], + baseDeps({ + cwd: dir, + log: (m) => logs.push(m), + scan: async () => { + scanCalled = true; + return SAMPLE_SCAN; + }, + loadConfig: async () => ({ projectState: 'greenfield' }), + readOverview: async () => '# Plan\n\nNext.js + Prisma\n', + }), + ); + expect(scanCalled).toBe(false); + const out = logs.join('\n'); + expect(out).toContain('PROJECT PLAN'); + expect(out).not.toContain('SCANNER OUTPUT'); + }); + + it('--type=tech errors if the product spec is missing', async () => { + const specDir = join(dir, '.draftwise', 'specs', 'orphan'); + await mkdir(specDir, { recursive: true }); + await writeFile(join(specDir, 'technical-spec.md'), '# Tech\n', 'utf8'); + await expect( + refineCommand( + ['--type=tech'], + baseDeps({ cwd: dir, log: () => {} }), + ), + ).rejects.toThrow(/product-spec\.md is missing/); + }); + + it('--type=tasks errors if the technical spec is missing', async () => { + const specDir = join(dir, '.draftwise', 'specs', 'orphan'); + await mkdir(specDir, { recursive: true }); + await writeFile(join(specDir, 'tasks.md'), '# Tasks\n', 'utf8'); + await expect( + refineCommand( + ['--type=tasks'], + baseDeps({ cwd: dir, log: () => {} }), + ), + ).rejects.toThrow(/technical-spec\.md is missing/); + }); + + it('does not write to disk', async () => { + await seedSpec(dir, 'collab-albums', { product: '# Product\n\nBody.' }); + await refineCommand( + [], + baseDeps({ cwd: dir, log: () => {} }), + ); + }); + + it('instruction includes the no-fabrication and no-scope-creep rules', async () => { + await seedSpec(dir, 'alpha', { product: '# Product\n' }); + await refineCommand([], baseDeps({ cwd: dir, log: (m) => logs.push(m) })); + const out = logs.join('\n'); + expect(out).toMatch(/PHASE 1/); + expect(out).toMatch(/PHASE 2/); + expect(out).toMatch(/PHASE 3/); + expect(out).toMatch(/no fabricated code references/i); + expect(out).toMatch(/scope creep/i); + }); +});