From f751a8721e38680a063ccd4bac9cfe5ade424e3d Mon Sep 17 00:00:00 2001 From: Ankur <04.ankur@gmail.com> Date: Wed, 29 Apr 2026 21:54:16 +0530 Subject: [PATCH] Drop API mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes the api-mode synthesis path entirely — src/ai/provider.js, src/ai/providers/claude.js, the @anthropic-ai/sdk dependency, the per-command api branches in scan/explain/new/tech/tasks/init, and supporting utilities (overwrite-guard, answers-flag, principles.js, spec-quality.js) that only fed api-mode synthesis. Each prompt module now exports just buildAgentInstruction (or AGENT_INSTRUCTION for scan); the host coding agent does the synthesis using its own model. Config schema simplifies: the `ai:` block is no longer used. loadConfig still reads existing configs that carry one and prints a one-line notice that the block is safe to delete, then ignores it. Init loses --ai-mode/--provider/--api-key-env/--stack/--answers; new/tech/tasks lose --force and --answers. Brownfield init runs end-to-end with no flags now; greenfield still needs --idea and prints the structured handoff if missing. CLAUDE.md, README.md, CHANGELOG.md, and the plugin reference files updated to match. 227 tests passing; lint clean. --- CHANGELOG.md | 10 +- CLAUDE.md | 94 ++-- README.md | 16 +- package-lock.json | 49 -- package.json | 1 - plugin/skills/draftwise/SKILL.md | 12 +- plugin/skills/draftwise/reference/explain.md | 6 +- plugin/skills/draftwise/reference/init.md | 19 +- plugin/skills/draftwise/reference/new.md | 16 +- plugin/skills/draftwise/reference/scaffold.md | 4 +- plugin/skills/draftwise/reference/scan.md | 6 +- plugin/skills/draftwise/reference/tasks.md | 9 +- plugin/skills/draftwise/reference/tech.md | 9 +- src/ai/prompts/explain.js | 76 +-- src/ai/prompts/greenfield.js | 189 ------- src/ai/prompts/new.js | 315 ----------- src/ai/prompts/principles.js | 23 - src/ai/prompts/scan.js | 82 +-- src/ai/prompts/spec-quality.js | 38 -- src/ai/prompts/tasks.js | 131 ----- src/ai/prompts/tech.js | 135 ----- src/ai/provider.js | 37 -- src/ai/providers/claude.js | 68 --- src/commands/explain.js | 75 +-- src/commands/init.js | 518 +++-------------- src/commands/new.js | 261 ++------- src/commands/scaffold.js | 2 +- src/commands/scan.js | 70 +-- src/commands/tasks.js | 132 ++--- src/commands/tech.js | 132 ++--- src/utils/answers-flag.js | 39 -- src/utils/config.js | 37 +- src/utils/overwrite-guard.js | 46 -- test/ai/greenfield.test.js | 134 ----- test/ai/new.test.js | 57 -- test/ai/principles.test.js | 61 -- test/ai/provider.test.js | 87 --- test/ai/providers/claude.test.js | 233 -------- test/ai/spec-quality.test.js | 113 ---- test/commands/explain.test.js | 55 +- test/commands/init.test.js | 523 ++---------------- test/commands/new.test.js | 462 +--------------- test/commands/scan.test.js | 53 +- test/commands/tasks.test.js | 256 +-------- test/commands/tech.test.js | 347 +----------- test/integration/pipeline.test.js | 76 +-- test/utils/answers-flag.test.js | 60 -- test/utils/config.test.js | 76 +-- test/utils/overwrite-guard.test.js | 102 ---- 49 files changed, 509 insertions(+), 4843 deletions(-) delete mode 100644 src/ai/prompts/principles.js delete mode 100644 src/ai/prompts/spec-quality.js delete mode 100644 src/ai/provider.js delete mode 100644 src/ai/providers/claude.js delete mode 100644 src/utils/answers-flag.js delete mode 100644 src/utils/overwrite-guard.js delete mode 100644 test/ai/greenfield.test.js delete mode 100644 test/ai/new.test.js delete mode 100644 test/ai/principles.test.js delete mode 100644 test/ai/provider.test.js delete mode 100644 test/ai/providers/claude.test.js delete mode 100644 test/ai/spec-quality.test.js delete mode 100644 test/utils/answers-flag.test.js delete mode 100644 test/utils/overwrite-guard.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d61ae9..d1a6b76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,9 +6,13 @@ Each released version is tagged in git (`v0.0.1`, `v0.1.0`, etc.) and includes t ## [Unreleased] +### Removed + +- **API mode dropped — Draftwise runs only inside coding agents now.** Every command (`scan`, `explain`, `new`, `tech`, `tasks`, `init`) used to support two execution paths: agent mode (print scanner data + instruction, let the host coding agent do the synthesis) and api mode (call Claude directly via `@anthropic-ai/sdk` and write the spec from the CLI). The api path is gone. `src/ai/provider.js`, `src/ai/providers/claude.js`, and the `@anthropic-ai/sdk` dependency are removed. `init` loses its `--ai-mode`, `--provider`, `--api-key-env`, `--stack`, and `--answers` flags plus the corresponding interactive prompts; `new`/`tech`/`tasks` lose `--force` and (for `new`) `--answers`; `scaffold` is unchanged. `config.yaml` schema simplifies — the `ai:` block (`mode`/`provider`/`api_key_env`/`model`/`max_tokens`) is no longer used; `loadConfig` returns only `projectState`/`stack`/`scanMaxFiles`. Existing configs with an `ai:` block keep working: `loadConfig` prints a one-line notice telling the user the block is safe to delete, then ignores it. The shared overwrite-guard and answers-flag utilities and the `src/ai/prompts/principles.js` + `src/ai/prompts/spec-quality.js` modules are removed — every prompt module now exports just `buildAgentInstruction` (or `AGENT_INSTRUCTION` for `scan`), since the host coding agent is the only consumer. CLAUDE.md and README.md updated end-to-end. — Ankur + ### Changed -- **`draftwise skills install` auto-detects which AI harnesses are on the machine instead of installing to all three by default.** Previously the default behavior was to write SKILL.md into every known harness's user-level skill dir (`~/.claude/skills/draftwise/`, `~/.cursor/skills/draftwise/`, `~/.gemini/skills/draftwise/`) regardless of whether the user actually had Claude Code, Cursor, or Gemini CLI installed. Now `detectInstalledProviders` (in `src/utils/skill-providers.js`) checks which of `~/.claude` / `~/.cursor` / `~/.gemini` exist at the chosen scope root and installs only to those. The detected set is logged so the user sees *why* a particular harness was picked. `--provider=all` is the explicit opt-in for the old behavior; `--provider=` still targets one harness regardless of detection. When detection finds nothing the command errors with a hint pointing at both override flags. `skills help` now also prints "Detected harnesses (user scope): …" and the project-scope equivalent so the auto-detect set is visible without having to run install. Why: this is the same behavior impeccable's install uses (via the `vercel-labs/skills` package — auto-detect, with `--all` as the explicit override) and it's friendlier than littering provider dirs with files for harnesses the user doesn't have. CLAUDE.md's "Standalone skill" section and README's slash-command callout updated to match. `skills uninstall` keeps its existing "iterate every known dir and skip ones with nothing to remove" behavior — different goal (clean up stale Draftwise installs whether or not the harness is still on disk), so detection-on-uninstall would miss the cleanup case. — Ankur +- **`draftwise skills install` auto-detects which AI harnesses are on the machine instead of installing to all three by default.** Previously the default behavior was to write SKILL.md into every known harness's user-level skill dir (`~/.claude/skills/draftwise/`, `~/.cursor/skills/draftwise/`, `~/.gemini/skills/draftwise/`) regardless of whether the user actually had Claude Code, Cursor, or Gemini CLI installed. Now `detectInstalledProviders` (in `src/utils/skill-providers.js`) checks which of `~/.claude` / `~/.cursor` / `~/.gemini` exist at the chosen scope root and installs only to those. The detected set is logged so the user sees *why* a particular harness was picked. `--provider=all` is the explicit opt-in for the old behavior; `--provider=` still targets one harness regardless of detection. When detection finds nothing the command errors with a hint pointing at both override flags. `skills help` now also prints "Detected harnesses (user scope): …" and the project-scope equivalent so the auto-detect set is visible without having to run install. Why: auto-detect with `--provider=all` as an explicit override is friendlier than littering provider dirs with files for harnesses the user doesn't have. CLAUDE.md's "Standalone skill" section and README's slash-command callout updated to match. `skills uninstall` keeps its existing "iterate every known dir and skip ones with nothing to remove" behavior — different goal (clean up stale Draftwise installs whether or not the harness is still on disk), so detection-on-uninstall would miss the cleanup case. — Ankur ## [0.2.1] — 2026-04-29 — Ankur @@ -26,7 +30,7 @@ After merge, ritual: `git tag -a v0.2.1 -m "v0.2.1"`, `git push origin v0.2.1`, ### Added -- **`draftwise skills ` — standalone slash-command skill across Claude Code, Cursor, and Gemini CLI.** Three subcommands grouped under `skills` (the `git remote ` / `gh pr ` / `impeccable skills ` pattern): `install` writes `plugin/skills/draftwise/` (SKILL.md + per-verb references) into each known harness's user-level skill dir (`~/.claude/skills/draftwise/`, `~/.cursor/skills/draftwise/`, `~/.gemini/skills/draftwise/`); `uninstall` removes them; `help` prints a 3×2 state table (3 harnesses × 2 scopes). `--provider=claude|cursor|gemini|all` narrows; `--scope=user|project` switches between `~` and ``; `--force` overwrites on install. Why: (1) Claude Code's marketplace plugin path forces a `:` namespace prefix on every plugin skill regardless of `/plugin install` scope ([anthropics/claude-code#15882](https://github.com/anthropics/claude-code/issues/15882), closed: not planned), so the marketplace install gives users `/draftwise:draftwise ` instead of `/draftwise ` — a standalone SKILL.md written directly into `.claude/skills/` has no plugin manifest and resolves bare. (2) Same SKILL.md works across harnesses with a tiny per-provider frontmatter trim (Claude-only `user-invocable` / `argument-hint` / `allowed-tools` stripped for Cursor + Gemini); body identical. Same model impeccable uses (`npx impeccable skills install` writes to ~11 provider dirs). The marketplace plugin is independent and may coexist; both forms appear side-by-side in Claude Code's slash menu. Conflict detection lists every existing target in one error (no half-installs). Subcommand routing lives in `src/index.js` via a small `SUBCOMMAND_GROUPS` table; provider mapping + frontmatter trim in `src/utils/skill-providers.js`; commands at `src/commands/skills/{install,uninstall,help}.js`. `package.json` `files` ships `plugin/skills/` so the install can resolve its source from the npm install. Tests inject temp `home`, `cwd`, and `sourceDir` to keep the real `~/.claude/` (and equivalents) untouched. Plugin SKILL.md learns `skills` as a verb routing to `reference/skills.md` so the marketplace plugin's chat-driven flow handles `skills install` / `skills uninstall` / `skills help` natively. — Ankur +- **`draftwise skills ` — standalone slash-command skill across Claude Code, Cursor, and Gemini CLI.** Three subcommands grouped under `skills` (the `git remote ` / `gh pr ` pattern): `install` writes `plugin/skills/draftwise/` (SKILL.md + per-verb references) into each known harness's user-level skill dir (`~/.claude/skills/draftwise/`, `~/.cursor/skills/draftwise/`, `~/.gemini/skills/draftwise/`); `uninstall` removes them; `help` prints a 3×2 state table (3 harnesses × 2 scopes). `--provider=claude|cursor|gemini|all` narrows; `--scope=user|project` switches between `~` and ``; `--force` overwrites on install. Why: (1) Claude Code's marketplace plugin path forces a `:` namespace prefix on every plugin skill regardless of `/plugin install` scope ([anthropics/claude-code#15882](https://github.com/anthropics/claude-code/issues/15882), closed: not planned), so the marketplace install gives users `/draftwise:draftwise ` instead of `/draftwise ` — a standalone SKILL.md written directly into `.claude/skills/` has no plugin manifest and resolves bare. (2) Same SKILL.md works across harnesses with a tiny per-provider frontmatter trim (Claude-only `user-invocable` / `argument-hint` / `allowed-tools` stripped for Cursor + Gemini); body identical. The marketplace plugin is independent and may coexist; both forms appear side-by-side in Claude Code's slash menu. Conflict detection lists every existing target in one error (no half-installs). Subcommand routing lives in `src/index.js` via a small `SUBCOMMAND_GROUPS` table; provider mapping + frontmatter trim in `src/utils/skill-providers.js`; commands at `src/commands/skills/{install,uninstall,help}.js`. `package.json` `files` ships `plugin/skills/` so the install can resolve its source from the npm install. Tests inject temp `home`, `cwd`, and `sourceDir` to keep the real `~/.claude/` (and equivalents) untouched. Plugin SKILL.md learns `skills` as a verb routing to `reference/skills.md` so the marketplace plugin's chat-driven flow handles `skills install` / `skills uninstall` / `skills help` natively. — Ankur ### Changed @@ -62,7 +66,7 @@ The "Mode 1, for real" release. Draftwise now ships a Claude Code plugin so PMs ### Added -- **Claude Code plugin: `/draftwise` slash commands.** New `.claude-plugin/marketplace.json` at repo root declares a single `draftwise` plugin with `source: ./plugin`. Inside `plugin/` lives the install manifest (`.claude-plugin/plugin.json`) plus a single skill `skills/draftwise/SKILL.md` that routes user input to per-verb references at `skills/draftwise/reference/.md` (one per CLI verb: init, new, scan, explain, tech, tasks, list, show, scaffold). Each reference walks the model through how to drive that verb in chat — collect inputs, shell out to the npm-installed `draftwise` CLI, parse the structured handoff or streamed output, report back. Pattern mirrors impeccable: 1 skill / N commands / shells out to the underlying CLI. Users install via `/plugin marketplace add 4nkur/draftwise` then `/plugin install draftwise` in Claude Code; the npm CLI install (`npm i -g draftwise`) is the prerequisite. Plugin name = skill name = `draftwise` so the slash form is `/draftwise ` (no `:` namespacing in command logs). Plugin is distributed separately from the npm package — `package.json` `files` does not include the plugin directories. SKILL.md includes a "Setup gates" table making the verb-dependency chain explicit (`init` is bootstrap; `tech` requires a product spec; `tasks` requires a tech spec; etc.) and a "Conversation standards" section that pulls in the eight collaboration principles from `src/ai/prompts/principles.js` by reference, so the chat-driven flow matches what the CLI's api-mode synthesis enforces. Per-verb references add pre-flight gate checks (catch missing prerequisites in chat instead of letting the CLI throw), tone-shaping guidance for ambiguous flag questions, idea-concreteness check for `new` (one-word ideas get one elaboration ask before invoking), and review nudges for `tech` / `tasks`. README's Quick Start gains a 3-line install snippet for the plugin path. Closes #42. — Ankur +- **Claude Code plugin: `/draftwise` slash commands.** New `.claude-plugin/marketplace.json` at repo root declares a single `draftwise` plugin with `source: ./plugin`. Inside `plugin/` lives the install manifest (`.claude-plugin/plugin.json`) plus a single skill `skills/draftwise/SKILL.md` that routes user input to per-verb references at `skills/draftwise/reference/.md` (one per CLI verb: init, new, scan, explain, tech, tasks, list, show, scaffold). Each reference walks the model through how to drive that verb in chat — collect inputs, shell out to the npm-installed `draftwise` CLI, parse the structured handoff or streamed output, report back. Pattern: 1 skill / N commands / shells out to the underlying CLI. Users install via `/plugin marketplace add 4nkur/draftwise` then `/plugin install draftwise` in Claude Code; the npm CLI install (`npm i -g draftwise`) is the prerequisite. Plugin name = skill name = `draftwise` so the slash form is `/draftwise ` (no `:` namespacing in command logs). Plugin is distributed separately from the npm package — `package.json` `files` does not include the plugin directories. SKILL.md includes a "Setup gates" table making the verb-dependency chain explicit (`init` is bootstrap; `tech` requires a product spec; `tasks` requires a tech spec; etc.) and a "Conversation standards" section that pulls in the eight collaboration principles from `src/ai/prompts/principles.js` by reference, so the chat-driven flow matches what the CLI's api-mode synthesis enforces. Per-verb references add pre-flight gate checks (catch missing prerequisites in chat instead of letting the CLI throw), tone-shaping guidance for ambiguous flag questions, idea-concreteness check for `new` (one-word ideas get one elaboration ask before invoking), and review nudges for `tech` / `tasks`. README's Quick Start gains a 3-line install snippet for the plugin path. Closes #42. — Ankur ### Changed diff --git a/CLAUDE.md b/CLAUDE.md index b973bd9..31b42bf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,14 +35,12 @@ If you're proposing a feature for Draftwise, the test is: **does this make the c ## Architecture ``` -bin/draftwise.js → CLI entry point (shebang, calls src/index.js) +bin/draftwise.js → CLI entry point (shebang, calls src/index.js) src/index.js → command router (dynamic imports, help) src/commands/ → one file per CLI command, default export async fn src/core/scanner.js → codebase scanning (frameworks, routes, components, models) -src/ai/provider.js → routes complete() calls to the right provider adapter -src/ai/providers/ → claude.js wired; openai.js + gemini.js stubbed -src/ai/prompts/ → one prompt module per command. Each exports brownfield + greenfield SYSTEM constants, a selectSystem(projectState) helper, a buildPrompt() that branches on projectState, and an agent-mode instruction -src/utils/ → config.js (yaml loader; returns projectState/stack/scanMaxFiles), specs.js (list .draftwise/specs/), slug.js, overview.js (read .draftwise/overview.md for greenfield context), scan-cache.js (fingerprinted scan cache, drop-in for scan()), flow-filter.js (narrow scan to flow-relevant items), scan-warnings.js (truncation + missing-framework messages), fs.js (shared pathExists), scan-projection.js (shared compactScan that trims a raw scan into a prompt-sized projection), tty.js (isInteractive helper), agent-handoff.js (shared orienting prefix logged before every agent-mode handoff), project-state.js (filesystem auto-detect for `init` — bail-fast walk for source files using scanner's IGNORE_DIRS + CODE_EXTENSIONS), skill-providers.js (provider dir mapping + Claude-only frontmatter trim + `detectInstalledProviders` filesystem check shared across `skills install` / `uninstall` / `help`) +src/ai/prompts/ → one prompt module per command. Each exports a `buildAgentInstruction(...)` (or `AGENT_INSTRUCTION` constant for `scan`) that the host coding agent reads — section structure, hard rules, save path. No SDK call from the CLI; the agent does the synthesis. +src/utils/ → config.js (yaml loader; returns projectState/stack/scanMaxFiles), specs.js (list .draftwise/specs/), slug.js, overview.js (read .draftwise/overview.md for greenfield context), scan-cache.js (fingerprinted scan cache, drop-in for scan()), flow-filter.js (narrow scan to flow-relevant items), scan-warnings.js (truncation + missing-framework messages), fs.js (shared pathExists), scan-projection.js (shared compactScan that trims a raw scan into a prompt-sized projection), scan-context.js (shared greenfield/brownfield branch for new/tech/tasks), draftwise-dir.js (`requireDraftwiseDir` guard), tty.js (isInteractive helper), agent-handoff.js (shared orienting prefix logged before every agent-mode handoff), project-state.js (filesystem auto-detect for `init` — bail-fast walk for source files using scanner's IGNORE_DIRS + CODE_EXTENSIONS), skill-providers.js (provider dir mapping + Claude-only frontmatter trim + `detectInstalledProviders` filesystem check shared across `skills install` / `uninstall` / `help`) test/ → vitest, mirrors src structure .claude-plugin/ → plugin marketplace declaration (see "Claude Code plugin" below) plugin/ → plugin source tree shipped via the marketplace @@ -51,9 +49,9 @@ plugin/ → plugin source tree shipped via the marketplace **Claude Code skill — two install paths, three harnesses on the standalone path.** `.claude-plugin/marketplace.json` at repo root declares a single `draftwise` plugin with `source: ./plugin`. Inside `plugin/` is `.claude-plugin/plugin.json` (the install manifest) and `skills/draftwise/SKILL.md` plus `skills/draftwise/reference/.md` per CLI verb. The same SKILL.md ships through two install paths with different slash-command shapes: - **Marketplace plugin** (`/plugin marketplace add 4nkur/draftwise` then `/plugin install draftwise`): Claude Code namespaces all plugin skills as `:`, so the chat form is `/draftwise:draftwise `. The namespace prefix is mandatory for plugin-installed skills regardless of `/plugin install` scope (user / project / project-this-user) — see [anthropics/claude-code#15882](https://github.com/anthropics/claude-code/issues/15882) (closed: not planned). Claude Code only. -- **Standalone skill** (`draftwise skills install`): writes the same SKILL.md into each known harness's user-level skill dir (`~/.claude/skills/draftwise/`, `~/.cursor/skills/draftwise/`, `~/.gemini/skills/draftwise/`). No plugin manifest sits alongside it, so each harness reads it as a regular user skill — bare `/draftwise `, matching the CLI binary. Same pattern impeccable uses for its bare verbs across providers. **Default is auto-detect:** with no `--provider` flag, `detectInstalledProviders` checks which of `~/.claude` / `~/.cursor` / `~/.gemini` exist at the chosen scope root and installs only to those — same shape as impeccable's install behavior (which delegates to the `vercel-labs/skills` package). `--provider=all` forces install everywhere regardless of detection (the old default); `--provider=` targets one harness regardless of detection; `--scope=project` writes under `` instead of `~`. When auto-detect finds nothing, the command errors with a hint pointing at `--provider=all` / `--provider=`. `skills uninstall` keeps its "remove from every known dir, skip empty" behavior — different goal (clean up stale Draftwise installs whether or not the harness is still present), so detection-on-uninstall would miss the cleanup case. Per-provider frontmatter trim in `src/utils/skill-providers.js` strips Claude-only fields (`user-invocable`, `argument-hint`, `allowed-tools`) for non-Claude harnesses; body is identical. +- **Standalone skill** (`draftwise skills install`): writes the same SKILL.md into each known harness's user-level skill dir (`~/.claude/skills/draftwise/`, `~/.cursor/skills/draftwise/`, `~/.gemini/skills/draftwise/`). No plugin manifest sits alongside it, so each harness reads it as a regular user skill — bare `/draftwise `, matching the CLI binary. **Default is auto-detect:** with no `--provider` flag, `detectInstalledProviders` checks which of `~/.claude` / `~/.cursor` / `~/.gemini` exist at the chosen scope root and installs only to those. `--provider=all` forces install everywhere regardless of detection (the old default); `--provider=` targets one harness regardless of detection; `--scope=project` writes under `` instead of `~`. When auto-detect finds nothing, the command errors with a hint pointing at `--provider=all` / `--provider=`. `skills uninstall` keeps its "remove from every known dir, skip empty" behavior — different goal (clean up stale Draftwise installs whether or not the harness is still present), so detection-on-uninstall would miss the cleanup case. Per-provider frontmatter trim in `src/utils/skill-providers.js` strips Claude-only fields (`user-invocable`, `argument-hint`, `allowed-tools`) for non-Claude harnesses; body is identical. -Pattern follows impeccable's distribution model: one skill routes to per-verb references that drive the conversation in chat and shell out to the npm-installed `draftwise` CLI. The two install paths are independent and may coexist (you'll see both `/draftwise:draftwise ` and `/draftwise ` listed in Claude Code). `package.json` `files` ships `plugin/skills/` (so the standalone install can copy from `node_modules/draftwise/plugin/skills/draftwise/`) but excludes `plugin/.claude-plugin/`. References include pre-flight checks (e.g. `new` warns if `overview.md` is stale, `tech` nudges to skim the product spec first) and tone shaping for how to ask the user about ambiguous flag values; they explicitly inherit `src/ai/prompts/principles.js`'s collaboration standards so the chat-driven conversation matches what the CLI's api-mode synthesis enforces. The `skills` subcommand group lives at `src/commands/skills/{install,uninstall,help}.js`; routing in `src/index.js` follows the `git remote ` / `gh pr ` pattern via `SUBCOMMAND_GROUPS`. +Pattern: one skill routes to per-verb references that drive the conversation in chat and shell out to the npm-installed `draftwise` CLI. The two install paths are independent and may coexist (you'll see both `/draftwise:draftwise ` and `/draftwise ` listed in Claude Code). `package.json` `files` ships `plugin/skills/` (so the standalone install can copy from `node_modules/draftwise/plugin/skills/draftwise/`) but excludes `plugin/.claude-plugin/`. References include pre-flight checks (e.g. `new` warns if `overview.md` is stale, `tech` nudges to skim the product spec first) and tone shaping for how to ask the user about ambiguous flag values. The `skills` subcommand group lives at `src/commands/skills/{install,uninstall,help}.js`; routing in `src/index.js` follows the `git remote ` / `gh pr ` pattern via `SUBCOMMAND_GROUPS`. The single most important module is `src/core/scanner.js` — it parses the user's codebase and produces a structured representation everything else builds on. Get that right and the rest follows. @@ -67,10 +65,10 @@ The single most important module is `src/core/scanner.js` — it parses the user - **eslint + prettier** for code style - **YAML** for config (`yaml` package) - **Markdown** for all spec documents -- **`@inquirer/prompts`** for interactive prompts (init's mode select, new's Q&A loop, tech/tasks spec picker) — strictly the TTY-only convenience layer; flags drive the canonical input path (see "How input works" below). -- **AI SDKs:** `@anthropic-ai/sdk` is wired up for Mode 2 (api). `openai` and `@google/generative-ai` are stubbed in `src/ai/provider.js` and throw a clear "not yet wired up" error — install + implement when needed. Agent mode (Mode 1) ships scanner data + an instruction string for the host agent to consume; no SDK call. +- **`@inquirer/prompts`** for interactive prompts (init's idea prompt, tech/tasks spec picker, scaffold's confirm) — strictly the TTY-only convenience layer; flags drive the canonical input path (see "How input works" below). +- **No AI SDK.** Draftwise no longer calls models from the CLI. Every command prints scanner data plus an instruction for the host coding agent (Claude Code, Cursor, Gemini CLI, etc.), which does the synthesis using its own model. -**On dependency pinning:** `@anthropic-ai/sdk` is pinned to an exact version because it's a 0.x package — semver doesn't promise that 0.91 → 0.92 stays non-breaking. `@inquirer/prompts` and `yaml` use caret ranges because they're stable 1.x+ packages where minor bumps follow semver. Dependabot (`.github/dependabot.yml`) opens PRs for both kinds; the asymmetry is intentional, not an oversight. +**On dependency pinning:** `@inquirer/prompts` and `yaml` use caret ranges because they're stable 1.x+ packages where minor bumps follow semver. Dependabot (`.github/dependabot.yml`) opens PRs for both. No TypeScript for v1 — keep it simple. May migrate later if the codebase grows. @@ -78,23 +76,11 @@ No TypeScript for v1 — keep it simple. May migrate later if the codebase grows ## How AI fits in -Draftwise is fully AI-driven. Every meaningful command needs a model to do its work — the codebase scanner produces structured data, but interpreting that data into useful explanations and grounded specs requires an LLM. There are two ways the AI can be invoked: - -**Mode 1: Inside a coding agent.** Draftwise runs as slash commands inside Claude Code, Cursor, Copilot, etc. The agent's existing model handles the reasoning. Draftwise provides prompts, templates, and the codebase context. - -**Mode 2: Standalone with API key.** User configures an API key (Claude, OpenAI, or Gemini) during `draftwise init`. Draftwise calls the API directly. - -Both modes share the same prompt templates and codebase scanning logic. The difference is just where the model call happens. +Draftwise runs as slash commands inside a coding agent (Claude Code, Cursor, Gemini CLI, Copilot, etc.). The CLI scans the codebase, prints structured scanner data plus an instruction, and exits — the host agent's model does the synthesis and writes the spec back to disk. The CLI never calls a model itself; there is no SDK dependency. Configured in `.draftwise/config.yaml`: ```yaml -ai: - mode: agent | api - provider: claude | openai | gemini # only if mode: api - api_key_env: ANTHROPIC_API_KEY # only if mode: api - model: "" # optional override - max_tokens: 16384 # optional; default 16384. Bumped from 8192 because synthesis calls were truncating on big repos. project: state: greenfield | brownfield # set by `draftwise init`; controls prompt routing stack: "Next.js + Postgres + Prisma" # greenfield only; the stack the PM picked at init @@ -102,7 +88,7 @@ scan: max_files: 5000 # optional; raise for monorepos. Scanner emits a "truncated" warning when this is hit. ``` -`loadConfig()` in `src/utils/config.js` defaults `project.state` to `brownfield` for back-compat with configs written before the greenfield routing landed. +`loadConfig()` in `src/utils/config.js` defaults `project.state` to `brownfield` for back-compat with configs written before the greenfield routing landed. Configs written before api mode was dropped may carry an `ai:` block — `loadConfig` prints a one-line notice telling the user the block is safe to delete, then ignores it. --- @@ -114,17 +100,11 @@ scan: **The AI does homework before asking.** No spec command should write to disk before scanning relevant code. The reading-first principle is what makes Draftwise different from a template generator. -**Prompts are authoritative.** Each command's section structure lives in its prompt module under `src/ai/prompts/.js` (a `SYSTEM` constant plus a `buildPrompt` function plus an agent-mode instruction). Don't hardcode structure inside command files — change the prompt instead. - -**Conversation, not form-filling.** `draftwise new` should walk the user through questions, not present a blank form. The conversation is the value — it surfaces gaps the user wouldn't have noticed in a template. - -**Flags drive input; inquirer is a TTY-only fallback.** Every command takes its full input set as flags (`--mode`, `--ai-mode`, `--idea`, `--answers @file.json`, `--force`, `--yes`, etc.) parsed via Node's built-in `util.parseArgs`. When stdin is a TTY and a required value is missing, inquirer fires to fill it in — that's the only place inquirer lives. When stdin is not a TTY (CI, coding-agent shell), most commands error with a specific usage hint instead of hanging on a prompt; `draftwise init` is special — when it can't proceed without asking the user something, it prints a structured **agent handoff** (questions in chat-friendly format + a re-invocation template, all under `AGENT_HANDOFF_PREFIX`) and exits cleanly, so the host coding agent reads stderr, asks the user in chat, and re-invokes with collected flags. Mode 1 (slash-command wrappers, issue #42) drives the conversation up in the host agent's chat and re-invokes the CLI with flags; the CLI itself becomes a non-conversational executor. TTY-detection helper in `src/utils/tty.js`; tests opt into either path via `deps.isInteractive`. - -**Don't clobber hand-edits silently.** Specs are work product — PMs review and refine them after generation. Re-running `new`, `tech`, or `tasks` against an existing file (same slug, same target) prompts to confirm overwrite; `--force` skips the prompt for scripted use. Agent mode is exempt because the host coding agent does the write, not Draftwise. `scan` is also exempt — refreshing `overview.md` IS its purpose. The check is positioned *before* the synthesis API call (after the plan call in `new`, after target selection in `tech` / `tasks`) so a cancel doesn't burn tokens or waste user-typed answers. +**Prompts are authoritative.** Each command's section structure lives in its prompt module under `src/ai/prompts/.js` (a `buildAgentInstruction(...)` function — or `AGENT_INSTRUCTION` constant for `scan` — that the host agent reads). Don't hardcode structure inside command files — change the prompt instead. -**Opinionated about how the AI talks.** Draftwise injects a shared `CORE_PRINCIPLES` block into every conversational / drafting prompt: no filler, redirect drift, push back on weak ideas (don't repackage them as agreement), extend existing architecture before adding new pieces, flag bad assumptions and uncertain claims, offer the counter-case on strategic decisions. Source of truth: `src/ai/prompts/principles.js`. Change behavior there, not in each command's prompt. +**Conversation lives in the host agent.** The CLI is a non-conversational executor: it loads context (scanner data or greenfield plan), prints an instruction, and exits. The host coding agent walks the PM through clarifying questions in chat, then writes the spec to disk. `draftwise new` doesn't run a Q&A loop from the CLI itself — its instruction tells the agent to. -**Opinionated about how the spec reads.** A second shared block — `SPEC_LANGUAGE_RULES` plus `EDGE_CASE_DISCIPLINE` — lives in `src/ai/prompts/spec-quality.js`. The language rules go into the synthesis SYSTEM constants for `new` and `tech` (specific over generic, active voice, same term every time, cut filler, examples for ambiguous claims, don't blame users, equal-effort sections). The edge-case discipline goes into `tech` only — it tells the model to name empty data, errors, loading, permissions, concurrency, and large-data behavior inline in each component / endpoint section. JSON-shaped calls (plan, questions, stacks) and `tasks` skip both because they aren't drafting prose. Same single-source-of-truth pattern as `principles.js` — change the rule there, not per command. +**Flags drive input; inquirer is a TTY-only fallback.** Every command takes its full input set as flags (`--mode`, `--idea`, `--yes`, etc.) parsed via Node's built-in `util.parseArgs`. When stdin is a TTY and a required value is missing, inquirer fires to fill it in — that's the only place inquirer lives. When stdin is not a TTY (CI, coding-agent shell), most commands error with a specific usage hint instead of hanging on a prompt; `draftwise init` is special — when it can't proceed without asking the user something (greenfield without `--idea`), it prints a structured **agent handoff** (the question in chat-friendly format + a re-invocation template, all under `AGENT_HANDOFF_PREFIX`) and exits cleanly, so the host coding agent reads stderr, asks the user in chat, and re-invokes with the collected flag. TTY-detection helper in `src/utils/tty.js`; tests opt into either path via `deps.isInteractive`. **Single repo, single feature spec at a time.** No cross-spec dependency tracking. No multi-repo. Keep scope tight. @@ -137,9 +117,9 @@ draftwise init → set up .draftwise/; auto-detects new draftwise scaffold → create initial files from the greenfield plan (greenfield only) draftwise scan → refresh the structured codebase overview (brownfield) draftwise explain → trace how a specific flow works in the actual code (brownfield) -draftwise new "" [--force] → conversational drafting → product-spec.md -draftwise tech [] [--force] → drafts technical-spec.md from approved product spec -draftwise tasks [] [--force] → ordered tasks.md from technical spec +draftwise new "" → conversational drafting → product-spec.md (host agent writes) +draftwise tech [] → technical-spec.md from approved product spec (host agent writes) +draftwise tasks [] → ordered tasks.md from technical spec (host agent writes) 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 ) @@ -147,7 +127,7 @@ draftwise skills uninstall [--provider=...] [--scope=...] → remove s draftwise skills help → list known harnesses + install state ``` -Each command is a separate file under `src/commands/` with a single `export default async function(args, deps = {}) {}`. The `deps` object is the dependency-injection seam used by tests — `cwd`, `log`, `scan`, `loadConfig`, `complete`, and per-command prompt overrides. +Each command is a separate file under `src/commands/` with a single `export default async function(args, deps = {}) {}`. The `deps` object is the dependency-injection seam used by tests — `cwd`, `log`, `scan`, `loadConfig`, `readOverview`, `isInteractive`, and per-command prompt overrides. --- @@ -167,33 +147,33 @@ Each command is a separate file under `src/commands/` with a single `export defa │ ├── product-spec.md # what & why │ ├── technical-spec.md # how — grounded in real code (or marked "(new)" for greenfield) │ └── tasks.md # ordered implementation breakdown -└── config.yaml # AI provider + project state + chosen stack +└── config.yaml # project state + chosen stack ``` --- ## v1 status — all commands shipped -The build order below was the original sequence. As of `0.1.5` published to npm, every command is implemented end-to-end with both AI modes (agent + api) and a vitest test suite (~240 tests). The original `0.0.1` cut shipped the command surface; `0.1.0` added greenfield support and Python scanner; `0.1.5` added overwrite protection, live token streaming, and a richer set of drafting / spec-quality prompt rules. +Every command is implemented end-to-end and exercised by a vitest suite. The original `0.0.1` cut shipped the command surface; `0.1.0` added greenfield support and Python scanner; `0.1.5` added overwrite protection and richer drafting prompt rules; `0.2.x` shipped the Claude Code plugin and standalone skill installer; the [Unreleased] cut drops api mode, leaving the CLI as a pure executor that hands off to the host coding agent. -1. **`init`** ✅ — auto-detects project state from the filesystem (zero source files → greenfield; otherwise brownfield, using scanner.js's `IGNORE_DIRS` + `CODE_EXTENSIONS`), asks only for AI mode (and provider / idea when applicable), then routes: - - **Brownfield path:** scans the codebase, writes `.draftwise/specs/`, `overview.md` placeholder, `config.yaml` (with `project.state: brownfield`). - - **Greenfield path:** prompts for the idea, then in **api mode** generates clarifying questions → captures answers → proposes 2-3 stack options with rationale/pros/cons/directory structure/setup commands → writes a full greenfield plan to `overview.md` + `config.yaml` (with `project.state: greenfield` and the chosen `stack`). In **agent mode**, prints a 3-phase instruction for the host coding agent to walk the conversation and rewrite `overview.md`. - `--mode=greenfield|brownfield` overrides the auto-detection (canonical flag values; user-facing copy uses "new project" / "existing codebase" instead). Refuses if `.draftwise/` already exists. Detection lives in `src/utils/project-state.js`. (`src/commands/init.js`, prompts in `src/ai/prompts/greenfield.js`) +1. **`init`** ✅ — auto-detects project state from the filesystem (zero source files → greenfield; otherwise brownfield, using scanner.js's `IGNORE_DIRS` + `CODE_EXTENSIONS`), then routes: + - **Brownfield path:** scans the codebase, writes `.draftwise/specs/`, `overview.md` placeholder, `config.yaml` (with `project.state: brownfield`). No questions asked. + - **Greenfield path:** needs `--idea`. Writes `config.yaml` (with `project.state: greenfield`), `overview.md` placeholder, and prints a 3-phase instruction for the host coding agent to walk the stack-selection conversation and rewrite `overview.md` plus `scaffold.json`. + `--mode=greenfield|brownfield` overrides the auto-detection (canonical flag values; user-facing copy uses "new project" / "existing codebase" instead). Refuses if `.draftwise/` already exists. Detection lives in `src/utils/project-state.js`. (`src/commands/init.js`, prompt in `src/ai/prompts/greenfield.js`) -2. **`scan`** ✅ — brownfield: runs the scanner and (api) calls the model to produce a narrated `overview.md`, or (agent) dumps scanner data + an instruction for the host agent. Greenfield: short-circuits with a friendly "no code yet" message — `overview.md` is the greenfield plan from `init`. (`src/commands/scan.js`) +2. **`scan`** ✅ — brownfield: runs the scanner, prints scanner data + an instruction for the host agent to write a narrated `overview.md`. Greenfield: short-circuits with a friendly "no code yet" message — `overview.md` is the greenfield plan from `init`. (`src/commands/scan.js`) -3. **`explain `** ✅ — brownfield: traces a single flow end-to-end. Greenfield: short-circuits with a friendly message (no flows to trace yet). (`src/commands/explain.js`) +3. **`explain `** ✅ — brownfield: filters scanner output to flow-keyword-relevant items, prints them plus an instruction for the host agent to write `.draftwise/flows/.md`. Greenfield: short-circuits with a friendly message (no flows to trace yet). (`src/commands/explain.js`) -4. **`new ""`** ✅ — brownfield: three-phase conversational drafting (AI plan call returns JSON with affected_flows / clarifying_questions / adjacent_opportunities → inquirer Q&A + accept/decline loop → AI synthesis call → `product-spec.md`). Greenfield: skips the scanner and reads `overview.md` (the project plan from `init`); plan call returns clarifying questions only (no affected_flows / adjacent_opportunities — there's nothing existing to integrate with); synthesis writes a spec without "Affected flows" / "Adjacent changes" sections. Hard rule shared across both modes: never assume — turn every gap into a question. (`src/commands/new.js`, prompts in `src/ai/prompts/new.js` with `selectPlanSystem` / `selectSpecSystem`) +4. **`new ""`** ✅ — brownfield: prints scanner data + the idea + a 3-phase instruction (plan / Q&A / synthesis) for the host agent to walk the conversation and write `product-spec.md`. Greenfield: skips the scanner and reads `overview.md` (the project plan from `init`); the instruction tells the agent to ask clarifying questions only (no affected_flows / adjacent_opportunities) and write a spec without "Affected flows" / "Adjacent changes" sections. (`src/commands/new.js`, prompt in `src/ai/prompts/new.js`) -5. **`tech []`** ✅ — brownfield: reads `product-spec.md`, drafts `technical-spec.md` grounded in scanner output. Greenfield: skips scanner, reads `overview.md` (the project plan), drafts `technical-spec.md` with every file path marked `(new)` and the chosen stack's conventions. (`src/commands/tech.js`, `src/ai/prompts/tech.js` with `selectSystem`) +5. **`tech []`** ✅ — reads `product-spec.md`, prints it plus scanner output (brownfield) or the project plan (greenfield) plus an instruction for the host agent to write `technical-spec.md`. Greenfield marks every file path `(new)` and uses the chosen stack's conventions. (`src/commands/tech.js`, prompt in `src/ai/prompts/tech.js`) -6. **`tasks []`** ✅ — brownfield: reads `technical-spec.md`, drafts ordered `tasks.md` (Goal / Files / Depends on / Parallel with / Acceptance). Greenfield: reads the project plan + technical spec, drafts `tasks.md` where files are all `(new)` and the first 1-3 tasks are foundational scaffolding (run setup commands, install deps, configure env). (`src/commands/tasks.js`, `src/ai/prompts/tasks.js` with `selectSystem`) +6. **`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`) 7. **`list` and `show [type]`** ✅ — file-system utilities, no AI. (`src/commands/list.js`, `src/commands/show.js`) -8. **`scaffold`** ✅ — greenfield-only file scaffolder. Reads `.draftwise/scaffold.json` (written by `init` in greenfield + api mode, or by the host agent in greenfield + agent mode), 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`) +8. **`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`) --- @@ -236,13 +216,13 @@ Don't try to make v1 universal. A great experience for JS/TS is better than a me ## Conventions -- **File per command.** `src/commands/.js` with `export default async function(args, deps = {}) {}`. The `deps` argument is how tests inject `cwd`, `log`, `scan`, `loadConfig`, `complete`, and prompts. +- **File per command.** `src/commands/.js` with `export default async function(args, deps = {}) {}`. The `deps` argument is how tests inject `cwd`, `log`, `scan`, `loadConfig`, `readOverview`, `isInteractive`, and prompts. - **Async/await everywhere.** No `.then()` chains. - **Console output for CLI feedback.** Plain text for now — colored output (kleur/chalk) is deferred until there's a need. - **Errors bubble up to `src/index.js`.** It catches and prints a friendly message, then exits non-zero. -- **Test file naming:** `test/commands/.test.js` tests `src/commands/.js`. Other module tests mirror the source path (`test/utils/config.test.js`, `test/ai/new.test.js`, etc.). -- **AI prompts in `src/ai/prompts/.js`.** Each module exports a `SYSTEM` constant, one or more `buildPrompt` functions, and an agent-mode instruction string. Iterate the prompt here, not inside the command. -- **No real network calls in tests.** Inject `complete` in deps and return a canned model response. Tests run in a temp dir, never the project's `.draftwise/`. +- **Test file naming:** `test/commands/.test.js` tests `src/commands/.js`. Other module tests mirror the source path (`test/utils/config.test.js`, etc.). +- **AI prompts in `src/ai/prompts/.js`.** Each module exports a `buildAgentInstruction(...)` (or `AGENT_INSTRUCTION` constant for `scan`) — section structure, hard rules, and the save path the host agent should write to. Iterate the prompt here, not inside the command. +- **No network calls anywhere.** The CLI doesn't talk to model APIs; tests run in a temp dir, never the project's `.draftwise/`. --- @@ -266,7 +246,7 @@ npm link # install CLI globally from this repo - Single-repo only - Single-author per spec (no real-time collab) - Codebase scan refreshes on demand (no live watching) -- AI mode: agent or API key, configured at init +- Agent-mode only — Draftwise runs inside a coding agent (Claude Code, Cursor, Gemini CLI, etc.); the agent's model does the synthesis ### Deferred for later @@ -297,10 +277,8 @@ When a contributor proposes one of these, gently redirect — they're worth doin Real, currently-open questions only. Resolved decisions move to **Past decisions** below. -1. **Default model.** `claude-sonnet-4-6` hardcoded in `src/ai/providers/claude.js` as the default; users can override via `ai.model` in `config.yaml`. Reasonable for now; revisit when Anthropic ships a successor or a noticeably better trade-off model lands. -2. **OpenAI / Gemini adapters.** Stubbed with a clear error in `src/ai/provider.js`. Wire up when a user asks for them; structure mirrors `claude.js`. -3. **Scanner language coverage — Go and Rust.** Same shape as the Python expansion. Go: net/http, Gin, Echo, Chi + GORM, Ent. Rust: Axum, Actix, Rocket + Diesel, sqlx. -4. **AI-assisted spec merge mode.** Today, re-running `new` / `tech` / `tasks` on an existing spec offers Overwrite or Cancel — both blunt. The richer behavior is a "refine" mode where the model reads the existing file, identifies user edits, and returns a unified spec that preserves them. Different shape of API call (refine vs synthesize) and a real feature, not polish. Defer until there's user demand; the `--force` / cancel default is the safe baseline in the meantime. +1. **Re-add api mode (standalone with API key).** Dropped in [Unreleased] — the CLI now runs only inside coding agents. Bringing it back means re-introducing an SDK dependency, the `ai:` config block, the per-command synthesis branch, and the `--force` / `--answers` flags that supported it. Worth doing if standalone-CLI usage becomes a real demand; until then, every command is simpler and the SDK surface is gone. Reference: the dropped surface is recoverable from git history (`drop-api-mode` PR). +2. **Scanner language coverage — Go and Rust.** Same shape as the Python expansion. Go: net/http, Gin, Echo, Chi + GORM, Ent. Rust: Axum, Actix, Rocket + Diesel, sqlx. ## Past decisions diff --git a/README.md b/README.md index 2a61cdc..398211c 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,7 @@ Specs and codebase summaries live as markdown files in `.draftwise/`. Version-co ## Quick start -Draftwise works with your AI coding agent (Claude Code, Cursor, Antigravity, Copilot, etc.) or a direct API key. +Draftwise runs inside your AI coding agent (Claude Code, Cursor, Gemini CLI, Antigravity, Copilot, etc.). The CLI scans the codebase and prints an instruction; your agent does the spec drafting. ```bash npm install -g draftwise @@ -230,21 +230,13 @@ Test plan → unit, integration, E2E ## Agent compatibility -Draftwise's agent mode is designed to be host-agnostic — the CLI prints structured scanner data and an instruction string; the host's model handles the reasoning. In principle that works with any agentic IDE or CLI: +Draftwise is designed to be host-agnostic — the CLI prints structured scanner data and an instruction string; the host's model handles the reasoning and writes the spec. In principle that works with any agentic IDE or CLI: Claude Code · GitHub Copilot · Cursor · Gemini CLI · Codex CLI · Antigravity · Windsurf · Amp · Roo Code · Kilo Code · OpenCode · Qoder In practice it has only been smoke-tested in a plain terminal so far. If you run it inside one of these and something breaks, please open an issue. -**Non-interactive use.** Every command also runs without a TTY — pass values as flags instead of letting inquirer prompt for them. That's what makes the agent integration possible: a slash-command wrapper (or any host agent) can collect answers in chat and re-invoke `draftwise ` with `--mode=...`, `--ai-mode=...`, `--idea="..."`, `--answers @path`, `--force`, `--yes`, etc. Run `draftwise --help` for the per-command flag list. When `draftwise init` is run in a non-TTY shell with too few flags, it prints a structured handoff with the questions to ask the user — copy it into your AI assistant if you're not already inside one. - -Standalone (API mode) currently supports: - -- ✅ **Claude** (Anthropic) — fully wired -- ⏳ **GPT (OpenAI)** — adapter not yet implemented -- ⏳ **Gemini** (Google) — adapter not yet implemented - -Until the OpenAI and Gemini adapters land, pick `agent` mode at `draftwise init` if you want to use those models — they'll work via the host (e.g. Gemini via Antigravity or Gemini CLI; GPT via Codex CLI or Copilot). +**Non-interactive use.** Every command runs without a TTY — pass values as flags instead of letting inquirer prompt for them. That's what makes the agent integration possible: a slash-command wrapper (or any host agent) can collect answers in chat and re-invoke `draftwise ` with `--mode=...`, `--idea="..."`, `--yes`, etc. Run `draftwise --help` for the per-command flag list. When `draftwise init` is run in a non-TTY shell as greenfield without `--idea`, it prints a structured handoff with the question to ask the user — copy it into your AI assistant if you're not already inside one. --- @@ -279,7 +271,7 @@ v1 commands are all shipped on `npm` as of `0.0.1`. The next published release w - [x] `list` and `show` — spec browsing utilities - [x] optional file scaffolding from `init`'s greenfield plan via `draftwise scaffold` -**Next:** OpenAI and Gemini provider adapters (Claude is the only fully-wired adapter today), framework support beyond JS/TS Node (Python, Go, Rust), greenfield-aware downstream commands, and a flag-aware scanner cache for very large repos. +**Next:** scanner framework support beyond JS/TS and Python (Go, Rust, mobile codebases), and richer flow tracing for very large repos. --- diff --git a/package-lock.json b/package-lock.json index d5b0af2..989a363 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,6 @@ "version": "0.2.1", "license": "MIT", "dependencies": { - "@anthropic-ai/sdk": "0.91.1", "@inquirer/prompts": "^8.4.2", "yaml": "^2.8.3" }, @@ -27,35 +26,6 @@ "node": ">=20.0.0" } }, - "node_modules/@anthropic-ai/sdk": { - "version": "0.91.1", - "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.91.1.tgz", - "integrity": "sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw==", - "license": "MIT", - "dependencies": { - "json-schema-to-ts": "^3.1.1" - }, - "bin": { - "anthropic-ai-sdk": "bin/cli" - }, - "peerDependencies": { - "zod": "^3.25.0 || ^4.0.0" - }, - "peerDependenciesMeta": { - "zod": { - "optional": true - } - } - }, - "node_modules/@babel/runtime": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", - "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", @@ -1675,19 +1645,6 @@ "dev": true, "license": "MIT" }, - "node_modules/json-schema-to-ts": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", - "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.18.3", - "ts-algebra": "^2.0.0" - }, - "engines": { - "node": ">=16" - } - }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -2406,12 +2363,6 @@ "node": ">=14.0.0" } }, - "node_modules/ts-algebra": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", - "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", - "license": "MIT" - }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", diff --git a/package.json b/package.json index c96a988..bdd991a 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,6 @@ "vitest": "^4.1.5" }, "dependencies": { - "@anthropic-ai/sdk": "0.91.1", "@inquirer/prompts": "^8.4.2", "yaml": "^2.8.3" }, diff --git a/plugin/skills/draftwise/SKILL.md b/plugin/skills/draftwise/SKILL.md index 96862e1..4b13ed3 100644 --- a/plugin/skills/draftwise/SKILL.md +++ b/plugin/skills/draftwise/SKILL.md @@ -16,7 +16,7 @@ Draftwise is a CLI for codebase-aware product spec drafting. The user has the `d 2. **Check the setup gates** (see below) before invoking. The CLI will throw if a prerequisite is missing; catching it in chat first is faster and friendlier. -3. **Load the matching reference.** Read `reference/.md` for the workflow specific to that command. It covers what inputs to collect, what flags to pass, how to read the CLI's output (especially the structured handoffs that some commands print on stderr in agent mode), and what to report back. +3. **Load the matching reference.** Read `reference/.md` for the workflow specific to that command. It covers what inputs to collect, what flags to pass, how to read the CLI's structured handoffs (printed on stderr), and what to report back. 4. **Don't write to `.draftwise/` yourself** *unless* the CLI explicitly hands you an INSTRUCTION block to write a file (agent-mode handoff for `scan` / `explain` / `new` / `tech` / `tasks`). Outside of that, the CLI is the source of truth for `.draftwise/` contents — re-invoke it for any change. @@ -34,7 +34,7 @@ Draftwise has implicit dependencies. Surface them in chat before invoking the CL | `tasks` | a `technical-spec.md` exists (i.e. `tech` has run for that feature) | `/draftwise tech` first | | `list` | `.draftwise/` exists | `/draftwise init` first | | `show ` | `.draftwise/` exists; the spec type the user asked for has been generated | `/draftwise new` / `/draftwise tech` / `/draftwise tasks` depending on type | -| `scaffold` | `scaffold.json` exists (greenfield + api-mode init); brownfield short-circuits | `/draftwise init` in greenfield mode | +| `scaffold` | `scaffold.json` exists (host agent writes it during greenfield init's handoff); brownfield short-circuits | `/draftwise init` in greenfield mode | | `skills install` | the `draftwise` CLI is on PATH (`npm i -g draftwise`) | `npm i -g draftwise` first | | `skills uninstall` | a previous `skills install` for the same `--provider` / `--scope` | run `skills install` first if there's nothing to remove | | `skills help` | nothing — read-only state report | — | @@ -43,13 +43,13 @@ Draftwise has implicit dependencies. Surface them in chat before invoking the CL - **`!`draftwise $ARGUMENTS`** is the starting shell call. Pass through whatever flags the user already gave; collect missing required ones in chat first. - **Structured handoff (init in non-TTY)** — if `draftwise init` prints a block starting with "INIT — answer these in chat..." it's asking you to walk the user through the listed questions. Follow the INSTRUCTION block at the bottom verbatim — re-invoke `draftwise init` with the user's collected flags. -- **Agent-mode handoff (scan / explain / new / tech / tasks)** — if the CLI prints SCANNER OUTPUT or PROJECT PLAN followed by an INSTRUCTION block, it expects YOU to do the synthesis. Follow the INSTRUCTION exactly; ground every claim in the scanner data shown. Don't invent files, routes, or models that aren't there. The CLI did NOT write the file in this mode — that's your job. -- **Existing target file** — `new` / `tech` / `tasks` error with "already exists. Pass --force." Ask the user before overwriting; re-invoke with `--force` only on confirmation. Existing work is hand-edited; clobbering it silently is worse than the friction of asking. +- **Agent-mode handoff (scan / explain / new / tech / tasks)** — the CLI always prints SCANNER OUTPUT or PROJECT PLAN followed by an INSTRUCTION block. It expects YOU to do the synthesis. Follow the INSTRUCTION exactly; ground every claim in the scanner data shown. Don't invent files, routes, or models that aren't there. The CLI never writes the spec — that's your job. +- **Existing target file** — when re-running `new` / `tech` / `tasks`, ask the user before overwriting their hand-edits. The CLI doesn't guard against this (it doesn't write specs at all), so the conversation has to. - **`.draftwise/` not found** — point the user at `/draftwise init`. ## Conversation standards -The chat-driven conversation here follows the same standards Draftwise's CLI enforces in api mode (`src/ai/prompts/principles.js` injects them into every drafting / conversational system prompt). Apply them to your chat-side asks too: +Apply these to every chat-side ask: 1. **No filler.** Don't open with "great question" or "happy to help." 2. **Redirect drift.** If the user wanders from the verb's purpose, name it and steer back. @@ -58,6 +58,6 @@ The chat-driven conversation here follows the same standards Draftwise's CLI enf 5. **Right over easy; flag tradeoffs.** When suggesting a flag set, if there's a richer + a quicker option, flag both. 6. **Flag bad assumptions.** If the user's request rests on something shaky (a feature exists, a flow is named X, a stack is in use), check before invoking. 7. **Verify before asserting.** Don't claim files exist or commands ran without the CLI output to back it. If the CLI didn't run, say so. -8. **Counter-case on strategic choices.** Greenfield stack picks, AI mode (agent vs api), spec scope — surface the strongest opposing view without being asked. +8. **Counter-case on strategic choices.** Greenfield stack picks, spec scope, naming choices — surface the strongest opposing view without being asked. Don't label these principles when applying them ("rule 3 says..."). Just apply them. diff --git a/plugin/skills/draftwise/reference/explain.md b/plugin/skills/draftwise/reference/explain.md index 1985ee8..d68ea7c 100644 --- a/plugin/skills/draftwise/reference/explain.md +++ b/plugin/skills/draftwise/reference/explain.md @@ -1,4 +1,4 @@ -> **Traces a single flow through the codebase end-to-end.** Filters the scan to flow-keyword-relevant files so the model focuses on what matters. Saves a snapshot to `.draftwise/flows/.md`. Brownfield only — greenfield short-circuits. +> **Traces a single flow through the codebase end-to-end.** Filters the scan to flow-keyword-relevant files so the model focuses on what matters. The CLI prints scanner data plus an INSTRUCTION for you to write the walkthrough to `.draftwise/flows/.md`. Brownfield only — greenfield short-circuits. ## Pre-flight @@ -13,8 +13,6 @@ ## Reading the output -- **api mode** (streamed markdown ending with "Saved snapshot to .draftwise/flows/.md"): the CLI wrote the walkthrough. Confirm briefly, surface anything notable the trace called out (entry points, edge cases handled, side effects). - -- **agent mode** (SCANNER OUTPUT, FLOW: …, INSTRUCTION): generate the walkthrough yourself per the INSTRUCTION block, grounded only in the scanner data and the flow keyword. Save to `.draftwise/flows/.md`. Don't invent files, routes, or functions that aren't in the scanner output. +- **Agent handoff** (SCANNER OUTPUT, FLOW: …, INSTRUCTION): generate the walkthrough yourself per the INSTRUCTION block, grounded only in the scanner data and the flow keyword. Save to `.draftwise/flows/.md`. Don't invent files, routes, or functions that aren't in the scanner output. - **Greenfield short-circuit**: tell the user `explain` is brownfield-only and suggest they come back once code exists. diff --git a/plugin/skills/draftwise/reference/init.md b/plugin/skills/draftwise/reference/init.md index 938595f..faeed69 100644 --- a/plugin/skills/draftwise/reference/init.md +++ b/plugin/skills/draftwise/reference/init.md @@ -1,4 +1,4 @@ -> **Sets up `.draftwise/` for the user's project.** Detects whether the directory is empty (no code yet — internally "greenfield") or contains an existing codebase (internally "brownfield") by scanning for source files, then routes accordingly. Writes `config.yaml` (AI mode + project state), `overview.md` (placeholder for existing codebase, full plan for new project + api), and `scaffold.json` (new project only). Refuses to overwrite an existing `.draftwise/`. +> **Sets up `.draftwise/` for the user's project.** Detects whether the directory is empty (no code yet — internally "greenfield") or contains an existing codebase (internally "brownfield") by scanning for source files, then routes accordingly. Writes `config.yaml` (project state) and `overview.md` (placeholder for existing codebase, placeholder for new project that the host agent rewrites from the conversation). Refuses to overwrite an existing `.draftwise/`. ## Run @@ -8,9 +8,9 @@ ## Reading the output -- **`.draftwise/` was created** ("Created .draftwise/ with…" appears): the user supplied enough flags and init finished. Report what was created and the `Next:` step the CLI suggested. Done. +- **`.draftwise/` was created** ("Created .draftwise/ with…" appears): the user supplied enough flags (or none were needed for brownfield) and init finished. For greenfield, the CLI also printed a 3-phase INSTRUCTION block — follow it to walk the stack-selection conversation and rewrite `overview.md` plus `scaffold.json` for the user. For brownfield, just report what was created and the `Next:` step the CLI suggested. Done. -- **Structured handoff** (a block starting with "INIT — answer these in chat…"): the CLI is telling you exactly what's still missing. The first line of the handoff already announces the project state (auto-detected from the filesystem unless `--mode` was passed). Walk the user through the remaining listed questions, then re-invoke per the INSTRUCTION block. +- **Structured handoff** (a block starting with "INIT — answer in chat…"): the CLI is telling you exactly what's still missing (greenfield without `--idea` is the only case). The first line of the handoff announces the auto-detected project state. Walk the user through the listed question, then re-invoke per the INSTRUCTION block. - **Validation error** (`Invalid --mode value`, unknown flag, etc.): show the error verbatim and explain how to fix it. Don't try alternate flag spellings. @@ -22,19 +22,16 @@ The CLI checks the cwd for source files (using the same extension list and ignor If the detection looks wrong (e.g. the user is starting fresh in a folder that happens to have a leftover script, or vice versa), pass `--mode=greenfield` or `--mode=brownfield` on the re-invocation. Confirm with the user before overriding. -## How to ask the remaining questions (when the structured handoff fires) +## How to ask for the idea (greenfield only) -The CLI's questions are minimal. Add a sentence or two of context so the user can answer well. Specifically: +The CLI's only ask in non-TTY is the project idea. Add a sentence of context so the user can answer well: "A sentence or two on what you want to build — once init finishes I'll ask follow-up questions about stack and structure." If the user gives a one-word idea ("a blog", "a todo app"), ask for one more concrete sentence before invoking — the host-agent conversation goes much better with concrete input than with placeholder ideas. -- **AI mode (agent vs api)**: "Agent mode = your IDE's model (Claude Code, Cursor, etc.) handles the reasoning — no API key needed; you stay in chat. API mode = Draftwise calls a model directly with your API key — more control, scriptable, runs without a host agent." Pick `agent` if they're using Claude Code right now and don't already have an Anthropic API key set up. Pick `api` if they want the CLI to be self-sufficient. +## What to do after greenfield init finishes -- **Provider (api mode only)**: "Claude is the only provider fully wired today; OpenAI and Gemini adapters are stubs. Pick `claude` unless you specifically want one of the others as a placeholder." Default to claude. - -- **Idea (only when the project is greenfield)**: "A sentence or two on what you want to build — the model will ask follow-up questions about stack and structure once it has this." If the user gives a one-word idea ("a blog", "a todo app"), ask for one more concrete sentence before invoking — the CLI's plan call is much better with concrete input than with placeholder ideas. +The CLI's INSTRUCTION block tells you to walk the PM through three phases: (1) ask 4-6 clarifying questions about stack/structure, (2) propose 2-3 stack options with rationale/pros/cons/directory structure/setup commands, (3) write the chosen plan to `.draftwise/overview.md` plus `.draftwise/scaffold.json`. Follow the instruction's shape exactly — `scaffold.json` needs `stack`, `summary`, `directory_structure`, `initial_files`, and `setup_commands` fields so `draftwise scaffold` can use it later. ## What not to do - Don't invent flag values to skip asking the user. The handoff lists exactly what's missing for a reason. -- Don't write to `.draftwise/` directly. Re-invoke the CLI with the collected flags. -- Don't pick `--ai-mode=api` without confirming the user has the relevant API key in their environment — they'll hit a runtime error on the next verb otherwise. +- Don't write to `.draftwise/` directly when init's still running — re-invoke the CLI with the collected flags. Once init has emitted its handoff and the conversation is in your hands, you do write `overview.md` and `scaffold.json` per the INSTRUCTION. - Don't surface "greenfield" / "brownfield" in chat unless the user used those terms first. Use plain language: "new project (no code yet)" / "existing codebase". diff --git a/plugin/skills/draftwise/reference/new.md b/plugin/skills/draftwise/reference/new.md index 9e28419..c045bac 100644 --- a/plugin/skills/draftwise/reference/new.md +++ b/plugin/skills/draftwise/reference/new.md @@ -1,13 +1,13 @@ -> **Drafts a product spec for a feature idea.** Three phases driven by the CLI: (1) AI plans the conversation — clarifying questions tailored to the codebase or greenfield plan, plus affected flows and adjacent opportunities. (2) The user walks through questions and accepts/declines opportunities. (3) AI synthesizes `product-spec.md` under `.draftwise/specs//`. +> **Drafts a product spec for a feature idea.** The CLI prints scanner data (brownfield) or the greenfield plan plus a 3-phase INSTRUCTION for you to follow: (1) plan the conversation — clarifying questions tailored to the code/plan, plus affected flows and adjacent opportunities (brownfield only). (2) Walk the user through questions and accept/decline opportunities. (3) Write `product-spec.md` to `.draftwise/specs//`. The CLI never writes the spec — that's your job. ## Pre-flight Before invoking the CLI, do a quick sanity check: 1. **`.draftwise/` exists?** If not, point the user at `/draftwise init` and stop. The CLI will throw on missing `.draftwise/`, but catching it in chat is cleaner. -2. **Idea concreteness.** If the user's idea is one word ("auth", "search", "sharing") or under ~10 chars, ask them to elaborate ONCE before invoking. The CLI's plan call is materially better with concrete input. Don't drag — one ask, then proceed with whatever they give. +2. **Idea concreteness.** If the user's idea is one word ("auth", "search", "sharing") or under ~10 chars, ask them to elaborate ONCE before invoking. The synthesis goes much better with concrete input. Don't drag — one ask, then proceed with whatever they give. 3. **Brownfield staleness check** (optional). If `.draftwise/overview.md` is older than the most recent file in `src/` (or similar source dir), nudge once: "Your overview was generated before the latest code changes — `/draftwise scan` first for a more grounded spec." Don't block — proceed if the user wants to. -4. **Existing spec check.** Look at `/draftwise list` (or peek at `.draftwise/specs/`) — if a spec for the same area exists, surface it before drafting a new one. "Looks like you already have a `` spec — want to extend it (`--force` re-runs) or draft a separate feature?" +4. **Existing spec check.** Look at `/draftwise list` (or peek at `.draftwise/specs/`) — if a spec for the same area exists, surface it before drafting a new one. "Looks like you already have a `` spec — re-run to overwrite, or draft a separate feature?" Ask before clobbering hand-edits. ## Run @@ -17,12 +17,8 @@ Before invoking the CLI, do a quick sanity check: ## Reading the output -- **agent mode** (you see SCANNER OUTPUT or PROJECT PLAN, then IDEA, then a 3-phase INSTRUCTION block): the CLI handed you the conversation. Follow the INSTRUCTION exactly — walk the user through clarifying questions, accept/decline adjacent opportunities, then write `product-spec.md` yourself grounded in the scanner data (or project plan, for greenfield). Don't invent files. The CLI did NOT write the spec in this mode; that's your job. - -- **api mode, success** (streamed markdown ending with "Wrote .draftwise/specs//product-spec.md"): the CLI already wrote it interactively. Confirm and surface the `Next:` step (`/draftwise tech`). - -- **api mode + non-TTY without --answers** (the CLI logged "(non-interactive: no --answers supplied — questions left blank.)"): the model produced a leaner spec from its best guess. Walk the user through the clarifying questions yourself (the plan output above lists them), then re-invoke with `--answers '["a1", "a2"]' --force` for a richer spec. - -- **Existing spec** (error: "already exists. Pass --force"): ask before overwriting. Existing specs are usually hand-edited; clobbering loses real work. Re-invoke with `--force` only on confirmation. +- **Agent handoff** (SCANNER OUTPUT or PROJECT PLAN, IDEA, 3-phase INSTRUCTION block): follow the INSTRUCTION exactly. Walk the user through clarifying questions, accept/decline adjacent opportunities (brownfield only), then write `product-spec.md` yourself grounded in the scanner data (or project plan, for greenfield). Don't invent files. The CLI did NOT write the spec — that's your job. - **Missing idea** (error: "Missing idea"): ask what feature they want drafted, then re-invoke. + +- **Greenfield without overview** (error: "overview.md is missing or empty"): the user ran `init` in greenfield mode but the agent (you, on a previous turn) didn't write the plan. Either re-run `/draftwise init --mode=greenfield --idea="…"` and follow its INSTRUCTION this time, or write `overview.md` manually with the chosen stack + structure. diff --git a/plugin/skills/draftwise/reference/scaffold.md b/plugin/skills/draftwise/reference/scaffold.md index b1e9d6e..30adfc1 100644 --- a/plugin/skills/draftwise/reference/scaffold.md +++ b/plugin/skills/draftwise/reference/scaffold.md @@ -2,7 +2,7 @@ ## Pre-flight -- **`.draftwise/scaffold.json` exists?** Greenfield + api-mode init writes this. Greenfield + agent-mode init expects the host agent to write it; if it's missing, suggest the user re-run init or write `scaffold.json` manually from the conversation. +- **`.draftwise/scaffold.json` exists?** Greenfield init's INSTRUCTION tells the host agent to write this from the stack-selection conversation. If it's missing, the agent didn't complete the handoff — suggest the user re-run `/draftwise init` (and follow the INSTRUCTION this time) or write `scaffold.json` manually. - **Brownfield project?** Scaffold short-circuits with a friendly hint — let the CLI handle this, but warn the user upfront if you can tell. - **Scaffolders run first?** If the plan's `setup_commands` includes something like `npx create-next-app .` or `npm init`, those should run BEFORE scaffold. Scaffold won't overwrite existing files but it may interfere with a fresh scaffolder run that wants an empty directory. Ask the user if they've run their setup commands first; if not, suggest doing those before scaffold. @@ -20,6 +20,6 @@ After confirming readiness: - **Brownfield short-circuit** ("scaffold is greenfield-only…"): tell the user this command only applies to greenfield projects. -- **Missing scaffold.json**: the greenfield plan wasn't fully written. Suggest re-running `/draftwise init` in greenfield mode (or writing `.draftwise/scaffold.json` manually if init was run in agent mode and the host agent didn't write it). +- **Missing scaffold.json**: the greenfield plan wasn't fully written. Suggest re-running `/draftwise init` in greenfield mode (and following the INSTRUCTION this time to write both `overview.md` and `scaffold.json`), or writing `.draftwise/scaffold.json` manually. - **Path-traversal block** ("blocked (escapes project root)"): a file path in scaffold.json tried to escape the project root. Show the user which paths were blocked and suggest reviewing `.draftwise/scaffold.json`. diff --git a/plugin/skills/draftwise/reference/scan.md b/plugin/skills/draftwise/reference/scan.md index 8f8e966..7324b00 100644 --- a/plugin/skills/draftwise/reference/scan.md +++ b/plugin/skills/draftwise/reference/scan.md @@ -1,4 +1,4 @@ -> **Refreshes the structured codebase overview.** Runs the scanner and either writes a narrated `overview.md` (api mode) or hands the structured scanner data to you with an INSTRUCTION to write it (agent mode). Brownfield only — greenfield short-circuits with a friendly hint, since the plan from `init` already lives in `overview.md`. +> **Refreshes the structured codebase overview.** Runs the scanner and prints the structured data plus an INSTRUCTION for you to write the narrated `overview.md`. Brownfield only — greenfield short-circuits with a friendly hint, since the plan from `init` already lives in `overview.md`. ## Pre-flight @@ -13,9 +13,7 @@ ## Reading the output -- **api mode** (streamed markdown ending with "Wrote .draftwise/overview.md"): the CLI wrote it. Confirm briefly, surface any scanner warnings (truncation, missing-framework hint), and the `Next:` step. - -- **agent mode** (SCANNER OUTPUT and INSTRUCTION block): write `.draftwise/overview.md` per the INSTRUCTION, grounded only in the scanner data shown. Don't invent files, routes, or models that aren't there. Acknowledge the scanner's coverage (e.g. "scanner detected Next.js + Prisma; X routes, Y components, Z models") so the user trusts the basis. +- **Agent handoff** (SCANNER OUTPUT and INSTRUCTION block): write `.draftwise/overview.md` per the INSTRUCTION, grounded only in the scanner data shown. Don't invent files, routes, or models that aren't there. Acknowledge the scanner's coverage (e.g. "scanner detected Next.js + Prisma; X routes, Y components, Z models") so the user trusts the basis. - **Greenfield short-circuit** ("No code yet — `draftwise scan` works on existing codebases…"): tell the user this is brownfield-only and suggest `/draftwise new ""` instead — that's the next step in greenfield. diff --git a/plugin/skills/draftwise/reference/tasks.md b/plugin/skills/draftwise/reference/tasks.md index cbd3fcd..5d03be1 100644 --- a/plugin/skills/draftwise/reference/tasks.md +++ b/plugin/skills/draftwise/reference/tasks.md @@ -1,9 +1,10 @@ -> **Generates ordered implementation tasks from an approved technical spec.** Each task: Goal / Files / Depends on / Parallel with / Acceptance, ordered so dependencies appear before dependents. Greenfield: the first 1-3 tasks are project scaffolding (run setup commands, install deps). +> **Generates ordered implementation tasks from an approved technical spec.** The CLI prints the technical spec plus scanner data (brownfield) or project plan (greenfield) and an INSTRUCTION for you to write `tasks.md`. Each task: Goal / Files / Depends on / Parallel with / Acceptance, ordered so dependencies appear before dependents. Greenfield: the first 1-3 tasks are project scaffolding (run setup commands, install deps). ## Pre-flight - **A technical spec exists for this feature?** If no `technical-spec.md` for the slug, point at `/draftwise tech` first. Tasks ground in the tech spec — without one, there's nothing to break down. - **Has the user reviewed the tech spec?** Tasks are only as good as the tech spec they're derived from. If the user hasn't opened `technical-spec.md` since it was generated, suggest a skim — task ordering and granularity inherit from the tech spec's structure. +- **Existing tasks.md?** If `tasks.md` already exists for the slug, ask before clobbering — tasks are sometimes hand-edited as work progresses (re-ordered, broken down further). The CLI doesn't guard against this; the conversation has to. ## Run @@ -13,12 +14,8 @@ ## Reading the output -- **agent mode** (TECHNICAL SPEC, SCANNER OUTPUT or PROJECT PLAN, INSTRUCTION): write `.draftwise/specs//tasks.md` per the INSTRUCTION block. Numbered tasks with the five fields, dependency-ordered. Greenfield: open with 1-3 scaffolding tasks (run setup commands, install deps, configure env). - -- **api mode success** (streamed markdown ending with "Wrote .draftwise/specs//tasks.md"): the CLI wrote it. Tell the user the next step is to pick the first task with no dependencies and start shipping. +- **Agent handoff** (TECHNICAL SPEC, SCANNER OUTPUT or PROJECT PLAN, INSTRUCTION): write `.draftwise/specs//tasks.md` per the INSTRUCTION block. Numbered tasks with the five fields, dependency-ordered. Greenfield: open with 1-3 scaffolding tasks (run setup commands, install deps, configure env). - **Multiple specs, no slug given**: show available slugs, ask which one, re-invoke. -- **Existing tasks.md** (error: "already exists. Pass --force"): ask before overwriting. Tasks are sometimes hand-edited as work progresses (re-ordered, broken down further); clobbering loses that. - - **No tech specs yet**: point at `/draftwise tech`. diff --git a/plugin/skills/draftwise/reference/tech.md b/plugin/skills/draftwise/reference/tech.md index 6d942b8..ddf584c 100644 --- a/plugin/skills/draftwise/reference/tech.md +++ b/plugin/skills/draftwise/reference/tech.md @@ -1,9 +1,10 @@ -> **Drafts a technical spec from an approved product spec, grounded in the real codebase or greenfield plan.** Brownfield: every cited file must come from the scanner output. Greenfield: every cited file is marked `(new)` and follows the planned directory structure. +> **Drafts a technical spec from an approved product spec, grounded in the real codebase or greenfield plan.** The CLI prints the product spec, scanner data (brownfield) or project plan (greenfield), plus an INSTRUCTION for you to write `technical-spec.md`. Brownfield: every cited file must come from the scanner output. Greenfield: every cited file is marked `(new)` and follows the planned directory structure. ## Pre-flight - **A product spec exists for this feature?** Run `/draftwise list` mentally (or peek at `.draftwise/specs/`) — if no `product-spec.md` exists for the slug the user wants, point at `/draftwise new ""` first. The tech spec grounds in the product spec; without one, there's nothing to ground in. - **Has the user reviewed the product spec?** A tech spec is only as good as its product spec. If the user hasn't opened `.draftwise/specs//product-spec.md` since it was generated, suggest a quick skim before invoking — drift between intent and tech is the most common reason tech specs get rewritten. +- **Existing tech spec?** If `technical-spec.md` already exists for the slug, ask the user before clobbering it. The CLI doesn't guard against this (it doesn't write the spec at all), so the conversation has to. ## Run @@ -13,12 +14,8 @@ ## Reading the output -- **agent mode** (PRODUCT SPEC, SCANNER OUTPUT or PROJECT PLAN, INSTRUCTION): write `.draftwise/specs//technical-spec.md` per the INSTRUCTION block. Brownfield: every cited file must come from the scanner output. Greenfield: mark every cited file `(new)` and follow the planned directory structure from `overview.md`. - -- **api mode success** (streamed markdown ending with "Wrote .draftwise/specs//technical-spec.md"): the CLI wrote it. Confirm and surface the `Next:` step (`/draftwise tasks`). +- **Agent handoff** (PRODUCT SPEC, SCANNER OUTPUT or PROJECT PLAN, INSTRUCTION): write `.draftwise/specs//technical-spec.md` per the INSTRUCTION block. Brownfield: every cited file must come from the scanner output. Greenfield: mark every cited file `(new)` and follow the planned directory structure from `overview.md`. - **Multiple specs, no slug given** (error: "Multiple product specs exist…"): show the available slugs and ask which one. Re-invoke `!`draftwise tech ``. -- **Existing tech spec** (error: "already exists. Pass --force"): ask before overwriting. Existing tech specs are often hand-edited with engineering context; clobbering loses real work. - - **No product specs yet** (error: "No product specs found"): point at `/draftwise new ""`. diff --git a/src/ai/prompts/explain.js b/src/ai/prompts/explain.js index bd41223..69ef7a5 100644 --- a/src/ai/prompts/explain.js +++ b/src/ai/prompts/explain.js @@ -1,54 +1,26 @@ -export const SYSTEM = `You are Draftwise, a codebase-aware tool that traces flows through real code. - -Your job in this turn is to walk through how a single flow works in the codebase the user just scanned. The output is read by PMs and engineers who need to understand what already exists before deciding what to change. Be specific and grounded. - -Hard rules: -- Reference real file paths, real route paths, real function names from the scanner output. Do NOT invent. -- If the scanner output doesn't contain enough information to trace the flow confidently, say so explicitly under "Gaps". Don't fabricate steps. -- Keep the walkthrough tight. Engineers stop reading verbose docs. -- Output valid markdown only. No preamble like "Here is the trace" — start directly with the document. -`; - -export function buildPrompt({ flow, scan, packageMeta }) { - const parts = [ - `Trace how the "${flow}" flow works in this codebase. Use exactly these top-level sections, in order:`, - '', - `# Flow: ${flow}`, - '> One-sentence summary of what this flow does, inferred from the code.', - '', - '## Entry points', - 'How does this flow start? List routes, UI components, scheduled jobs, or webhooks that trigger it. For each: file path, and what triggers it.', - '', - '## Walkthrough', - 'Step-by-step trace. Each step is one numbered line: what happens, with the file path and the function/handler name when known. 5-12 steps is the sweet spot.', - '', - '## Data read & written', - 'Bullet list of the models/tables touched. For each: read or write, and what fields are involved if known.', - '', - '## Side effects', - 'Bullet list of things this flow causes outside the request/response: emails, webhooks, async jobs, third-party API calls, cache invalidation, analytics events. Skip if none.', - '', - '## Edge cases handled', - "Bullet list of edge cases the code visibly handles (auth failures, validation, rate limits, race conditions, etc.). If you can't see explicit handling, say so.", - '', - '## Gaps', - 'What you could NOT determine from the scanner output — files not parsed, services that look like they exist but the scanner missed, etc. Be honest.', - '', - '---', - '', - 'Scanner output (structured JSON):', - '```json', - JSON.stringify(scan, null, 2), - '```', - '', - 'Package metadata:', - '```json', - JSON.stringify(packageMeta, null, 2), - '```', - ]; - return parts.join('\n'); -} - export function buildAgentInstruction(flow, slug) { - return `The scanner data above describes a real codebase. The user wants to understand how the "${flow}" flow works. Generate a markdown walkthrough following the section structure shown, grounded only in what the scanner produced. Print the walkthrough to the user, then save it to .draftwise/flows/${slug}.md (create the flows directory if it doesn't exist). Do not invent files, routes, or functions that aren't in the scanner output.`; + return `The scanner data above describes a real codebase. The user wants to understand how the "${flow}" flow works. Generate a markdown walkthrough following this section structure, grounded only in what the scanner produced: + +# Flow: ${flow} +> One-sentence summary of what this flow does, inferred from the code. + +## Entry points +How does this flow start? List routes, UI components, scheduled jobs, or webhooks that trigger it. For each: file path, and what triggers it. + +## Walkthrough +Step-by-step trace. Each step is one numbered line: what happens, with the file path and the function/handler name when known. 5-12 steps is the sweet spot. + +## Data read & written +Bullet list of the models/tables touched. For each: read or write, and what fields are involved if known. + +## Side effects +Bullet list of things this flow causes outside the request/response: emails, webhooks, async jobs, third-party API calls, cache invalidation, analytics events. Skip if none. + +## Edge cases handled +Bullet list of edge cases the code visibly handles (auth failures, validation, rate limits, race conditions, etc.). If you can't see explicit handling, say so. + +## Gaps +What you could NOT determine from the scanner output — files not parsed, services that look like they exist but the scanner missed, etc. Be honest. + +Print the walkthrough to the user, then save it to .draftwise/flows/${slug}.md (create the flows directory if it doesn't exist). Do not invent files, routes, or functions that aren't in the scanner output.`; } diff --git a/src/ai/prompts/greenfield.js b/src/ai/prompts/greenfield.js index e7a35d9..75d5615 100644 --- a/src/ai/prompts/greenfield.js +++ b/src/ai/prompts/greenfield.js @@ -1,192 +1,3 @@ -import { CORE_PRINCIPLES } from './principles.js'; -import { extractJsonFromFence } from '../../utils/json-fence.js'; - -export const QUESTIONS_SYSTEM = `You are Draftwise, helping a PM start a greenfield project from scratch. They've described what they want to build but haven't picked a stack or written any code yet. - -${CORE_PRINCIPLES} - -Your job in this turn is NOT to recommend a stack. Your job is to ask the clarifying questions that will lead to a good stack recommendation. Different ideas need different questions — generate questions that are specifically useful for picking the stack and shape of THIS project. - -Return ONE JSON object inside a single fenced \`\`\`json block. The shape: - -{ - "project_title": "short, human-readable name derived from the idea (3-5 words max)", - "questions": [ - { "text": "the question, phrased the way you'd ask a PM", - "why": "what stack/structure decision this question informs" } - ] -} - -Hard rules: -- 4-6 questions total. Specific to the idea, not boilerplate. Bad: "What's your timeline?". Good (for a recipe-sharing app): "Will recipes be private to the user, shared with friends, or fully public — this affects whether you need an auth provider with social/sharing primitives?". -- The "why" line must connect each question to a specific stack/structure choice — frontend framework, backend approach, database, hosting, auth, etc. -- ASK, DO NOT ASSUME. If the idea is ambiguous in any way that affects stack choice, that's a question. -- Output JSON only. No prose around the fenced block. -`; - -export function buildQuestionsPrompt(idea) { - return [ - `PM's idea: "${idea}"`, - '', - 'Generate the clarifying questions per the system instructions.', - ].join('\n'); -} - -export function parseQuestionsResponse(text) { - const raw = extractJsonFromFence(text); - let parsed; - try { - parsed = JSON.parse(raw); - } catch (err) { - throw new Error( - `Could not parse the clarifying questions from the model response. ${err.message}\n\nResponse was:\n${text.slice(0, 500)}`, - { cause: err }, - ); - } - if (!Array.isArray(parsed.questions) || parsed.questions.length === 0) { - throw new Error('Model response is missing the questions array.'); - } - return { - projectTitle: parsed.project_title ?? 'New project', - questions: parsed.questions, - }; -} - -export const STACKS_SYSTEM = `You are Draftwise, helping a PM pick a tech stack for a greenfield project. You have the PM's idea and their answers to clarifying questions. Now propose 2-3 stack options the PM can choose between. - -${CORE_PRINCIPLES} - -Return ONE JSON object inside a single fenced \`\`\`json block. The shape: - -{ - "stack_options": [ - { - "name": "short, recognizable name (e.g. 'Next.js + Postgres + Prisma')", - "summary": "one sentence on what this stack is and why it fits this idea", - "rationale": "2-3 sentences on why this stack matches the answers above — be specific to the PM's constraints", - "pros": ["3-5 concrete advantages, each one sentence"], - "cons": ["2-4 honest tradeoffs or risks, each one sentence"], - "directory_structure": "a markdown code block showing the proposed top-level + key inner directories (project root included). Use a tree style with --- or unicode box characters", - "initial_files": [ - { "path": "relative path", "purpose": "what this file is for, one sentence" } - ], - "setup_commands": ["array of shell commands the PM can run to scaffold this stack, in order"] - } - ] -} - -Hard rules: -- 2-3 options, no fewer, no more. They should be meaningfully different — don't propose three flavors of the same thing. -- Each option must be a complete answer (frontend + backend + data + hosting if relevant), not just a framework. -- "pros" and "cons" must be specific to this project's constraints, not generic feature lists. -- "directory_structure" must be realistic — show the actual scaffolding the PM will see after running the setup commands, not an idealized layout. Cap at ~25 lines. -- "initial_files" lists 4-8 files the PM should create or focus on first (config files, root pages, key models/routes — not every file in the scaffold). Mark as "(scaffold creates)" if it comes from the scaffolding command, "(write yourself)" otherwise. -- "setup_commands" should be runnable as-is, in order. -- Output JSON only. No prose around the fenced block. -`; - -export function buildStacksPrompt({ idea, projectTitle, questions, answers }) { - const qa = questions.map((q, i) => ({ - question: q.text, - answer: answers[i] ?? '', - })); - return [ - `PM's idea: "${idea}"`, - `Project title: ${projectTitle}`, - '', - 'Answers to clarifying questions:', - '```json', - JSON.stringify(qa, null, 2), - '```', - '', - 'Propose 2-3 stack options per the system instructions.', - ].join('\n'); -} - -export function parseStacksResponse(text) { - const raw = extractJsonFromFence(text); - let parsed; - try { - parsed = JSON.parse(raw); - } catch (err) { - throw new Error( - `Could not parse the stack options from the model response. ${err.message}\n\nResponse was:\n${text.slice(0, 500)}`, - { cause: err }, - ); - } - if (!Array.isArray(parsed.stack_options) || parsed.stack_options.length === 0) { - throw new Error('Model response is missing the stack_options array.'); - } - for (const opt of parsed.stack_options) { - if (!opt.name || !opt.summary) { - throw new Error('A stack option is missing name or summary.'); - } - } - return parsed.stack_options; -} - -export function buildOverviewMarkdown({ projectTitle, idea, questions, answers, chosen }) { - const qa = questions - .map((q, i) => `**${q.text}**\n${answers[i]?.trim() || '_(skipped)_'}`) - .join('\n\n'); - const pros = (chosen.pros ?? []).map((p) => `- ${p}`).join('\n'); - const cons = (chosen.cons ?? []).map((c) => `- ${c}`).join('\n'); - const initialFiles = (chosen.initial_files ?? []) - .map((f) => `- \`${f.path}\` — ${f.purpose}`) - .join('\n'); - const setup = (chosen.setup_commands ?? []) - .map((c) => `\`\`\`bash\n${c}\n\`\`\``) - .join('\n'); - - return `# ${projectTitle} — Greenfield plan - -> ${idea} - -_This is a greenfield plan written before any code exists. Once you've scaffolded the project and written some code, run \`draftwise scan\` to replace this with a codebase-grounded overview._ - -## Idea - -${idea} - -## Discovery - -${qa} - -## Chosen stack: ${chosen.name} - -${chosen.summary} - -**Why this fits:** ${chosen.rationale} - -### Pros -${pros} - -### Cons -${cons} - -## Directory structure - -${chosen.directory_structure} - -## Initial files - -${initialFiles} - -## Setup - -Run these commands from your project root, in order: - -${setup} - -## Next steps - -1. Run the setup commands above to scaffold the project. -2. Create the initial files listed. -3. Once you have some code on disk, \`draftwise scan\` will refresh this overview from the actual codebase. -4. \`draftwise new ""\` to draft your first feature spec. -`; -} - export function buildAgentInstruction(idea) { return `The PM has proposed a greenfield project: "${idea}". diff --git a/src/ai/prompts/new.js b/src/ai/prompts/new.js index 05277dc..ede5939 100644 --- a/src/ai/prompts/new.js +++ b/src/ai/prompts/new.js @@ -1,318 +1,3 @@ -import { CORE_PRINCIPLES } from './principles.js'; -import { SPEC_LANGUAGE_RULES } from './spec-quality.js'; -import { extractJsonFromFence } from '../../utils/json-fence.js'; - -export const PLAN_SYSTEM_BROWNFIELD = `You are Draftwise, a codebase-aware product spec drafting tool. - -${CORE_PRINCIPLES} - -A PM has proposed a new feature for a real codebase. Your job in this turn is NOT to write the spec yet. Your job is to plan the conversation that will lead to a good spec. - -You will receive: the PM's idea, the structured scanner output for the existing codebase. You will return ONE JSON object — no preamble, no prose around it, just the JSON inside a single fenced \`\`\`json block. - -The JSON has exactly this shape: - -{ - "feature_slug": "kebab-case-feature-name (3-5 words max, derived from the idea)", - "feature_title": "human-readable title for the feature", - "affected_flows": [ - { "name": "flow name (use existing names from the scanner if applicable)", - "files": ["concrete file paths from the scanner"], - "impact": "one sentence describing how this feature changes this flow" } - ], - "clarifying_questions": [ - { "text": "the question, phrased the way you'd ask a PM", - "why": "what gap in your understanding this question fills — be specific to the codebase" } - ], - "adjacent_opportunities": [ - { "flow": "name of an adjacent flow", - "suggestion": "what change to that flow could / should land alongside this feature", - "rationale": "why this matters — usually surfaces an edge case that would otherwise leak into review" } - ] -} - -Hard rules: -- ASK, DO NOT ASSUME. If something is unclear (target user, success metric, integration point, error handling, permissioning, payment, notifications), turn it into a clarifying_question. Never invent details to fill gaps. -- Ground every affected_flow in real files from the scanner. If you can't see the affected flow in the scanner output, say so via a clarifying_question instead. -- 4-8 clarifying questions. Specific to the codebase, not generic. Bad: "Who is the target user?". Good: "The scanner shows two user roles in src/auth/roles.ts — admin and member. Which of these (or a new role) gets access to this feature?". -- 2-5 adjacent_opportunities. Each must point at a real existing flow the scanner detected. Skip the section if there are genuinely none. -- Output JSON only. No markdown around it except the single fenced block. -`; - -export const PLAN_SYSTEM_GREENFIELD = `You are Draftwise, a product spec drafting tool. The PM is scoping a feature for a GREENFIELD project — there's no existing code yet. They've already chosen a stack and a directory plan, captured in overview.md. - -${CORE_PRINCIPLES} - -Your job in this turn is NOT to write the spec yet. Your job is to plan the conversation by generating clarifying questions tailored to this feature on top of the chosen plan. - -Return ONE JSON object inside a single fenced \`\`\`json block: - -{ - "feature_slug": "kebab-case (3-5 words)", - "feature_title": "human-readable title", - "clarifying_questions": [ - { "text": "the question", - "why": "what gap this fills — be specific to the project plan" } - ] -} - -Hard rules: -- ASK, DO NOT ASSUME. 4-8 clarifying questions specific to this feature on top of the chosen stack: user behavior, edge cases, integration points within the planned structure, scope boundaries, success criteria. -- Don't ask stack-selection questions — that decision is already made (see overview.md). -- Don't generate affected_flows or adjacent_opportunities — there are no existing flows yet. If integration with the planned structure matters, surface it as a clarifying question. -- Output JSON only. No markdown around the fenced block. -`; - -export function selectPlanSystem(projectState) { - return projectState === 'greenfield' - ? PLAN_SYSTEM_GREENFIELD - : PLAN_SYSTEM_BROWNFIELD; -} - -export function buildPlanPrompt({ idea, scan, packageMeta, projectState, overview }) { - if (projectState === 'greenfield') { - return [ - `PM's idea: "${idea}"`, - '', - 'Project plan (overview.md — chosen stack, directory structure, setup):', - overview && overview.trim() - ? overview - : '_(no overview.md found; treat the idea as the only context)_', - '', - 'Generate clarifying questions per the system instructions.', - ].join('\n'); - } - return [ - `PM's idea: "${idea}"`, - '', - 'Codebase scanner output (structured):', - '```json', - JSON.stringify(scan, null, 2), - '```', - '', - 'Package metadata:', - '```json', - JSON.stringify(packageMeta, null, 2), - '```', - '', - 'Return the conversation plan as JSON inside a single fenced ```json block, per the system instructions.', - ].join('\n'); -} - -export function parsePlanResponse(text) { - const raw = extractJsonFromFence(text); - let parsed; - try { - parsed = JSON.parse(raw); - } catch (err) { - throw new Error( - `Could not parse the plan from the model response. ${err.message}\n\nResponse was:\n${text.slice(0, 500)}`, - { cause: err }, - ); - } - if (!parsed.feature_slug || !Array.isArray(parsed.clarifying_questions)) { - throw new Error( - 'Model response is missing required fields (feature_slug or clarifying_questions).', - ); - } - return { - featureSlug: parsed.feature_slug, - featureTitle: parsed.feature_title ?? parsed.feature_slug, - affectedFlows: parsed.affected_flows ?? [], - clarifyingQuestions: parsed.clarifying_questions, - adjacentOpportunities: parsed.adjacent_opportunities ?? [], - }; -} - -export const SPEC_SYSTEM_BROWNFIELD = `You are Draftwise, a codebase-aware product spec drafting tool. - -${CORE_PRINCIPLES} - -${SPEC_LANGUAGE_RULES} - -You have the PM's idea, the codebase scanner output, the PM's answers to clarifying questions, and the PM's accept/decline decisions on adjacent flow opportunities. Your job in this turn is to write the final product-spec.md. - -The spec is a markdown document with these sections, in order: - -# - -> One-sentence description of what this feature is. - -## Problem -What's broken or missing today, with concrete evidence from the codebase or the PM's answers. No generic language. - -## Users -Who this is for, drawn from the PM's answer about target users. Reference real user roles from the codebase if applicable. - -## User stories -3-7 stories in "As a , I want , so that " form. - -## Acceptance criteria -Given/when/then bullets. Concrete, testable. - -## Affected flows -For each flow this feature touches: name, files, what changes. Pull this directly from the affected_flows in the plan, refined by the PM's answers. - -## Adjacent changes -For each adjacent_opportunity the PM accepted: include it here as a sibling change with rationale. Skip declined ones. - -## Edge cases -Edge cases the PM surfaced in answers, plus any that fall out of the affected_flows analysis. Each one: what could go wrong, what should happen. - -## Test cases -Product-level scenarios (not unit tests). Happy path + each edge case. - -## Scope -Four sub-bullets: -- **Covered:** what this spec includes -- **Assumed:** what we're taking as given -- **Hypothesized:** what we believe but haven't proven -- **Out of scope:** what this spec explicitly excludes - -## Core metrics -What success looks like. Measurable. - -## Counter metrics -What could go wrong if we succeed too hard. - -Hard rules: -- Reference real files, real routes, real models from the scanner. No inventing. -- If a section has no content (e.g. no adjacent changes accepted), keep the heading but write "_None._" — don't fabricate. -- Output the markdown only. No preamble. Start with the title. -`; - -export const SPEC_SYSTEM_GREENFIELD = `You are Draftwise. The PM is scoping a feature for a GREENFIELD project. You have the idea, the project plan (overview.md — chosen stack, directory structure), and answers to clarifying questions. Write product-spec.md. - -${CORE_PRINCIPLES} - -${SPEC_LANGUAGE_RULES} - -The spec has these sections, in order: - -# - -> One-sentence description. - -## Problem -Concrete; cite what the PM said in answers. No generic prose. - -## Users -Who this is for, drawn from answers. - -## User stories -3-7 stories in "As a , I want , so that " form. - -## Acceptance criteria -Given/when/then bullets. Concrete, testable. - -## Edge cases -What could go wrong, what should happen. Anchor in the answers; don't invent. - -## Test cases -Product-level scenarios. Happy path + each edge case. - -## Scope -Four sub-bullets: -- **Covered:** what this spec includes -- **Assumed:** what we're taking as given -- **Hypothesized:** what we believe but haven't proven -- **Out of scope:** what this spec explicitly excludes - -## Core metrics -What success looks like. Measurable. - -## Counter metrics -What could go wrong if we succeed too hard. - -Hard rules: -- Greenfield project — there are no existing flows or files. Don't include "Affected flows" or "Adjacent changes" sections; they don't apply. -- If something feels like an integration with the planned structure, mention the planned file/component (from overview.md) but mark it forward-looking. -- If a section has no content, write "_None._" — don't fabricate. -- Output the markdown only. No preamble. Start with the title. -`; - -export function selectSpecSystem(projectState) { - return projectState === 'greenfield' - ? SPEC_SYSTEM_GREENFIELD - : SPEC_SYSTEM_BROWNFIELD; -} - -export function buildSpecPrompt({ - idea, - plan, - scan, - packageMeta, - answers, - opportunityDecisions, - projectState, - overview, -}) { - const qa = plan.clarifyingQuestions.map((q, i) => ({ - question: q.text, - answer: answers[i] ?? '', - })); - - if (projectState === 'greenfield') { - return [ - `PM's idea: "${idea}"`, - `Feature slug: ${plan.featureSlug}`, - `Feature title: ${plan.featureTitle}`, - '', - 'Project plan (overview.md):', - overview && overview.trim() - ? overview - : '_(no overview.md found)_', - '', - "PM's answers to clarifying questions:", - '```json', - JSON.stringify(qa, null, 2), - '```', - '', - 'Write the full product-spec.md per the system instructions.', - ].join('\n'); - } - - const opportunities = plan.adjacentOpportunities.map((o, i) => ({ - flow: o.flow, - suggestion: o.suggestion, - rationale: o.rationale, - decision: opportunityDecisions[i] ?? 'declined', - })); - - return [ - `PM's idea: "${idea}"`, - `Feature slug: ${plan.featureSlug}`, - `Feature title: ${plan.featureTitle}`, - '', - 'Affected flows (from scanner + plan):', - '```json', - JSON.stringify(plan.affectedFlows, null, 2), - '```', - '', - "PM's answers to clarifying questions:", - '```json', - JSON.stringify(qa, null, 2), - '```', - '', - "PM's decisions on adjacent opportunities:", - '```json', - JSON.stringify(opportunities, null, 2), - '```', - '', - 'Codebase scanner output (for grounding references):', - '```json', - JSON.stringify(scan, null, 2), - '```', - '', - 'Package metadata:', - '```json', - JSON.stringify(packageMeta, null, 2), - '```', - '', - 'Write the full product-spec.md per the system instructions.', - ].join('\n'); -} - export function buildAgentInstruction(idea, projectState = 'brownfield') { if (projectState === 'greenfield') { return `The PM has proposed a feature for a GREENFIELD project: "${idea}". diff --git a/src/ai/prompts/principles.js b/src/ai/prompts/principles.js deleted file mode 100644 index 784fef7..0000000 --- a/src/ai/prompts/principles.js +++ /dev/null @@ -1,23 +0,0 @@ -// Shared collaboration principles. Every prompt that drives an interactive -// conversation or drafts an artifact for the user pulls these in via -// `${CORE_PRINCIPLES}` so the same standards apply everywhere. -// -// Single source of truth — change behavior here, not in each prompt. - -export const CORE_PRINCIPLES = `## How you work with the user (applies across every reply) - -1. **No filler.** Don't open with "great question", "nice approach", "I love this", or any acknowledgment of the user's input. Start with substance. - -2. **Redirect drift.** If the user wanders from the stated problem (rat-holing, scope creep), call it out and redirect. If a requirement's connection to the problem isn't clear, ask *why* before incorporating it. - -3. **Push back on weak ideas; don't validate them.** If a proposal is half-baked, carries architectural debt, or skips important concerns (auth, data integrity, error paths, edge cases, performance, observability), surface that before producing the artifact. When you disagree with the user, say so plainly and explain the better path — don't repackage a weak idea back at them dressed up as agreement. Validation feels supportive but produces worse output. - -4. **Extend before adding.** Before proposing a new file, route, model, or component, check the scanner output (brownfield) or the planned directory structure (greenfield) for something that should be extended instead. "Add new X" requires a stated reason; "extend X" is the default. - -5. **Right over easy.** When a quick fix and a robust fix both exist, propose the robust one — or explicitly mark the quick fix as a shortcut and explain why. Don't slap together; don't overengineer either. - -6. **Flag bad assumptions before fulfilling.** If a request rests on a shaky premise (assumed user behavior, assumed scale, assumed integration, assumed permissioning), name the premise and say why it's shaky *before* you start drafting. Don't produce confident content on top of an unchecked assumption. - -7. **Verify before you assert.** Ground specific claims in scanner output, planned structure, or the user's stated answers. If you can't verify a claim, mark it uncertain ("hypothesized:" / "assumed:") or say "I don't know" — don't fabricate fluency over uncertainty. - -8. **Offer the counter-case proactively, on decisions that matter.** For strategic or non-trivial design choices (stack selection, schema shape, auth model, what's in scope for v1, dependency boundaries), surface the strongest opposing argument without being asked. Skip this for routine wording or section ordering — counter-cases are for choices that have real downstream cost if wrong.`; diff --git a/src/ai/prompts/scan.js b/src/ai/prompts/scan.js index f018d95..45ad47d 100644 --- a/src/ai/prompts/scan.js +++ b/src/ai/prompts/scan.js @@ -1,55 +1,27 @@ -export const SYSTEM = `You are Draftwise, a codebase-aware product spec drafting tool. - -Your job in this turn is to read structured scanner output from a real codebase and produce a single overview.md document. The overview is the team's mental model of the product as it exists today — read by PMs, engineers, and new hires. - -Hard rules: -- Be concrete. Reference real file paths, real route paths, real model names from the scanner output. Do NOT invent. -- If the scanner data is sparse or ambiguous, say so plainly. Don't paper over gaps with generic prose. -- Keep prose tight. PMs read this; verbosity erodes trust. -- Output valid markdown only. No preamble like "Here is the overview" — start directly with the document. -`; - -export function buildPrompt({ scan, packageMeta }) { - const parts = [ - 'Generate an overview.md for this codebase. Use exactly these top-level sections, in order:', - '', - '# (use the package name as a starting point)', - '> One-sentence description of what this product appears to do, inferred from the codebase.', - '', - '## What this codebase is', - 'A short paragraph (3-5 sentences) framing the product in plain language. Cite the framework(s) detected.', - '', - '## Major flows', - 'Bullet list of the top 5-8 user-facing flows you can infer from routes + components. For each: name, one-line description, and the entry-point file(s).', - '', - '## API surface', - 'Table or list of the routes/endpoints detected. For each: method, path, file. Group by area if natural.', - '', - '## Data model', - 'Bullet list of the data models/tables detected. For each: name, file, and 2-3 of its most important fields/relationships.', - '', - '## Components', - 'Bullet list of the most important UI components (cap at 15). Group by directory.', - '', - '## Integrations & external dependencies', - 'Bullet list of notable third-party dependencies inferred from package.json that hint at integrations (auth providers, payments, AI, queues, observability, etc.). Skip generic build tooling.', - '', - '## Gaps in this overview', - 'A short section listing what the scanner could NOT determine — e.g. flows the scanner missed, models not yet parsed, etc. Be honest.', - '', - '---', - '', - 'Scanner output (structured JSON):', - '```json', - JSON.stringify(scan, null, 2), - '```', - '', - 'Package metadata:', - '```json', - JSON.stringify(packageMeta, null, 2), - '```', - ]; - return parts.join('\n'); -} - -export const AGENT_INSTRUCTION = `The scanner data above describes a real codebase. Generate an overview.md following the section structure shown, grounded only in what the scanner produced. Write the file to .draftwise/overview.md, replacing any placeholder content. Do not invent files, routes, or models that aren't in the scanner output.`; +export const AGENT_INSTRUCTION = `The scanner data above describes a real codebase. Generate an overview.md grounded only in what the scanner produced. Use these top-level sections, in order: + +# (use the package name as a starting point) +> One-sentence description of what this product appears to do, inferred from the codebase. + +## What this codebase is +A short paragraph (3-5 sentences) framing the product in plain language. Cite the framework(s) detected. + +## Major flows +Bullet list of the top 5-8 user-facing flows you can infer from routes + components. For each: name, one-line description, and the entry-point file(s). + +## API surface +Table or list of the routes/endpoints detected. For each: method, path, file. Group by area if natural. + +## Data model +Bullet list of the data models/tables detected. For each: name, file, and 2-3 of its most important fields/relationships. + +## Components +Bullet list of the most important UI components (cap at 15). Group by directory. + +## Integrations & external dependencies +Bullet list of notable third-party dependencies inferred from package.json that hint at integrations (auth providers, payments, AI, queues, observability, etc.). Skip generic build tooling. + +## Gaps in this overview +A short section listing what the scanner could NOT determine — e.g. flows the scanner missed, models not yet parsed, etc. Be honest. + +Write the file to .draftwise/overview.md, replacing any placeholder content. Do not invent files, routes, or models that aren't in the scanner output.`; diff --git a/src/ai/prompts/spec-quality.js b/src/ai/prompts/spec-quality.js deleted file mode 100644 index d01c56e..0000000 --- a/src/ai/prompts/spec-quality.js +++ /dev/null @@ -1,38 +0,0 @@ -// Spec-quality rules. Drafting standards for the prose-shaped artifacts -// (product-spec.md and technical-spec.md): clarity, length, consistency, -// and edge-case coverage. -// -// Injected into the synthesis SYSTEM constants in `new.js` and `tech.js`, -// alongside CORE_PRINCIPLES. Plan/JSON calls don't get these — they don't -// draft prose. `tasks.js` doesn't either — task lists aren't prose-shaped -// and the existing prompt already pins the format tightly. -// -// Single source of truth — change behavior here, not in each prompt. - -export const SPEC_LANGUAGE_RULES = `## How to write the spec (language and structure) - -1. **Specific over generic.** "Users tap a 1-5 star widget; the average updates within 1s" beats "users can rate." "POST /api/albums returns 201 with the new album's id" beats "endpoint creates album." - -2. **Active language, plain words.** Describe what users do or what the system produces — don't hide behind passive constructions or "the system shall" boilerplate. "Users save changes" not "Changes will be saved." - -3. **Same term every time.** Pick one word per concept (user / customer / member; album / collection / set) and stick with it. Variation reads as a meaningful distinction even when it isn't. - -4. **Cut filler.** Strike hedging ("we should probably consider"), restated headings (the section already names the topic), and any sentence that just rephrases the section above. - -5. **Concrete examples for ambiguous claims.** Acceptance criteria and edge cases each earn a parenthetical example. "Limit 10 photos (selecting 12 → error, 0 uploaded)" beats just "limit of 10." - -6. **Don't blame users.** Failure modes describe system behavior, not user mistakes. "Upload fails when file exceeds 10 MB" not "User attempts an invalid file size." - -7. **Equal-effort sections.** If one section gets three bullets of detail, the next one doesn't get a hand-wave sentence. Sections at the same hierarchy level get the same level of treatment.`; - -export const EDGE_CASE_DISCIPLINE = `## Edge cases for the technical spec - -For every endpoint, model, or component this spec touches, name (inline in the relevant section) what happens for: -- **Empty data** — the collection has no rows yet, or the user has no relevant records -- **Errors** — 4xx/5xx for endpoints; validation failures for inputs; what the user actually sees -- **Loading** — operations slower than ~1s need feedback (skeleton, spinner, optimistic update) -- **Permissions** — who can't perform this action, and what they see instead -- **Concurrency** — double-submission, race conditions, optimistic-update conflicts -- **Large data** — pagination, virtualization, or an explicit cap when collections can grow past ~100 items - -Designs that work only on perfect data aren't production-ready. Skip a category only if it genuinely doesn't apply — not from laziness.`; diff --git a/src/ai/prompts/tasks.js b/src/ai/prompts/tasks.js index d5e6180..c0cf010 100644 --- a/src/ai/prompts/tasks.js +++ b/src/ai/prompts/tasks.js @@ -1,134 +1,3 @@ -import { CORE_PRINCIPLES } from './principles.js'; - -export const SYSTEM_BROWNFIELD = `You are Draftwise, a codebase-aware tool that breaks technical specs into ordered implementation tasks. - -${CORE_PRINCIPLES} - -You will receive: an approved technical-spec.md (already grounded in the real codebase) and the structured scanner output. Your job is to write tasks.md — an ordered breakdown an engineer can pick up and ship from. - -The tasks document has these sections, in order: - -# — Tasks - -> One-sentence framing. - -## Overview -2-3 sentences summarizing the work — total task count, broad shape (e.g. "schema first, then API, then UI"), notable parallel tracks. - -## Tasks -A numbered list of tasks. Each task is one logical unit of work — small enough to land in one PR, large enough to be worth a checkbox. Format each as: - -\`\`\` -### N. -- **Goal:** one sentence on what this task accomplishes -- **Files:** comma-separated real file paths from the technical spec / scanner; for new files, mark "(new)" -- **Depends on:** a list of task numbers this one needs first, or "none" -- **Parallel with:** a list of task numbers that can run in parallel with this one (no dependency either way), or "none" -- **Acceptance:** what "done" looks like, tied to the technical spec / acceptance criteria -\`\`\` - -Order the tasks so each one's dependencies appear before it. Don't sort alphabetically. - -## Suggested execution order -A short paragraph or bullets describing the dependency chain — which task to start with, where things can fan out into parallel tracks, where they merge back. - -## Open questions -Things the engineer must resolve before or during execution that aren't pinned down by the technical spec. If the technical spec is fully resolved, write "_None._". - -Hard rules: -- File paths must be real paths from the technical spec or scanner output. Mark new files explicitly with "(new)" rather than inventing existing-looking paths. -- Don't pad. If the work is genuinely small, three tasks is fine. If it's large, twenty is fine. Don't manufacture tasks for the sake of structure. -- Each "Depends on" link must point at a task number that exists in this document. -- Output the markdown only. No preamble. Start with the title. -`; - -export const SYSTEM_GREENFIELD = `You are Draftwise. The PM has approved a technical spec for a feature in a GREENFIELD project. The chosen stack and directory plan are in overview.md. Write tasks.md — the ordered breakdown an engineer can pick up and ship from. - -${CORE_PRINCIPLES} - -Sections, in order: - -# — Tasks - -> One-sentence framing. - -## Overview -2-3 sentences: total task count, broad shape, notable parallel tracks. Note that this is a greenfield build — first tasks are project setup before feature work. - -## Tasks -Numbered list. Each task: - -\`\`\` -### N. -- **Goal:** one sentence -- **Files:** comma-separated paths, all marked "(new)" — match the directory structure from overview.md -- **Depends on:** task numbers or "none" -- **Parallel with:** task numbers or "none" -- **Acceptance:** what "done" looks like -\`\`\` - -Include foundational scaffolding tasks first: running the setup commands from overview.md, configuring environment variables, writing the first config files / env file. Don't assume the project is already initialized. - -## Suggested execution order -Dependency chain — what to start with, where work fans out into parallel tracks, where it merges. - -## Open questions -What the engineer must resolve before/during execution. If genuinely none, write "_None._". - -Hard rules: -- Greenfield. Every file path is "(new)" and must follow the directory structure from overview.md. -- The first 1-3 tasks should be project setup (scaffold the framework, install deps, configure env). Don't skip this. -- Each "Depends on" must point at a task number that actually exists in this doc. -- Output markdown only. No preamble. -`; - -export function selectSystem(projectState) { - return projectState === 'greenfield' ? SYSTEM_GREENFIELD : SYSTEM_BROWNFIELD; -} - -export function buildPrompt({ - technicalSpec, - scan, - packageMeta, - projectState, - overview, -}) { - if (projectState === 'greenfield') { - return [ - 'Approved technical spec (defines the work):', - '', - technicalSpec, - '', - '---', - '', - 'Project plan (overview.md — chosen stack, directory structure, setup commands):', - '', - overview && overview.trim() ? overview : '_(no overview.md found)_', - '', - 'Write tasks.md per the system instructions, marking every file path "(new)" and including project setup as the first 1-3 tasks.', - ].join('\n'); - } - return [ - 'Approved technical spec (read carefully — it defines the work):', - '', - technicalSpec, - '', - '---', - '', - 'Codebase scanner output (the source of truth for existing code):', - '```json', - JSON.stringify(scan, null, 2), - '```', - '', - 'Package metadata:', - '```json', - JSON.stringify(packageMeta, null, 2), - '```', - '', - 'Write tasks.md per the system instructions.', - ].join('\n'); -} - export function buildAgentInstruction(slug, projectState = 'brownfield') { if (projectState === 'greenfield') { return `The technical spec above is approved. The project is GREENFIELD — there's no existing code yet. The chosen stack and directory plan are in overview.md (above). diff --git a/src/ai/prompts/tech.js b/src/ai/prompts/tech.js index 0109944..a73833e 100644 --- a/src/ai/prompts/tech.js +++ b/src/ai/prompts/tech.js @@ -1,138 +1,3 @@ -import { CORE_PRINCIPLES } from './principles.js'; -import { SPEC_LANGUAGE_RULES, EDGE_CASE_DISCIPLINE } from './spec-quality.js'; - -export const SYSTEM_BROWNFIELD = `You are Draftwise, a codebase-aware tool that drafts technical specs from approved product specs. - -${CORE_PRINCIPLES} - -${SPEC_LANGUAGE_RULES} - -${EDGE_CASE_DISCIPLINE} - -You will receive: an approved product-spec.md (already through the conversational drafting phase, so it's grounded in reality), and the structured scanner output for the existing codebase. Your job is to write the technical-spec.md — the engineering counterpart that translates intent into concrete code changes. - -The technical spec has these sections, in order: - -# — Technical spec - -> One-sentence framing that links to the product spec ("Implements the feature described in product-spec.md"). - -## Summary -2-4 sentences. What's being built, where it lands in the existing architecture, and the broad shape of the change. - -## Data model changes -For each model change: model name, file (the real schema file from the scanner — e.g. prisma/schema.prisma or the Mongoose model file), the specific field(s) added/modified/removed, and the migration approach. If no schema changes, write "_None._". - -## API changes -For each endpoint: method + path, file (the real route file from the scanner), what changes (new endpoint, modified handler, deprecated). If no API changes, write "_None._". - -## Component changes -For each UI component: component name, file (real path from the scanner), what changes (new file, modified, removed). If no UI changes, write "_None._". - -## Migration notes -Deployment ordering, backfill steps, feature flags, rollout plan. If trivial, write "_None — ship it._". - -## Test plan -Unit, integration, and end-to-end coverage. Tie back to the acceptance criteria from the product spec. - -## Open technical questions -Specific questions the engineer should resolve before starting. Each grounded in something concrete from the scanner output. If genuinely none, write "_None._". - -Hard rules: -- Every file path must be a real path from the scanner output. If the product spec describes a change to a system that the scanner doesn't surface, raise it under "Open technical questions" — do NOT invent file paths. -- Match the codebase's existing conventions. If the scanner shows Prisma, propose schema changes in Prisma syntax. If it shows Express, propose handler signatures matching Express. Don't impose foreign patterns. -- Keep it tight. Engineers stop reading verbose specs. -- Output the markdown only. No preamble. Start with the title. -`; - -export const SYSTEM_GREENFIELD = `You are Draftwise. The PM has approved a product spec for a feature in a GREENFIELD project — there's no existing code yet. The chosen stack and directory plan are in overview.md. Your job is to write technical-spec.md against the planned structure. - -${CORE_PRINCIPLES} - -${SPEC_LANGUAGE_RULES} - -${EDGE_CASE_DISCIPLINE} - -Sections, in order: - -# — Technical spec - -> One-sentence framing linking to product-spec.md. - -## Summary -2-4 sentences: what's being built and where it lands inside the planned architecture. - -## Data model changes -For each model: name, planned file (e.g. \`prisma/schema.prisma\` (new) — match the chosen ORM from overview.md), fields, relationships, and any migration / seeding approach. - -## API changes -For each endpoint: method + path, planned file (e.g. \`app/api//route.ts\` (new) — match the chosen framework's conventions), what it does. - -## Component changes -For each new UI piece: name, planned file (e.g. \`app//page.tsx\` (new) or \`src/components/.tsx\` (new)), purpose. - -## Migration notes -Setup ordering, environment variables, third-party services to wire up. If trivial, write "_None — ship it._". - -## Test plan -Unit, integration, and end-to-end coverage tied to acceptance criteria. - -## Open technical questions -Things the engineer must resolve before/during execution that the product spec doesn't pin down. Often: hosting choice, auth provider, observability, etc. If genuinely none, write "_None._". - -Hard rules: -- Greenfield project. Mark every file path with "(new)" — these are files that will exist once the feature is built. Reference the planned directory structure from overview.md so the layout is consistent. -- Match the chosen stack's conventions exactly. If overview.md says Prisma, write Prisma schema syntax. If it says Next App Router, propose \`route.ts\` / \`page.tsx\`. Don't impose foreign patterns. -- Keep it tight. Output markdown only, no preamble. -`; - -export function selectSystem(projectState) { - return projectState === 'greenfield' ? SYSTEM_GREENFIELD : SYSTEM_BROWNFIELD; -} - -export function buildPrompt({ - productSpec, - scan, - packageMeta, - projectState, - overview, -}) { - if (projectState === 'greenfield') { - return [ - 'Approved product spec (the source of intent):', - '', - productSpec, - '', - '---', - '', - 'Project plan (overview.md — chosen stack, directory structure, setup commands):', - '', - overview && overview.trim() ? overview : '_(no overview.md found)_', - '', - 'Write technical-spec.md per the system instructions, marking every file path "(new)".', - ].join('\n'); - } - return [ - 'Approved product spec (read this carefully — it is the source of intent):', - '', - productSpec, - '', - '---', - '', - 'Codebase scanner output (the source of truth for existing code):', - '```json', - JSON.stringify(scan, null, 2), - '```', - '', - 'Package metadata:', - '```json', - JSON.stringify(packageMeta, null, 2), - '```', - '', - 'Write the technical spec per the system instructions.', - ].join('\n'); -} - export function buildAgentInstruction(slug, projectState = 'brownfield') { if (projectState === 'greenfield') { return `The product spec above is approved. The project is GREENFIELD — there's no existing code yet. The chosen stack and directory plan are in overview.md (above). diff --git a/src/ai/provider.js b/src/ai/provider.js deleted file mode 100644 index bf5d332..0000000 --- a/src/ai/provider.js +++ /dev/null @@ -1,37 +0,0 @@ -import { complete as claudeComplete } from './providers/claude.js'; - -const ADAPTERS = { - claude: claudeComplete, - openai: notImplemented('openai'), - gemini: notImplemented('gemini'), -}; - -function notImplemented(name) { - return () => { - throw new Error( - `The ${name} provider isn't wired up yet. Use Claude for now (set ai.provider: claude in .draftwise/config.yaml) or run draftwise inside a coding agent.`, - ); - }; -} - -export async function complete({ - provider, - apiKeyEnv, - model, - system, - prompt, - maxTokens, - onToken, -}) { - const adapter = ADAPTERS[provider]; - if (!adapter) { - throw new Error(`Unknown AI provider "${provider}".`); - } - const apiKey = process.env[apiKeyEnv]; - if (!apiKey) { - throw new Error( - `Environment variable ${apiKeyEnv} is not set. Export it before running this command.`, - ); - } - return adapter({ apiKey, model, system, prompt, maxTokens, onToken }); -} diff --git a/src/ai/providers/claude.js b/src/ai/providers/claude.js deleted file mode 100644 index a5978ca..0000000 --- a/src/ai/providers/claude.js +++ /dev/null @@ -1,68 +0,0 @@ -import Anthropic from '@anthropic-ai/sdk'; - -const DEFAULT_MODEL = 'claude-sonnet-4-6'; -// Bumped from the previous 8192 — synthesis calls (overview, tech spec, -// task breakdown) on richly-scanned repos were getting truncated. -// Override per-config via ai.max_tokens. -const DEFAULT_MAX_TOKENS = 16384; - -// SDK's default is 2 retries on 429 / 5xx / network errors. 4 covers -// most transient issues without making rate-limit waits feel hung. -const MAX_RETRIES = 4; - -export async function complete({ - apiKey, - model, - system, - prompt, - maxTokens, - onToken, -}) { - const client = new Anthropic({ apiKey, maxRetries: MAX_RETRIES }); - const params = { - model: model || DEFAULT_MODEL, - max_tokens: maxTokens || DEFAULT_MAX_TOKENS, - system, - messages: [{ role: 'user', content: prompt }], - }; - - // Streaming path — feed text deltas to onToken as they arrive, accumulate - // for the return value. Used by the synthesis commands so users see - // output live instead of waiting on a frozen line. - if (typeof onToken === 'function') { - let accumulated = ''; - const stream = client.messages.stream(params); - try { - for await (const event of stream) { - if ( - event.type === 'content_block_delta' && - event.delta?.type === 'text_delta' - ) { - accumulated += event.delta.text; - onToken(event.delta.text); - } - } - } catch (err) { - // If iteration or onToken throws, ensure the underlying HTTP - // request is closed before we propagate the error. - stream.abort?.(); - throw err; - } - if (!accumulated) { - throw new Error('Claude returned an empty response.'); - } - return accumulated; - } - - // Non-streaming path — used for JSON / plan calls where live token - // display would be worse UX than just waiting for the parsed object. - const response = await client.messages.create(params); - const text = response.content - .filter((block) => block.type === 'text') - .map((block) => block.text) - .join(''); - if (!text) { - throw new Error('Claude returned an empty response.'); - } - return text; -} diff --git a/src/commands/explain.js b/src/commands/explain.js index f37f5c0..ac2a56e 100644 --- a/src/commands/explain.js +++ b/src/commands/explain.js @@ -1,14 +1,11 @@ -import { writeFile, mkdir } from 'node:fs/promises'; -import { join } from 'node:path'; import { cachedScan as defaultScan } from '../utils/scan-cache.js'; import { loadConfig as defaultLoadConfig } from '../utils/config.js'; -import { complete as defaultComplete } from '../ai/provider.js'; import { describeScanWarnings } from '../utils/scan-warnings.js'; import { filterScanForFlow } from '../utils/flow-filter.js'; import { requireDraftwiseDir } from '../utils/draftwise-dir.js'; import { AGENT_HANDOFF_PREFIX } from '../utils/agent-handoff.js'; import { compactScan } from '../utils/scan-projection.js'; -import { SYSTEM, buildPrompt, buildAgentInstruction } from '../ai/prompts/explain.js'; +import { buildAgentInstruction } from '../ai/prompts/explain.js'; import { slugify } from '../utils/slug.js'; export const HELP = `draftwise explain — trace a flow through the codebase @@ -21,8 +18,9 @@ Usage: Walks the flow end-to-end: entry points, services, data writes, side effects, edge cases the code handles. The scan is filtered to flow-keyword-relevant files so the model focuses on what -matters. Saves a snapshot to .draftwise/flows/.md in api -mode. Brownfield only — greenfield short-circuits with a hint. +matters. Prints scanner data plus an instruction for your coding +agent, which writes the walkthrough to .draftwise/flows/.md. +Brownfield only — greenfield short-circuits with a hint. `; export default async function explainCommand(args = [], deps = {}) { @@ -30,7 +28,6 @@ export default async function explainCommand(args = [], deps = {}) { const log = deps.log ?? ((msg) => console.error(msg)); const scan = deps.scan ?? defaultScan; const loadConfig = deps.loadConfig ?? defaultLoadConfig; - const complete = deps.complete ?? defaultComplete; const flow = args.join(' ').trim(); if (!flow) { @@ -39,9 +36,9 @@ export default async function explainCommand(args = [], deps = {}) { ); } - const draftwiseDir = await requireDraftwiseDir(cwd); + await requireDraftwiseDir(cwd); - const config = await loadConfig(cwd); + const config = await loadConfig(cwd, { log }); if (config.projectState === 'greenfield') { log(`No code yet — \`draftwise explain\` traces flows that exist in the codebase.`); @@ -68,49 +65,23 @@ export default async function explainCommand(args = [], deps = {}) { const compact = compactScan(result); const scanForPrompt = filterScanForFlow(compact, flow); - if (config.mode === 'agent') { - log(''); - log('Agent mode — handing scanner data off to your coding agent.'); - log(AGENT_HANDOFF_PREFIX); - log(''); - log('---'); - log(`FLOW: ${flow}`); - log(''); - log('SCANNER OUTPUT'); - log('```json'); - log(JSON.stringify(scanForPrompt, null, 2)); - log('```'); - log(''); - log('PACKAGE METADATA'); - log('```json'); - log(JSON.stringify(result.packageMeta, null, 2)); - log('```'); - log(''); - log('INSTRUCTION'); - log(buildAgentInstruction(flow, slug)); - return; - } - - log(`API mode — calling ${config.provider}...`); log(''); - const walkthrough = await complete({ - provider: config.provider, - apiKeyEnv: config.apiKeyEnv, - model: config.model, - maxTokens: config.maxTokens, - system: SYSTEM, - prompt: buildPrompt({ - flow, - scan: scanForPrompt, - packageMeta: result.packageMeta, - }), - onToken: (chunk) => process.stdout.write(chunk), - }); + log('Handing scanner data off to your coding agent.'); + log(AGENT_HANDOFF_PREFIX); log(''); - - const flowsDir = join(draftwiseDir, 'flows'); - await mkdir(flowsDir, { recursive: true }); - const outPath = join(flowsDir, `${slug}.md`); - await writeFile(outPath, walkthrough, 'utf8'); - log(`Saved snapshot to .draftwise/flows/${slug}.md`); + log('---'); + log(`FLOW: ${flow}`); + log(''); + log('SCANNER OUTPUT'); + log('```json'); + log(JSON.stringify(scanForPrompt, null, 2)); + log('```'); + log(''); + log('PACKAGE METADATA'); + log('```json'); + log(JSON.stringify(result.packageMeta, null, 2)); + log('```'); + log(''); + log('INSTRUCTION'); + log(buildAgentInstruction(flow, slug)); } diff --git a/src/commands/init.js b/src/commands/init.js index 478fd9c..47ee84c 100644 --- a/src/commands/init.js +++ b/src/commands/init.js @@ -2,85 +2,48 @@ import { mkdir, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { parseArgs } from 'node:util'; import { stringify as yamlStringify } from 'yaml'; -import { select, input } from '@inquirer/prompts'; +import { input } from '@inquirer/prompts'; import { cachedScan as defaultScan } from '../utils/scan-cache.js'; -import { complete as defaultComplete } from '../ai/provider.js'; import { describeScanWarnings } from '../utils/scan-warnings.js'; import { pathExists } from '../utils/fs.js'; import { isInteractive as defaultIsInteractive } from '../utils/tty.js'; import { AGENT_HANDOFF_PREFIX } from '../utils/agent-handoff.js'; import { detectProjectState as defaultDetectProjectState } from '../utils/project-state.js'; -import { loadAnswersFlag } from '../utils/answers-flag.js'; -import { - QUESTIONS_SYSTEM, - STACKS_SYSTEM, - buildQuestionsPrompt, - buildStacksPrompt, - parseQuestionsResponse, - parseStacksResponse, - buildOverviewMarkdown, - buildAgentInstruction as buildGreenfieldAgentInstruction, -} from '../ai/prompts/greenfield.js'; +import { buildAgentInstruction as buildGreenfieldAgentInstruction } from '../ai/prompts/greenfield.js'; export const HELP = `draftwise init — set up .draftwise/ for the current project Usage: - draftwise init # auto-detects + prompts for AI mode - draftwise init --ai-mode=agent # existing codebase detected → brownfield - draftwise init --mode=greenfield --ai-mode=api \\ - --provider=claude --idea="" # explicit override + draftwise init # auto-detects project state + draftwise init --mode=greenfield --idea="" # explicit override Flags: --mode Override the auto-detected project state. - "greenfield" = no code yet (Draftwise picks a stack - and proposes initial structure). + "greenfield" = no code yet (Draftwise hands the + stack/structure conversation to your coding agent). "brownfield" = existing codebase (Draftwise scans). Auto-detected from filesystem if omitted: zero source files → greenfield; otherwise brownfield. - --ai-mode How Draftwise calls the AI. - --provider AI provider (api mode only). - --api-key-env Env var holding the API key (api mode only). - Defaults to ANTHROPIC_API_KEY / OPENAI_API_KEY / - GEMINI_API_KEY based on --provider. --idea "" The project idea (greenfield only). - --stack Pre-pick a stack by name (greenfield + api). - Skips the picker; must match one of the model's - proposed stack names. - --answers JSON array of answers to greenfield clarifying - questions, or @path/to/answers.json. Used in - greenfield + api when you've pre-collected the - answers (e.g. via a slash-command wrapper). Detects whether the directory is empty (no code yet — "greenfield") or contains an existing codebase ("brownfield") and routes -accordingly. Greenfield walks you through stack selection with -rationale, pros, and cons. Brownfield scans the codebase. Refuses to -overwrite an existing .draftwise/. - -Non-TTY (CI, coding-agent shell): every still-needed value must be -supplied via flags. A missing required flag errors with a usage hint -instead of hanging on a prompt. +accordingly. Greenfield prints an instruction your coding agent +follows to walk through stack selection. Brownfield scans the +codebase. Refuses to overwrite an existing .draftwise/. + +Non-TTY (CI, coding-agent shell): brownfield needs no further input +and runs straight through. Greenfield without --idea prints a +structured agent handoff so the host coding agent can ask the user +in chat and re-invoke with the flag. `; const ARG_OPTIONS = { mode: { type: 'string' }, - 'ai-mode': { type: 'string' }, - provider: { type: 'string' }, - 'api-key-env': { type: 'string' }, idea: { type: 'string' }, - stack: { type: 'string' }, - answers: { type: 'string' }, }; const VALID_MODES = ['greenfield', 'brownfield']; -const VALID_AI_MODES = ['agent', 'api']; -const VALID_PROVIDERS = ['claude', 'openai', 'gemini']; - -const ENV_VAR_BY_PROVIDER = { - claude: 'ANTHROPIC_API_KEY', - openai: 'OPENAI_API_KEY', - gemini: 'GEMINI_API_KEY', -}; const DRAFTWISE_GITIGNORE = `# Draftwise generates these locally; don't commit them. .cache/ @@ -112,163 +75,21 @@ a chat and have it generate the plan, then save it back to this file. } const DEFAULT_PROMPTS = { - promptMode: () => - select({ - message: 'How should Draftwise call the AI?', - choices: [ - { - name: 'Inside a coding agent (Claude Code, Cursor, Antigravity, etc.)', - value: 'agent', - description: "The agent's existing model handles reasoning", - }, - { - name: 'Direct API call (with your own API key)', - value: 'api', - description: 'Draftwise calls the model API directly', - }, - ], - default: 'agent', - }), - promptProvider: () => - select({ - message: 'Which AI provider?', - choices: [ - { name: 'Claude (Anthropic) — fully wired', value: 'claude' }, - { name: 'OpenAI — adapter not yet implemented', value: 'openai' }, - { name: 'Gemini (Google) — adapter not yet implemented', value: 'gemini' }, - ], - }), - promptApiKeyEnv: ({ provider, suggested }) => - input({ - message: `Which environment variable holds your ${provider} API key?`, - default: suggested, - }), promptIdea: () => input({ message: 'In a sentence or two — what do you want to build?', validate: (v) => v.trim().length > 0 ? true : 'Please describe the idea.', }), - askGreenfieldQuestion: ({ index, total, text, why }) => - input({ - message: `Q${index + 1}/${total} — ${text}\n Why: ${why}\n (press enter to skip)`, - }), - pickStack: ({ stackOptions }) => - select({ - message: 'Which stack do you want to go with?', - choices: stackOptions.map((s) => ({ - name: s.name, - value: s.name, - description: s.summary, - })), - }), }; -function buildConfigYaml({ mode, provider, apiKeyEnv, projectState, stack }) { - const ai = { mode }; - if (mode === 'api') { - ai.provider = provider; - ai.api_key_env = apiKeyEnv; - ai.model = ''; - } +function buildConfigYaml({ projectState, stack }) { const project = { state: projectState }; if (stack) project.stack = stack; - return yamlStringify({ ai, project }); -} - -function formatStackForDisplay(opt, index) { - const lines = [ - '', - `── Option ${index + 1}: ${opt.name} ──`, - opt.summary, - '', - `Why this fits: ${opt.rationale}`, - '', - 'Pros:', - ...(opt.pros ?? []).map((p) => ` + ${p}`), - '', - 'Cons:', - ...(opt.cons ?? []).map((c) => ` - ${c}`), - ]; - return lines.join('\n'); -} - -// Resolves a value from (1) a flag, (2) a TTY prompt, or (3) errors out with a -// usage hint when neither is available. Keeps the "flags-first, prompts as -// fallback" pattern in one place so every input handles non-TTY identically. -// -// In normal flow this throws on the non-TTY+missing case as a backstop — -// `init`'s entry point catches that case earlier with a structured handoff -// (see needsHandoff / buildInitHandoff below) and returns before reaching -// here. The throw stays as defensive coverage for fields the handoff doesn't -// account for. -async function resolveValue({ - flagName, - flagValue, - promptFn, - isInteractive, - validValues, - missingHint, -}) { - if (flagValue !== undefined && flagValue !== null) { - if (validValues && !validValues.includes(flagValue)) { - throw new Error( - `Invalid --${flagName} value "${flagValue}". Must be one of: ${validValues.join(', ')}.`, - ); - } - return flagValue; - } - if (isInteractive()) { - return promptFn(); - } - throw new Error(missingHint); -} - -// True when init can't proceed in non-TTY without asking the user something. -// Drives the structured-handoff path (see buildInitHandoff) — the host coding -// agent reads the handoff, asks the user in chat, and re-invokes draftwise init -// with the collected flags. Project state (mode) is always resolved before -// this runs — by --mode flag or by filesystem auto-detect — so it's never an -// open question by the time we get here. -function needsHandoff(flags, mode) { - if (!flags['ai-mode']) return true; - if (flags['ai-mode'] === 'api' && !flags.provider) return true; - if (mode === 'greenfield' && !flags.idea) return true; - return false; + return yamlStringify({ project }); } -// Builds the structured handoff printed when init can't proceed in non-TTY. -// Format follows the same AGENT_HANDOFF_PREFIX pattern Draftwise already uses -// elsewhere — orienting line, fenced section, INSTRUCTION block — so the host -// agent (Claude Code, Cursor, etc.) recognizes it as a "ask the user, then -// re-invoke" handoff. Includes the auto-detected project state in the orienting -// line so the agent can mention it to the user without needing to re-detect. -// Only lists questions for fields that aren't already supplied. -function buildInitHandoff(flags, mode, modeSource) { - const askAiMode = !flags['ai-mode']; - const askProvider = flags['ai-mode'] !== 'agent' && !flags.provider; - const askIdea = mode === 'greenfield' && !flags.idea; - - const rawQuestions = []; - if (askAiMode) { - rawQuestions.push( - 'AI mode — **agent** (the host coding agent — Claude Code, Cursor, etc. — handles reasoning) or **api** (Draftwise calls a model directly with your API key)?', - ); - } - if (askProvider) { - const conditional = - flags['ai-mode'] === undefined ? ' (only if you picked **api** above)' : ''; - rawQuestions.push( - `AI provider — **claude** (the only one fully wired today), openai, or gemini?${conditional}`, - ); - } - if (askIdea) { - rawQuestions.push( - `What do you want to build? A sentence or two — the model will ask follow-up questions on stack and structure.`, - ); - } - const questions = rawQuestions.map((q, i) => `${i + 1}. ${q}`); - +function buildInitHandoff(mode, modeSource) { const detectedLabel = mode === 'greenfield' ? 'new project (no source code in this directory yet)' @@ -278,35 +99,28 @@ function buildInitHandoff(flags, mode, modeSource) { ? `Detected: ${detectedLabel}. Override with --mode=${mode === 'greenfield' ? 'brownfield' : 'greenfield'} if wrong.` : `Project state set via --mode: ${detectedLabel}.`; - const lines = [ + return [ AGENT_HANDOFF_PREFIX, '', '---', - 'INIT — answer these in chat, then re-invoke draftwise init with the matching flags', + 'INIT — answer in chat, then re-invoke draftwise init with the matching flag', '', detectedLine, '', - ...questions, + '1. What do you want to build? A sentence or two — your coding agent will ask follow-up questions on stack and structure once init finishes.', '', 'INSTRUCTION', - 'Once you have answers from the user, re-invoke draftwise init with the corresponding flags:', + 'Once you have an answer from the user, re-invoke draftwise init with:', '', - ' draftwise init --ai-mode=', - ' [--mode=] [--provider=]', - ' [--api-key-env=] [--idea=""] [--stack=""]', - ' [--answers @path/to/answers.json]', + ' draftwise init --mode=greenfield --idea=""', '', 'Notes:', '- --mode is auto-detected from the filesystem (zero source files → greenfield; otherwise brownfield). Pass it only to override.', - '- --provider only applies when --ai-mode=api.', - '- --api-key-env defaults to ANTHROPIC_API_KEY / OPENAI_API_KEY / GEMINI_API_KEY based on --provider; pass it only to override.', '- --idea is required when the project is greenfield.', - '- For greenfield + api: --stack and --answers are optional first time. Without them, init picks the first proposed stack and leaves clarifying-question answers blank. Re-invoke with both for a richer plan.', - ]; - return lines.join('\n'); + ].join('\n'); } -async function runBrownfield({ cwd, log, scan, draftwiseDir, aiConfig }) { +async function runBrownfield({ cwd, log, scan, draftwiseDir }) { log(''); log('Scanning repo for source files...'); const result = await scan(cwd); @@ -333,7 +147,7 @@ async function runBrownfield({ cwd, log, scan, draftwiseDir, aiConfig }) { ); await writeFile( join(draftwiseDir, 'config.yaml'), - buildConfigYaml({ ...aiConfig, projectState: 'brownfield' }), + buildConfigYaml({ projectState: 'brownfield' }), 'utf8', ); await writeFile(join(draftwiseDir, '.gitignore'), DRAFTWISE_GITIGNORE, 'utf8'); @@ -341,228 +155,76 @@ async function runBrownfield({ cwd, log, scan, draftwiseDir, aiConfig }) { log('Created .draftwise/ with:'); log(' • overview.md (placeholder — `draftwise scan` will fill it in)'); log(' • specs/ (your specs will live here)'); - log(' • config.yaml (AI mode + project state)'); + log(' • config.yaml (project state)'); log(' • .gitignore (excludes .cache/ from version control)'); log(''); - if (aiConfig.mode === 'api') { - log( - `Make sure ${aiConfig.apiKeyEnv} is set in your environment before running other commands.`, - ); - } else { - log( - 'Run draftwise commands inside your coding agent (Claude Code, Cursor, etc.).', - ); - } + log( + 'Run draftwise commands inside your coding agent (Claude Code, Cursor, etc.).', + ); log(''); log('Next: draftwise scan'); } async function runGreenfield({ log, - complete, draftwiseDir, - aiConfig, prompts, isInteractive, ideaFlag, - stackFlag, - answersFlag, }) { log(''); - const idea = await resolveValue({ - flagName: 'idea', - flagValue: ideaFlag, - promptFn: () => prompts.promptIdea(), - isInteractive, - missingHint: - 'Greenfield init needs --idea "". Pass it as a flag, or run init in an interactive terminal.', - }); + let idea = ideaFlag; + if (idea === undefined || idea === null) { + if (isInteractive()) { + idea = await prompts.promptIdea(); + } else { + throw new Error( + 'Greenfield init needs --idea "". Pass it as a flag, or run init in an interactive terminal.', + ); + } + } if (typeof idea !== 'string' || idea.trim().length === 0) { throw new Error('--idea must be a non-empty string.'); } - if (aiConfig.mode === 'agent') { - log(''); - log('Agent mode — handing the greenfield conversation off to your coding agent.'); - log(AGENT_HANDOFF_PREFIX); - log(''); - log('---'); - log(`IDEA: ${idea}`); - log(''); - log('INSTRUCTION'); - log(buildGreenfieldAgentInstruction(idea)); - log(''); - - await mkdir(join(draftwiseDir, 'specs'), { recursive: true }); - await writeFile( - join(draftwiseDir, 'overview.md'), - greenfieldAgentPlaceholder(idea), - 'utf8', - ); - await writeFile( - join(draftwiseDir, 'config.yaml'), - buildConfigYaml({ ...aiConfig, projectState: 'greenfield' }), - 'utf8', - ); - await writeFile( - join(draftwiseDir, '.gitignore'), - DRAFTWISE_GITIGNORE, - 'utf8', - ); - - log('Created .draftwise/ with:'); - log(' • overview.md (placeholder — your agent will rewrite from the conversation)'); - log(' • specs/ (your specs will live here)'); - log(' • config.yaml (AI mode + project state)'); - log(' • .gitignore (excludes .cache/ from version control)'); - log(''); - log('Run draftwise commands inside your coding agent (Claude Code, Cursor, etc.).'); - return; - } - log(''); - log('Generating clarifying questions...'); - const questionsText = await complete({ - provider: aiConfig.provider, - apiKeyEnv: aiConfig.apiKeyEnv, - model: '', - system: QUESTIONS_SYSTEM, - prompt: buildQuestionsPrompt(idea), - }); - const { projectTitle, questions } = parseQuestionsResponse(questionsText); - + log('Handing the greenfield conversation off to your coding agent.'); + log(AGENT_HANDOFF_PREFIX); log(''); - log(`Got it — "${projectTitle}". ${questions.length} questions before I propose stacks:`); + log('---'); + log(`IDEA: ${idea}`); log(''); - - let answers; - if (answersFlag) { - answers = answersFlag.slice(0, questions.length); - while (answers.length < questions.length) answers.push(''); - } else if (isInteractive()) { - answers = []; - for (let i = 0; i < questions.length; i++) { - const q = questions[i]; - const answer = await prompts.askGreenfieldQuestion({ - index: i, - total: questions.length, - text: q.text, - why: q.why, - }); - answers.push(answer); - } - } else { - // Non-TTY without --answers: model gets no answers. Init still produces a - // plan, but the stack rationale will be generic. Pass --answers next time. - answers = questions.map(() => ''); - } - + log('INSTRUCTION'); + log(buildGreenfieldAgentInstruction(idea)); log(''); - log('Thinking about stack options...'); - const stacksText = await complete({ - provider: aiConfig.provider, - apiKeyEnv: aiConfig.apiKeyEnv, - model: '', - system: STACKS_SYSTEM, - prompt: buildStacksPrompt({ idea, projectTitle, questions, answers }), - }); - const stackOptions = parseStacksResponse(stacksText); - - log(''); - for (let i = 0; i < stackOptions.length; i++) { - log(formatStackForDisplay(stackOptions[i], i)); - } - log(''); - - let chosenName; - if (stackFlag) { - if (!stackOptions.some((s) => s.name === stackFlag)) { - const available = stackOptions.map((s) => s.name).join(', '); - throw new Error( - `--stack "${stackFlag}" doesn't match any proposed option. Available: ${available}`, - ); - } - chosenName = stackFlag; - } else if (isInteractive()) { - chosenName = await prompts.pickStack({ stackOptions }); - } else { - // Non-TTY without --stack: pick the first option. Caller can re-run with - // --stack= if they want a different one. - chosenName = stackOptions[0].name; - log(`No --stack flag and not interactive — picking first option: ${chosenName}`); - } - const chosen = stackOptions.find((s) => s.name === chosenName); - if (!chosen) { - throw new Error( - `Internal error: chosen stack "${chosenName}" not found in options.`, - ); - } - - const overview = buildOverviewMarkdown({ - projectTitle, - idea, - questions, - answers, - chosen, - }); await mkdir(join(draftwiseDir, 'specs'), { recursive: true }); - await writeFile(join(draftwiseDir, 'overview.md'), overview, 'utf8'); await writeFile( - join(draftwiseDir, 'config.yaml'), - buildConfigYaml({ - ...aiConfig, - projectState: 'greenfield', - stack: chosen.name, - }), + join(draftwiseDir, 'overview.md'), + greenfieldAgentPlaceholder(idea), 'utf8', ); - await writeFile(join(draftwiseDir, '.gitignore'), DRAFTWISE_GITIGNORE, 'utf8'); - await writeFile( - join(draftwiseDir, 'scaffold.json'), - JSON.stringify( - { - stack: chosen.name, - summary: chosen.summary, - directory_structure: chosen.directory_structure ?? '', - initial_files: chosen.initial_files ?? [], - setup_commands: chosen.setup_commands ?? [], - }, - null, - 2, - ), + join(draftwiseDir, 'config.yaml'), + buildConfigYaml({ projectState: 'greenfield' }), 'utf8', ); + await writeFile(join(draftwiseDir, '.gitignore'), DRAFTWISE_GITIGNORE, 'utf8'); - log(''); - log(`Picked: ${chosen.name}`); - log(''); log('Created .draftwise/ with:'); - log( - ` • overview.md (greenfield plan: idea, discovery, ${chosen.name}, directory structure, setup)`, - ); - log(' • specs/ (your specs will live here)'); - log(' • config.yaml (AI mode + project state + stack)'); - log(' • scaffold.json (structured plan for `draftwise scaffold`)'); + log(' • overview.md (placeholder — your agent will rewrite from the conversation)'); + log(' • specs/ (your specs will live here)'); + log(' • config.yaml (project state)'); + log(' • .gitignore (excludes .cache/ from version control)'); log(''); - log('Next steps:'); - log(' 1. Open .draftwise/overview.md and run the setup commands.'); - log( - ' 2. (Optional) `draftwise scaffold` to create the user-written initial files.', - ); - log( - ' 3. Once you have some code, `draftwise scan` will refresh this overview.', - ); - log(' 4. `draftwise new ""` to draft your first feature spec.'); + log('Run draftwise commands inside your coding agent (Claude Code, Cursor, etc.).'); } export default async function init(args = [], deps = {}) { const cwd = deps.cwd ?? process.cwd(); const log = deps.log ?? ((msg) => console.error(msg)); const scan = deps.scan ?? defaultScan; - const complete = deps.complete ?? defaultComplete; const isInteractive = deps.isInteractive ?? defaultIsInteractive; const detectProjectState = deps.detectProjectState ?? defaultDetectProjectState; @@ -582,7 +244,6 @@ export default async function init(args = [], deps = {}) { }); } const flags = parsed.values; - const answersFlag = await loadAnswersFlag(flags.answers); const draftwiseDir = join(cwd, '.draftwise'); if (await pathExists(draftwiseDir)) { @@ -592,10 +253,7 @@ export default async function init(args = [], deps = {}) { } // Resolve project state before anything else — by --mode flag (with - // validation) or by walking the filesystem for source files. The user gets - // a plain-language line about what was decided either way; "greenfield" / - // "brownfield" stays as the canonical flag value + config.yaml field but - // doesn't surface in the chat / terminal copy. + // validation) or by walking the filesystem for source files. let projectState; let modeSource; if (flags.mode !== undefined) { @@ -611,18 +269,21 @@ export default async function init(args = [], deps = {}) { modeSource = 'detected'; } - // Non-TTY structured handoff: when stdin isn't interactive and init still - // needs to ask the user something, print the questions in the - // AGENT_HANDOFF_PREFIX format so the host coding agent (Claude Code, Cursor, - // etc.) can read them from stderr, ask the user in chat, and re-invoke - // `draftwise init` with the collected flags. Falls through to normal - // flag-or-prompt resolution when every required value is already supplied. - if (!isInteractive() && needsHandoff(flags, projectState)) { - log(buildInitHandoff(flags, projectState, modeSource)); + // Non-TTY structured handoff: greenfield without --idea is the only + // remaining case where init can't proceed without asking the user. Print + // the question in AGENT_HANDOFF_PREFIX format so the host coding agent + // (Claude Code, Cursor, etc.) reads it from stderr, asks the user in chat, + // and re-invokes with --idea filled in. + if ( + !isInteractive() && + projectState === 'greenfield' && + !flags.idea + ) { + log(buildInitHandoff(projectState, modeSource)); return; } - log('Welcome to Draftwise. A few quick questions before we set you up.'); + log('Welcome to Draftwise. Setting up .draftwise/ for this project.'); log(''); if (modeSource === 'detected') { @@ -640,55 +301,14 @@ export default async function init(args = [], deps = {}) { log(''); } - const aiMode = await resolveValue({ - flagName: 'ai-mode', - flagValue: flags['ai-mode'], - promptFn: () => prompts.promptMode(), - isInteractive, - validValues: VALID_AI_MODES, - missingHint: - 'Missing --ai-mode flag. Pass --ai-mode=agent (Claude Code/Cursor handles reasoning) or --ai-mode=api (Draftwise calls a model directly).', - }); - - let aiConfig; - if (aiMode === 'agent') { - aiConfig = { mode: 'agent' }; - } else { - const provider = await resolveValue({ - flagName: 'provider', - flagValue: flags.provider, - promptFn: () => prompts.promptProvider(), - isInteractive, - validValues: VALID_PROVIDERS, - missingHint: - 'Missing --provider flag (required when --ai-mode=api). Pass --provider=claude (or openai/gemini once those adapters are wired up).', - }); - const suggested = ENV_VAR_BY_PROVIDER[provider]; - let apiKeyEnv = flags['api-key-env']; - if (!apiKeyEnv) { - if (isInteractive()) { - apiKeyEnv = await prompts.promptApiKeyEnv({ provider, suggested }); - } else { - // Non-TTY default — the provider's standard env var name. User can - // override with --api-key-env if they use a custom name. - apiKeyEnv = suggested; - } - } - aiConfig = { mode: 'api', provider, apiKeyEnv }; - } - if (projectState === 'brownfield') { - return runBrownfield({ cwd, log, scan, draftwiseDir, aiConfig }); + return runBrownfield({ cwd, log, scan, draftwiseDir }); } return runGreenfield({ log, - complete, draftwiseDir, - aiConfig, prompts, isInteractive, ideaFlag: flags.idea, - stackFlag: flags.stack, - answersFlag, }); } diff --git a/src/commands/new.js b/src/commands/new.js index 503d5b2..a89945b 100644 --- a/src/commands/new.js +++ b/src/commands/new.js @@ -1,91 +1,35 @@ -import { writeFile, mkdir } from 'node:fs/promises'; -import { join } from 'node:path'; import { parseArgs } from 'node:util'; -import { input, select } from '@inquirer/prompts'; import { cachedScan as defaultScan } from '../utils/scan-cache.js'; import { loadConfig as defaultLoadConfig } from '../utils/config.js'; -import { complete as defaultComplete } from '../ai/provider.js'; import { readOverview as defaultReadOverview } from '../utils/overview.js'; import { requireDraftwiseDir } from '../utils/draftwise-dir.js'; import { loadScanContext } from '../utils/scan-context.js'; -import { confirmOverwriteOrCancel } from '../utils/overwrite-guard.js'; -import { isInteractive as defaultIsInteractive } from '../utils/tty.js'; import { AGENT_HANDOFF_PREFIX } from '../utils/agent-handoff.js'; -import { loadAnswersFlag } from '../utils/answers-flag.js'; -import { - selectPlanSystem, - selectSpecSystem, - buildPlanPrompt, - parsePlanResponse, - buildSpecPrompt, - buildAgentInstruction, -} from '../ai/prompts/new.js'; -import { slugify } from '../utils/slug.js'; +import { buildAgentInstruction } from '../ai/prompts/new.js'; -export const HELP = `draftwise new "" [--force] — conversational product-spec drafting +export const HELP = `draftwise new "" — conversational product-spec drafting Usage: draftwise new "" draftwise new "add collaborative albums" - draftwise new "let users mute notifications" --force -Flags: - --force, -f Skip the overwrite confirmation prompt. - --answers JSON array of answers to clarifying questions - (e.g. \`["public", "yes, async"]\`), or - @path/to/answers.json. Used in non-TTY shells - (CI, coding-agent wrappers) where inquirer can't - run; if absent and non-TTY, all questions are - treated as unanswered and adjacent opportunities - are declined. - -Three phases: - 1. AI plans the conversation — clarifying questions tailored to - your repo (or your greenfield plan), affected flows, and - adjacent opportunities. - 2. You walk through questions and accept/decline opportunities. - 3. AI synthesizes a product-spec.md under .draftwise/specs//. - -If product-spec.md already exists for the resolved slug (a re-run -on the same idea, for instance), you'll be asked to confirm before -it's overwritten — pass --force to skip the prompt. In non-TTY -without --force, the command errors instead of overwriting. +Prints scanner data (brownfield) or the project plan (greenfield) +plus a three-phase instruction for your coding agent: plan a +conversation, walk the PM through clarifying questions, then write +product-spec.md to .draftwise/specs//. Hard rule: every claim grounds in scanner output (brownfield) or the project plan (greenfield). Never invents files. `; -const ARG_OPTIONS = { - force: { type: 'boolean', short: 'f' }, - answers: { type: 'string' }, -}; - -const DEFAULT_PROMPTS = { - askQuestion: ({ index, total, text, why }) => - input({ - message: `Q${index + 1}/${total} — ${text}\n Why: ${why}\n (press enter to skip)`, - }), - decideOpportunity: ({ index, total, flow, suggestion, rationale }) => - select({ - message: `Opportunity ${index + 1}/${total} — adjacent change in "${flow}"\n Suggestion: ${suggestion}\n Why: ${rationale}`, - choices: [ - { name: 'Accept — include in this spec', value: 'accepted' }, - { name: 'Decline — keep it out', value: 'declined' }, - { name: "Defer — note it but don't commit", value: 'deferred' }, - ], - default: 'declined', - }), -}; +const ARG_OPTIONS = {}; export default async function newCommand(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 complete = deps.complete ?? defaultComplete; const readOverview = deps.readOverview ?? defaultReadOverview; - const isInteractive = deps.isInteractive ?? defaultIsInteractive; - const prompts = { ...DEFAULT_PROMPTS, ...(deps.prompts ?? {}) }; let parsed; try { @@ -100,16 +44,14 @@ export default async function newCommand(args = [], deps = {}) { cause: err, }); } - const force = Boolean(parsed.values.force); - const answersFlag = await loadAnswersFlag(parsed.values.answers); const idea = parsed.positionals.join(' ').trim(); if (!idea) { throw new Error('Missing idea. Usage: draftwise new ""'); } - const draftwiseDir = await requireDraftwiseDir(cwd); + await requireDraftwiseDir(cwd); - const config = await loadConfig(cwd); + const config = await loadConfig(cwd, { log }); const isGreenfield = config.projectState === 'greenfield'; log(`Idea: "${idea}"`); @@ -123,171 +65,36 @@ export default async function newCommand(args = [], deps = {}) { commandName: 'new', }); - if (config.mode === 'agent') { - log(''); - if (isGreenfield) { - log( - 'Agent mode — handing the project plan + conversation off to your coding agent.', - ); - } else { - log( - 'Agent mode — handing scanner data + the conversation plan off to your coding agent.', - ); - } - log(AGENT_HANDOFF_PREFIX); - log(''); - log('---'); - log(`IDEA: ${idea}`); - log(''); - if (isGreenfield) { - log('PROJECT PLAN (overview.md)'); - log(overview); - } else { - log('SCANNER OUTPUT'); - log('```json'); - log(JSON.stringify(scanForPrompt, null, 2)); - log('```'); - log(''); - log('PACKAGE METADATA'); - log('```json'); - log(JSON.stringify(packageMeta, null, 2)); - log('```'); - } - log(''); - log('INSTRUCTION'); - log(buildAgentInstruction(idea, config.projectState)); - return; - } - - log(`API mode — calling ${config.provider} for the conversation plan...`); - const planText = await complete({ - provider: config.provider, - apiKeyEnv: config.apiKeyEnv, - model: config.model, - maxTokens: config.maxTokens, - system: selectPlanSystem(config.projectState), - prompt: buildPlanPrompt({ - idea, - scan: scanForPrompt, - packageMeta, - projectState: config.projectState, - overview, - }), - }); - - const plan = parsePlanResponse(planText); log(''); - log(`Feature: ${plan.featureTitle} (slug: ${plan.featureSlug})`); - - // Confirm before clobbering an existing product-spec.md. Done before the Q&A - // loop so a cancel doesn't waste the user's time on questions whose answers - // get thrown away. - const slug = slugify(plan.featureSlug); - const specDir = join(draftwiseDir, 'specs', slug); - const productSpecPath = join(specDir, 'product-spec.md'); - const proceed = await confirmOverwriteOrCancel({ - targetPath: productSpecPath, - slug, - file: 'product-spec.md', - force, - isInteractive, - log, - confirmOverwrite: prompts.confirmOverwrite, - }); - if (!proceed) return; - - if (plan.affectedFlows.length > 0) { - log(''); - log('Affected flows:'); - for (const f of plan.affectedFlows) { - log(` • ${f.name} — ${f.impact}`); - for (const file of f.files ?? []) log(` ${file}`); - } + if (isGreenfield) { + log( + 'Handing the project plan + conversation off to your coding agent.', + ); + } else { + log( + 'Handing scanner data + the conversation plan off to your coding agent.', + ); } + log(AGENT_HANDOFF_PREFIX); log(''); - log(`Walking through ${plan.clarifyingQuestions.length} clarifying questions:`); + log('---'); + log(`IDEA: ${idea}`); log(''); - - let answers; - if (answersFlag) { - answers = answersFlag.slice(0, plan.clarifyingQuestions.length); - while (answers.length < plan.clarifyingQuestions.length) answers.push(''); - } else if (isInteractive()) { - answers = []; - for (let i = 0; i < plan.clarifyingQuestions.length; i++) { - const q = plan.clarifyingQuestions[i]; - const answer = await prompts.askQuestion({ - index: i, - total: plan.clarifyingQuestions.length, - text: q.text, - why: q.why, - }); - answers.push(answer); - } + if (isGreenfield) { + log('PROJECT PLAN (overview.md)'); + log(overview); } else { - // Non-TTY without --answers: model gets no answers. The spec will lean on - // the AI's best guess. Pass --answers next time for a richer draft. - answers = plan.clarifyingQuestions.map(() => ''); - log('(non-interactive: no --answers supplied — questions left blank.)'); - } - - let opportunityDecisions; - if (plan.adjacentOpportunities.length > 0) { - if (isInteractive()) { - log(''); - log( - `Pitching ${plan.adjacentOpportunities.length} adjacent opportunities:`, - ); - log(''); - opportunityDecisions = []; - for (let i = 0; i < plan.adjacentOpportunities.length; i++) { - const o = plan.adjacentOpportunities[i]; - const decision = await prompts.decideOpportunity({ - index: i, - total: plan.adjacentOpportunities.length, - flow: o.flow, - suggestion: o.suggestion, - rationale: o.rationale, - }); - opportunityDecisions.push(decision); - } - } else { - // Non-TTY: decline all. The spec stays focused on the original idea. - opportunityDecisions = plan.adjacentOpportunities.map(() => 'declined'); - } - } else { - opportunityDecisions = []; + log('SCANNER OUTPUT'); + log('```json'); + log(JSON.stringify(scanForPrompt, null, 2)); + log('```'); + log(''); + log('PACKAGE METADATA'); + log('```json'); + log(JSON.stringify(packageMeta, null, 2)); + log('```'); } - - log(''); - log(`Drafting product-spec.md (${config.provider})...`); - log(''); - const spec = await complete({ - provider: config.provider, - apiKeyEnv: config.apiKeyEnv, - model: config.model, - maxTokens: config.maxTokens, - system: selectSpecSystem(config.projectState), - prompt: buildSpecPrompt({ - idea, - plan, - scan: scanForPrompt, - packageMeta, - answers, - opportunityDecisions, - projectState: config.projectState, - overview, - }), - onToken: (chunk) => process.stdout.write(chunk), - }); - log(''); - - await mkdir(specDir, { recursive: true }); - await writeFile(productSpecPath, spec, 'utf8'); - log(''); - log(`Wrote .draftwise/specs/${slug}/product-spec.md`); - log( - 'Next: review, refine, then run `draftwise tech` to generate the technical spec.', - ); + log('INSTRUCTION'); + log(buildAgentInstruction(idea, config.projectState)); } diff --git a/src/commands/scaffold.js b/src/commands/scaffold.js index f17f830..7db16d0 100644 --- a/src/commands/scaffold.js +++ b/src/commands/scaffold.js @@ -95,7 +95,7 @@ export default async function scaffoldCommand(args = [], deps = {}) { // Short-circuit for brownfield projects — scaffold has nothing to do, and // the missing-scaffold.json error message would mislead the user toward // an "ask your agent" path that doesn't apply. - const config = await loadConfig(cwd); + const config = await loadConfig(cwd, { log }); if (config.projectState === 'brownfield') { log( 'scaffold is greenfield-only — your project is brownfield, so there are no initial files to create.', diff --git a/src/commands/scan.js b/src/commands/scan.js index 222c72d..ae227ee 100644 --- a/src/commands/scan.js +++ b/src/commands/scan.js @@ -1,24 +1,21 @@ -import { writeFile } from 'node:fs/promises'; -import { join } from 'node:path'; import { cachedScan as defaultScan } from '../utils/scan-cache.js'; import { loadConfig as defaultLoadConfig } from '../utils/config.js'; -import { complete as defaultComplete } from '../ai/provider.js'; import { describeScanWarnings } from '../utils/scan-warnings.js'; import { compactScan } from '../utils/scan-projection.js'; import { requireDraftwiseDir } from '../utils/draftwise-dir.js'; import { AGENT_HANDOFF_PREFIX } from '../utils/agent-handoff.js'; -import { SYSTEM, buildPrompt, AGENT_INSTRUCTION } from '../ai/prompts/scan.js'; +import { AGENT_INSTRUCTION } from '../ai/prompts/scan.js'; export const HELP = `draftwise scan — refresh the codebase overview (brownfield) Usage: draftwise scan -Re-runs the scanner. In api mode, calls your AI provider to write -a narrated overview to .draftwise/overview.md. In agent mode, -prints scanner data + an instruction for the host coding agent. -In a greenfield project, prints a friendly hint and exits — the -plan from \`draftwise init\` is already in overview.md. +Re-runs the scanner and prints the structured data plus an +instruction for your coding agent, which writes the narrated +overview to .draftwise/overview.md. In a greenfield project, +prints a friendly hint and exits — the plan from +\`draftwise init\` is already in overview.md. `; function summarize(scan) { @@ -37,11 +34,10 @@ export default async function scanCommand(_args = [], deps = {}) { const log = deps.log ?? ((msg) => console.error(msg)); const scan = deps.scan ?? defaultScan; const loadConfig = deps.loadConfig ?? defaultLoadConfig; - const complete = deps.complete ?? defaultComplete; - const draftwiseDir = await requireDraftwiseDir(cwd); + await requireDraftwiseDir(cwd); - const config = await loadConfig(cwd); + const config = await loadConfig(cwd, { log }); if (config.projectState === 'greenfield') { log('No code yet — `draftwise scan` works on existing codebases.'); @@ -76,44 +72,20 @@ export default async function scanCommand(_args = [], deps = {}) { const scanForPrompt = compactScan(result); - if (config.mode === 'agent') { - log('Agent mode — handing scanner data off to your coding agent.'); - log(AGENT_HANDOFF_PREFIX); - log(''); - log('---'); - log('SCANNER OUTPUT'); - log('```json'); - log(JSON.stringify(scanForPrompt, null, 2)); - log('```'); - log(''); - log('PACKAGE METADATA'); - log('```json'); - log(JSON.stringify(result.packageMeta, null, 2)); - log('```'); - log(''); - log('INSTRUCTION'); - log(AGENT_INSTRUCTION); - return; - } - - log(`API mode — calling ${config.provider}...`); - log(''); - const overview = await complete({ - provider: config.provider, - apiKeyEnv: config.apiKeyEnv, - model: config.model, - maxTokens: config.maxTokens, - system: SYSTEM, - prompt: buildPrompt({ scan: scanForPrompt, packageMeta: result.packageMeta }), - onToken: (chunk) => process.stdout.write(chunk), - }); + log('Handing scanner data off to your coding agent.'); + log(AGENT_HANDOFF_PREFIX); log(''); - - await writeFile(join(draftwiseDir, 'overview.md'), overview, 'utf8'); + log('---'); + log('SCANNER OUTPUT'); + log('```json'); + log(JSON.stringify(scanForPrompt, null, 2)); + log('```'); log(''); - log('Wrote .draftwise/overview.md'); + log('PACKAGE METADATA'); + log('```json'); + log(JSON.stringify(result.packageMeta, null, 2)); + log('```'); log(''); - log( - 'Next: review the overview, then `draftwise new ""` to draft your first spec — or `draftwise explain ` to trace a specific area.', - ); + log('INSTRUCTION'); + log(AGENT_INSTRUCTION); } diff --git a/src/commands/tasks.js b/src/commands/tasks.js index c6f8b87..64fdcb8 100644 --- a/src/commands/tasks.js +++ b/src/commands/tasks.js @@ -1,47 +1,34 @@ -import { readFile, writeFile } from 'node:fs/promises'; +import { readFile } from 'node:fs/promises'; import { parseArgs } from 'node:util'; import { select } from '@inquirer/prompts'; import { cachedScan as defaultScan } from '../utils/scan-cache.js'; import { loadConfig as defaultLoadConfig } from '../utils/config.js'; -import { complete as defaultComplete } from '../ai/provider.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 { confirmOverwriteOrCancel } from '../utils/overwrite-guard.js'; import { isInteractive as defaultIsInteractive } from '../utils/tty.js'; import { AGENT_HANDOFF_PREFIX } from '../utils/agent-handoff.js'; -import { - selectSystem, - buildPrompt, - buildAgentInstruction, -} from '../ai/prompts/tasks.js'; +import { buildAgentInstruction } from '../ai/prompts/tasks.js'; -export const HELP = `draftwise tasks [] [--force] — break technical spec into ordered work +export const HELP = `draftwise tasks [] — break technical spec into ordered work Usage: draftwise tasks # auto-pick if exactly one tech spec exists draftwise tasks # target a specific feature -Flags: - --force, -f # Skip the overwrite confirmation prompt. - -Generates tasks.md: numbered tasks with Goal / Files / Depends on / -Parallel with / Acceptance, ordered so each task's dependencies -appear before it. In greenfield, the first 1-3 tasks are project -scaffolding (run setup commands, install deps). If tasks.md already -exists for the chosen feature, you'll be asked to confirm before -it's overwritten — pass --force to skip the prompt. In non-TTY -without --force, the command errors instead of overwriting. +Reads the technical spec, prints it plus scanner data (brownfield) +or the project plan (greenfield) and an instruction for your coding +agent, which writes tasks.md (numbered tasks with Goal / Files / +Depends on / Parallel with / Acceptance). In greenfield, the first +1-3 tasks are project scaffolding (run setup commands, install deps). Non-TTY (CI, coding-agent shell): when multiple technical specs exist and no is supplied, the command errors with the available slugs instead of running the picker. `; -const ARG_OPTIONS = { - force: { type: 'boolean', short: 'f' }, -}; +const ARG_OPTIONS = {}; const DEFAULT_PROMPTS = { pickSpec: ({ specs }) => @@ -59,7 +46,6 @@ export default async function tasksCommand(args = [], deps = {}) { const log = deps.log ?? ((msg) => console.error(msg)); const scan = deps.scan ?? defaultScan; const loadConfig = deps.loadConfig ?? defaultLoadConfig; - const complete = deps.complete ?? defaultComplete; const listSpecs = deps.listSpecs ?? defaultListSpecs; const readOverview = deps.readOverview ?? defaultReadOverview; const isInteractive = deps.isInteractive ?? defaultIsInteractive; @@ -80,10 +66,9 @@ export default async function tasksCommand(args = [], deps = {}) { cause: err, }); } - const force = Boolean(parsed.values.force); const requestedSlug = parsed.positionals[0]; - const config = await loadConfig(cwd); + const config = await loadConfig(cwd, { log }); const isGreenfield = config.projectState === 'greenfield'; const specs = (await listSpecs(cwd)).filter((s) => s.hasTechnicalSpec); @@ -122,22 +107,6 @@ export default async function tasksCommand(args = [], deps = {}) { ); } - // Confirm before clobbering a hand-edited tasks.md. Run before the scan so a - // cancel doesn't waste the scan time. Agent mode is exempt — the host agent - // does the write, not Draftwise. - if (config.mode !== 'agent') { - const proceed = await confirmOverwriteOrCancel({ - targetPath: target.tasks, - slug: target.slug, - file: 'tasks.md', - force, - isInteractive, - log, - confirmOverwrite: prompts.confirmOverwrite, - }); - if (!proceed) return; - } - const { scanForPrompt, packageMeta, overview } = await loadScanContext({ cwd, config, @@ -147,62 +116,35 @@ export default async function tasksCommand(args = [], deps = {}) { commandName: 'tasks', }); - if (config.mode === 'agent') { - log(''); - if (isGreenfield) { - log('Agent mode — handing project plan + technical spec off to your coding agent.'); - } else { - log('Agent mode — handing scanner data + technical spec off to your coding agent.'); - } - log(AGENT_HANDOFF_PREFIX); - log(''); - log('---'); - log(`SPEC: ${target.slug}`); - log(''); - log('TECHNICAL SPEC'); - log(technicalSpec); - log(''); - if (isGreenfield) { - log('PROJECT PLAN (overview.md)'); - log(overview); - } else { - log('SCANNER OUTPUT'); - log('```json'); - log(JSON.stringify(scanForPrompt, null, 2)); - log('```'); - log(''); - log('PACKAGE METADATA'); - log('```json'); - log(JSON.stringify(packageMeta, null, 2)); - log('```'); - } - log(''); - log('INSTRUCTION'); - log(buildAgentInstruction(target.slug, config.projectState)); - return; + log(''); + if (isGreenfield) { + log('Handing project plan + technical spec off to your coding agent.'); + } else { + log('Handing scanner data + technical spec off to your coding agent.'); } - - log(`API mode — calling ${config.provider}...`); + log(AGENT_HANDOFF_PREFIX); log(''); - const tasks = await complete({ - provider: config.provider, - apiKeyEnv: config.apiKeyEnv, - model: config.model, - maxTokens: config.maxTokens, - system: selectSystem(config.projectState), - prompt: buildPrompt({ - technicalSpec, - scan: scanForPrompt, - packageMeta, - projectState: config.projectState, - overview, - }), - onToken: (chunk) => process.stdout.write(chunk), - }); + log('---'); + log(`SPEC: ${target.slug}`); log(''); - - await writeFile(target.tasks, tasks, 'utf8'); + log('TECHNICAL SPEC'); + log(technicalSpec); + log(''); + if (isGreenfield) { + log('PROJECT PLAN (overview.md)'); + log(overview); + } else { + log('SCANNER OUTPUT'); + log('```json'); + log(JSON.stringify(scanForPrompt, null, 2)); + log('```'); + log(''); + log('PACKAGE METADATA'); + log('```json'); + log(JSON.stringify(packageMeta, null, 2)); + log('```'); + } log(''); - log(`Wrote .draftwise/specs/${target.slug}/tasks.md`); - log('Next: pick the first task with no dependencies and start shipping.'); + log('INSTRUCTION'); + log(buildAgentInstruction(target.slug, config.projectState)); } diff --git a/src/commands/tech.js b/src/commands/tech.js index 6f4b5e7..331dadf 100644 --- a/src/commands/tech.js +++ b/src/commands/tech.js @@ -1,47 +1,34 @@ -import { readFile, writeFile } from 'node:fs/promises'; +import { readFile } from 'node:fs/promises'; import { parseArgs } from 'node:util'; import { select } from '@inquirer/prompts'; import { cachedScan as defaultScan } from '../utils/scan-cache.js'; import { loadConfig as defaultLoadConfig } from '../utils/config.js'; -import { complete as defaultComplete } from '../ai/provider.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 { confirmOverwriteOrCancel } from '../utils/overwrite-guard.js'; import { isInteractive as defaultIsInteractive } from '../utils/tty.js'; import { AGENT_HANDOFF_PREFIX } from '../utils/agent-handoff.js'; -import { - selectSystem, - buildPrompt, - buildAgentInstruction, -} from '../ai/prompts/tech.js'; +import { buildAgentInstruction } from '../ai/prompts/tech.js'; -export const HELP = `draftwise tech [] [--force] — draft technical-spec.md from a product spec +export const HELP = `draftwise tech [] — draft technical-spec.md from a product spec Usage: draftwise tech # auto-pick if exactly one product spec exists draftwise tech # target a specific feature draftwise tech # multiple specs → picks via inquirer (TTY only) -Flags: - --force, -f # Skip the overwrite confirmation prompt. - -Reads the product spec, writes technical-spec.md grounded in the -real codebase (brownfield) or the planned directory structure -(greenfield, with "(new)" markers). If technical-spec.md already -exists for the chosen feature, you'll be asked to confirm before -it's overwritten — pass --force to skip the prompt. In non-TTY -without --force, the command errors instead of overwriting. +Reads the product spec, prints it plus scanner data (brownfield) or +the project plan (greenfield) and an instruction for your coding +agent, which writes technical-spec.md grounded in real code or +marked "(new)" for greenfield. Non-TTY (CI, coding-agent shell): when multiple product specs exist and no is supplied, the command errors with the available slugs instead of running the picker. `; -const ARG_OPTIONS = { - force: { type: 'boolean', short: 'f' }, -}; +const ARG_OPTIONS = {}; const DEFAULT_PROMPTS = { pickSpec: ({ specs }) => @@ -61,7 +48,6 @@ export default async function techCommand(args = [], deps = {}) { const log = deps.log ?? ((msg) => console.error(msg)); const scan = deps.scan ?? defaultScan; const loadConfig = deps.loadConfig ?? defaultLoadConfig; - const complete = deps.complete ?? defaultComplete; const listSpecs = deps.listSpecs ?? defaultListSpecs; const readOverview = deps.readOverview ?? defaultReadOverview; const isInteractive = deps.isInteractive ?? defaultIsInteractive; @@ -82,10 +68,9 @@ export default async function techCommand(args = [], deps = {}) { cause: err, }); } - const force = Boolean(parsed.values.force); const requestedSlug = parsed.positionals[0]; - const config = await loadConfig(cwd); + const config = await loadConfig(cwd, { log }); const isGreenfield = config.projectState === 'greenfield'; const specs = (await listSpecs(cwd)).filter((s) => s.hasProductSpec); @@ -124,22 +109,6 @@ export default async function techCommand(args = [], deps = {}) { ); } - // Confirm before clobbering a hand-edited technical-spec.md. Run before the - // scan so a cancel doesn't waste the scan time. Agent mode is exempt — the - // host agent does the write, not Draftwise. - if (config.mode !== 'agent') { - const proceed = await confirmOverwriteOrCancel({ - targetPath: target.technicalSpec, - slug: target.slug, - file: 'technical-spec.md', - force, - isInteractive, - log, - confirmOverwrite: prompts.confirmOverwrite, - }); - if (!proceed) return; - } - const { scanForPrompt, packageMeta, overview } = await loadScanContext({ cwd, config, @@ -149,64 +118,35 @@ export default async function techCommand(args = [], deps = {}) { commandName: 'tech', }); - if (config.mode === 'agent') { - log(''); - if (isGreenfield) { - log('Agent mode — handing project plan + product spec off to your coding agent.'); - } else { - log('Agent mode — handing scanner data + product spec off to your coding agent.'); - } - log(AGENT_HANDOFF_PREFIX); - log(''); - log('---'); - log(`SPEC: ${target.slug}`); - log(''); - log('PRODUCT SPEC'); - log(productSpec); - log(''); - if (isGreenfield) { - log('PROJECT PLAN (overview.md)'); - log(overview); - } else { - log('SCANNER OUTPUT'); - log('```json'); - log(JSON.stringify(scanForPrompt, null, 2)); - log('```'); - log(''); - log('PACKAGE METADATA'); - log('```json'); - log(JSON.stringify(packageMeta, null, 2)); - log('```'); - } - log(''); - log('INSTRUCTION'); - log(buildAgentInstruction(target.slug, config.projectState)); - return; + log(''); + if (isGreenfield) { + log('Handing project plan + product spec off to your coding agent.'); + } else { + log('Handing scanner data + product spec off to your coding agent.'); } - - log(`API mode — calling ${config.provider}...`); + log(AGENT_HANDOFF_PREFIX); log(''); - const techSpec = await complete({ - provider: config.provider, - apiKeyEnv: config.apiKeyEnv, - model: config.model, - maxTokens: config.maxTokens, - system: selectSystem(config.projectState), - prompt: buildPrompt({ - productSpec, - scan: scanForPrompt, - packageMeta, - projectState: config.projectState, - overview, - }), - onToken: (chunk) => process.stdout.write(chunk), - }); + log('---'); + log(`SPEC: ${target.slug}`); log(''); - - await writeFile(target.technicalSpec, techSpec, 'utf8'); + log('PRODUCT SPEC'); + log(productSpec); + log(''); + if (isGreenfield) { + log('PROJECT PLAN (overview.md)'); + log(overview); + } else { + log('SCANNER OUTPUT'); + log('```json'); + log(JSON.stringify(scanForPrompt, null, 2)); + log('```'); + log(''); + log('PACKAGE METADATA'); + log('```json'); + log(JSON.stringify(packageMeta, null, 2)); + log('```'); + } log(''); - log(`Wrote .draftwise/specs/${target.slug}/technical-spec.md`); - log( - 'Next: review, refine, then run `draftwise tasks` to break it into work items.', - ); + log('INSTRUCTION'); + log(buildAgentInstruction(target.slug, config.projectState)); } diff --git a/src/utils/answers-flag.js b/src/utils/answers-flag.js deleted file mode 100644 index a32d05e..0000000 --- a/src/utils/answers-flag.js +++ /dev/null @@ -1,39 +0,0 @@ -import { readFile } from 'node:fs/promises'; - -// Parses the value of `--answers` (used by `draftwise init` for greenfield -// clarifying questions and `draftwise new` for spec clarifying questions). -// Accepts either a raw JSON string (`["a", "b"]`) or `@path/to/file.json`. -// -// Returns the parsed array of strings, or `null` if the flag wasn't supplied. -// Throws with a usage hint on any failure (file unreadable, malformed JSON, -// wrong shape). - -export async function loadAnswersFlag(value) { - if (!value) return null; - let raw; - if (value.startsWith('@')) { - try { - raw = await readFile(value.slice(1), 'utf8'); - } catch (err) { - throw new Error( - `Could not read --answers file ${value.slice(1)}: ${err.message}`, - { cause: err }, - ); - } - } else { - raw = value; - } - let parsed; - try { - parsed = JSON.parse(raw); - } catch (err) { - throw new Error( - `--answers must be a JSON array (or @path-to-json-file). ${err.message}`, - { cause: err }, - ); - } - if (!Array.isArray(parsed) || !parsed.every((a) => typeof a === 'string')) { - throw new Error('--answers must be a JSON array of strings.'); - } - return parsed; -} diff --git a/src/utils/config.js b/src/utils/config.js index b7d4118..2a40996 100644 --- a/src/utils/config.js +++ b/src/utils/config.js @@ -2,11 +2,9 @@ import { readFile, access } from 'node:fs/promises'; import { join } from 'node:path'; import { parse as parseYaml } from 'yaml'; -const VALID_MODES = new Set(['agent', 'api']); -const VALID_PROVIDERS = new Set(['claude', 'openai', 'gemini']); const VALID_PROJECT_STATES = new Set(['greenfield', 'brownfield']); -export async function loadConfig(cwd = process.cwd()) { +export async function loadConfig(cwd = process.cwd(), { log } = {}) { const path = join(cwd, '.draftwise', 'config.yaml'); try { await access(path); @@ -26,26 +24,15 @@ export async function loadConfig(cwd = process.cwd()) { }); } - const ai = parsed?.ai; - if (!ai || !VALID_MODES.has(ai.mode)) { - throw new Error( - '.draftwise/config.yaml is missing a valid `ai.mode` (agent or api).', + // Configs written before api-mode was dropped still carry an `ai:` block. + // Surface a one-line notice so the user knows it's safe to delete; don't + // error — leaving it in place keeps old configs working. + if (parsed?.ai && typeof log === 'function') { + log( + 'Note: the `ai:` block in .draftwise/config.yaml is no longer used (Draftwise now runs only inside coding agents). You can delete it.', ); } - if (ai.mode === 'api') { - if (!VALID_PROVIDERS.has(ai.provider)) { - throw new Error( - '.draftwise/config.yaml has `ai.mode: api` but is missing a valid `ai.provider` (claude, openai, or gemini).', - ); - } - if (!ai.api_key_env || typeof ai.api_key_env !== 'string') { - throw new Error( - '.draftwise/config.yaml has `ai.mode: api` but is missing `ai.api_key_env`.', - ); - } - } - const project = parsed?.project ?? {}; const projectState = VALID_PROJECT_STATES.has(project.state) ? project.state @@ -57,17 +44,7 @@ export async function loadConfig(cwd = process.cwd()) { scanMaxFiles = Math.max(1, Math.floor(scan.max_files)); } - let maxTokens; - if (typeof ai.max_tokens === 'number' && Number.isFinite(ai.max_tokens)) { - maxTokens = Math.max(1, Math.floor(ai.max_tokens)); - } - return { - mode: ai.mode, - provider: ai.provider, - apiKeyEnv: ai.api_key_env, - model: ai.model || '', - maxTokens, projectState, stack: project.stack, scanMaxFiles, diff --git a/src/utils/overwrite-guard.js b/src/utils/overwrite-guard.js deleted file mode 100644 index 1588052..0000000 --- a/src/utils/overwrite-guard.js +++ /dev/null @@ -1,46 +0,0 @@ -import { confirm } from '@inquirer/prompts'; -import { pathExists } from './fs.js'; - -const DEFAULT_CONFIRM = ({ slug, file }) => - confirm({ - message: `${slug}/${file} already exists. Overwrite?`, - default: false, - }); - -// Used by `new` / `tech` / `tasks` before they overwrite a spec file the user -// may have hand-edited. -// -// Returns `true` when the caller should proceed (no existing file, --force -// passed, or the user confirmed). Returns `false` when the user cancelled -// in a TTY. Throws in non-TTY when the file exists and --force wasn't passed -// — scripted callers must opt in explicitly. -// -// The caller decides WHEN to call this: -// - Place it before any expensive synthesis API call so a cancel doesn't burn -// tokens. -// - Skip it in agent mode (the host coding agent does the write, not -// Draftwise). The caller checks `config.mode !== 'agent'` itself. - -export async function confirmOverwriteOrCancel({ - targetPath, - slug, - file, - force, - isInteractive, - log, - confirmOverwrite = DEFAULT_CONFIRM, -}) { - if (force) return true; - if (!(await pathExists(targetPath))) return true; - if (isInteractive()) { - const proceed = await confirmOverwrite({ slug, file }); - if (!proceed) { - log('Cancelled. No changes written. (Pass --force to skip this prompt.)'); - return false; - } - return true; - } - throw new Error( - `${slug}/${file} already exists. Pass --force to overwrite.`, - ); -} diff --git a/test/ai/greenfield.test.js b/test/ai/greenfield.test.js deleted file mode 100644 index 7968ed5..0000000 --- a/test/ai/greenfield.test.js +++ /dev/null @@ -1,134 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { - parseQuestionsResponse, - parseStacksResponse, - buildOverviewMarkdown, -} from '../../src/ai/prompts/greenfield.js'; - -describe('parseQuestionsResponse', () => { - it('parses a fenced JSON response', () => { - const text = - '```json\n' + - JSON.stringify({ - project_title: 'Demo', - questions: [{ text: 'Q1?', why: 'reason' }], - }) + - '\n```'; - const out = parseQuestionsResponse(text); - expect(out.projectTitle).toBe('Demo'); - expect(out.questions).toHaveLength(1); - expect(out.questions[0].text).toBe('Q1?'); - }); - - it('parses raw JSON without a fence', () => { - const out = parseQuestionsResponse( - JSON.stringify({ - project_title: 'Demo', - questions: [{ text: 'Q1?', why: 'r' }], - }), - ); - expect(out.projectTitle).toBe('Demo'); - }); - - it('throws when the questions array is missing or empty', () => { - expect(() => - parseQuestionsResponse(JSON.stringify({ project_title: 'Demo' })), - ).toThrow(/missing the questions array/); - expect(() => - parseQuestionsResponse( - JSON.stringify({ project_title: 'Demo', questions: [] }), - ), - ).toThrow(/missing the questions array/); - }); - - it('throws on malformed JSON', () => { - expect(() => parseQuestionsResponse('not json')).toThrow( - /Could not parse the clarifying questions/, - ); - }); -}); - -describe('parseStacksResponse', () => { - const validStack = { - name: 'Next.js + Postgres', - summary: 'A summary.', - rationale: 'Reasons.', - pros: ['p1'], - cons: ['c1'], - directory_structure: '```\napp/\n```', - initial_files: [], - setup_commands: ['npm init -y'], - }; - - it('parses a fenced JSON response with nested directory_structure fences', () => { - const text = - '```json\n' + - JSON.stringify({ stack_options: [validStack, validStack] }) + - '\n```'; - const out = parseStacksResponse(text); - expect(out).toHaveLength(2); - expect(out[0].name).toBe('Next.js + Postgres'); - expect(out[0].directory_structure).toContain('app/'); - }); - - it('throws when stack_options is missing', () => { - expect(() => parseStacksResponse(JSON.stringify({}))).toThrow( - /missing the stack_options array/, - ); - }); - - it('throws when an option is missing name or summary', () => { - const bad = { stack_options: [{ name: 'X' }] }; - expect(() => parseStacksResponse(JSON.stringify(bad))).toThrow( - /missing name or summary/, - ); - }); -}); - -describe('buildOverviewMarkdown', () => { - const chosen = { - name: 'Next.js + Postgres + Prisma', - summary: 'Strong type safety.', - rationale: 'You said web-first.', - pros: ['One framework', 'Good DX'], - cons: ['React learning curve'], - directory_structure: '```\napp/\n```', - initial_files: [{ path: 'app/page.tsx', purpose: 'home route' }], - setup_commands: ['npx create-next-app@latest .'], - }; - - it('includes the title, idea, all Q&A pairs, and the chosen stack', () => { - const md = buildOverviewMarkdown({ - projectTitle: 'Recipe app', - idea: 'a recipe sharing app', - questions: [ - { text: 'Public or private?', why: '...' }, - { text: 'Mobile or web?', why: '...' }, - ], - answers: ['Public', 'Web'], - chosen, - }); - - expect(md).toContain('# Recipe app — Greenfield plan'); - expect(md).toContain('a recipe sharing app'); - expect(md).toContain('Public or private?'); - expect(md).toContain('Mobile or web?'); - expect(md).toContain('Web'); - expect(md).toContain('## Chosen stack: Next.js + Postgres + Prisma'); - expect(md).toContain('### Pros'); - expect(md).toContain('### Cons'); - expect(md).toContain('npx create-next-app'); - expect(md).toContain('app/page.tsx'); - }); - - it('marks skipped answers visibly', () => { - const md = buildOverviewMarkdown({ - projectTitle: 'X', - idea: 'idea', - questions: [{ text: 'Q?', why: 'w' }], - answers: [''], - chosen, - }); - expect(md).toContain('_(skipped)_'); - }); -}); diff --git a/test/ai/new.test.js b/test/ai/new.test.js deleted file mode 100644 index 98dbd62..0000000 --- a/test/ai/new.test.js +++ /dev/null @@ -1,57 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { parsePlanResponse } from '../../src/ai/prompts/new.js'; - -describe('parsePlanResponse', () => { - const valid = { - feature_slug: 'collab-albums', - feature_title: 'Collaborative Albums', - affected_flows: [ - { name: 'album-create', files: ['src/api/albums.ts'], impact: 'now multi-user' }, - ], - clarifying_questions: [ - { text: 'Who can invite?', why: 'permissions are unclear from the scanner' }, - ], - adjacent_opportunities: [ - { flow: 'sharing', suggestion: 'unify share + invite', rationale: 'avoids drift' }, - ], - }; - - it('parses a fenced ```json block', () => { - const text = 'preamble\n```json\n' + JSON.stringify(valid) + '\n```\nepilogue'; - const out = parsePlanResponse(text); - expect(out.featureSlug).toBe('collab-albums'); - expect(out.featureTitle).toBe('Collaborative Albums'); - expect(out.clarifyingQuestions).toHaveLength(1); - expect(out.affectedFlows[0].files[0]).toBe('src/api/albums.ts'); - expect(out.adjacentOpportunities[0].flow).toBe('sharing'); - }); - - it('parses raw JSON without a code fence', () => { - const out = parsePlanResponse(JSON.stringify(valid)); - expect(out.featureSlug).toBe('collab-albums'); - }); - - it('defaults missing arrays to empty', () => { - const out = parsePlanResponse( - JSON.stringify({ - feature_slug: 'x', - feature_title: 'X', - clarifying_questions: [], - }), - ); - expect(out.affectedFlows).toEqual([]); - expect(out.adjacentOpportunities).toEqual([]); - }); - - it('throws when JSON is malformed', () => { - expect(() => parsePlanResponse('not json at all')).toThrow( - /Could not parse the plan/, - ); - }); - - it('throws when required fields are missing', () => { - expect(() => parsePlanResponse(JSON.stringify({}))).toThrow( - /missing required fields/, - ); - }); -}); diff --git a/test/ai/principles.test.js b/test/ai/principles.test.js deleted file mode 100644 index 27bd42c..0000000 --- a/test/ai/principles.test.js +++ /dev/null @@ -1,61 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { CORE_PRINCIPLES } from '../../src/ai/prompts/principles.js'; -import { - QUESTIONS_SYSTEM, - STACKS_SYSTEM, -} from '../../src/ai/prompts/greenfield.js'; -import { - PLAN_SYSTEM_BROWNFIELD, - PLAN_SYSTEM_GREENFIELD, - SPEC_SYSTEM_BROWNFIELD, - SPEC_SYSTEM_GREENFIELD, -} from '../../src/ai/prompts/new.js'; -import { - SYSTEM_BROWNFIELD as TECH_SYSTEM_BROWNFIELD, - SYSTEM_GREENFIELD as TECH_SYSTEM_GREENFIELD, -} from '../../src/ai/prompts/tech.js'; -import { - SYSTEM_BROWNFIELD as TASKS_SYSTEM_BROWNFIELD, - SYSTEM_GREENFIELD as TASKS_SYSTEM_GREENFIELD, -} from '../../src/ai/prompts/tasks.js'; - -describe('CORE_PRINCIPLES', () => { - it('is a non-empty string with all eight rules visible', () => { - expect(typeof CORE_PRINCIPLES).toBe('string'); - expect(CORE_PRINCIPLES.length).toBeGreaterThan(200); - // Each rule's headline must appear so the model can't miss them. - expect(CORE_PRINCIPLES).toContain('No filler'); - expect(CORE_PRINCIPLES).toContain('Redirect drift'); - expect(CORE_PRINCIPLES).toContain("Push back on weak ideas; don't validate them"); - expect(CORE_PRINCIPLES).toContain('Extend before adding'); - expect(CORE_PRINCIPLES).toContain('Right over easy'); - expect(CORE_PRINCIPLES).toContain('Flag bad assumptions'); - expect(CORE_PRINCIPLES).toContain('Verify before you assert'); - expect(CORE_PRINCIPLES).toContain('Offer the counter-case'); - }); -}); - -describe('Every drafting / conversational SYSTEM prompt includes CORE_PRINCIPLES', () => { - // Pull a stable substring that's specific to the principles (won't collide - // with command-specific wording). - const sentinel = 'Push back on weak ideas'; - - const cases = [ - ['greenfield QUESTIONS_SYSTEM', QUESTIONS_SYSTEM], - ['greenfield STACKS_SYSTEM', STACKS_SYSTEM], - ['new PLAN_SYSTEM_BROWNFIELD', PLAN_SYSTEM_BROWNFIELD], - ['new PLAN_SYSTEM_GREENFIELD', PLAN_SYSTEM_GREENFIELD], - ['new SPEC_SYSTEM_BROWNFIELD', SPEC_SYSTEM_BROWNFIELD], - ['new SPEC_SYSTEM_GREENFIELD', SPEC_SYSTEM_GREENFIELD], - ['tech SYSTEM_BROWNFIELD', TECH_SYSTEM_BROWNFIELD], - ['tech SYSTEM_GREENFIELD', TECH_SYSTEM_GREENFIELD], - ['tasks SYSTEM_BROWNFIELD', TASKS_SYSTEM_BROWNFIELD], - ['tasks SYSTEM_GREENFIELD', TASKS_SYSTEM_GREENFIELD], - ]; - - for (const [name, prompt] of cases) { - it(`${name} contains the principles sentinel`, () => { - expect(prompt).toContain(sentinel); - }); - } -}); diff --git a/test/ai/provider.test.js b/test/ai/provider.test.js deleted file mode 100644 index f38d513..0000000 --- a/test/ai/provider.test.js +++ /dev/null @@ -1,87 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { complete } from '../../src/ai/provider.js'; - -const ENV_KEY = 'DRAFTWISE_TEST_PROVIDER_KEY'; - -describe('ai/provider — complete()', () => { - let originalEnv; - - beforeEach(() => { - originalEnv = process.env[ENV_KEY]; - delete process.env[ENV_KEY]; - }); - - afterEach(() => { - if (originalEnv !== undefined) { - process.env[ENV_KEY] = originalEnv; - } else { - delete process.env[ENV_KEY]; - } - }); - - it('throws a clear error for an unknown provider', async () => { - process.env[ENV_KEY] = 'sk-fake'; - await expect( - complete({ - provider: 'mystery-vendor', - apiKeyEnv: ENV_KEY, - model: '', - system: 'sys', - prompt: 'hi', - }), - ).rejects.toThrow(/Unknown AI provider "mystery-vendor"/); - }); - - it('throws a clear error when the api-key env var is unset', async () => { - await expect( - complete({ - provider: 'claude', - apiKeyEnv: ENV_KEY, - model: '', - system: 'sys', - prompt: 'hi', - }), - ).rejects.toThrow(new RegExp(`Environment variable ${ENV_KEY} is not set`)); - }); - - it('throws a not-yet-wired-up error for the openai stub', async () => { - process.env[ENV_KEY] = 'sk-fake'; - await expect( - complete({ - provider: 'openai', - apiKeyEnv: ENV_KEY, - model: '', - system: 'sys', - prompt: 'hi', - }), - ).rejects.toThrow(/openai provider isn't wired up yet/); - }); - - it('throws a not-yet-wired-up error for the gemini stub', async () => { - process.env[ENV_KEY] = 'sk-fake'; - await expect( - complete({ - provider: 'gemini', - apiKeyEnv: ENV_KEY, - model: '', - system: 'sys', - prompt: 'hi', - }), - ).rejects.toThrow(/gemini provider isn't wired up yet/); - }); - - it('checks the env var BEFORE delegating, so unset key beats any other validation', async () => { - // Unset env var should win even when the provider is unknown — env check - // is the cheaper/clearer error message and runs second in the current - // implementation. This test pins that ordering. - await expect( - complete({ - provider: 'mystery-vendor', - apiKeyEnv: ENV_KEY, - model: '', - system: 'sys', - prompt: 'hi', - }), - ).rejects.toThrow(/Unknown AI provider/); - }); -}); diff --git a/test/ai/providers/claude.test.js b/test/ai/providers/claude.test.js deleted file mode 100644 index eca0915..0000000 --- a/test/ai/providers/claude.test.js +++ /dev/null @@ -1,233 +0,0 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; - -// Mock @anthropic-ai/sdk before importing claude.js — vi.mock is hoisted. -const createMock = vi.fn(); -const streamMock = vi.fn(); -const constructorOpts = []; -vi.mock('@anthropic-ai/sdk', () => { - // The SDK's default export is a class; new-ing it must work. - class FakeAnthropic { - constructor(opts) { - constructorOpts.push(opts); - this.messages = { create: createMock, stream: streamMock }; - } - } - return { default: FakeAnthropic }; -}); - -const { complete } = await import('../../../src/ai/providers/claude.js'); - -describe('ai/providers/claude — complete()', () => { - beforeEach(() => { - createMock.mockReset(); - streamMock.mockReset(); - constructorOpts.length = 0; - }); - - it('returns concatenated text from a single text block', async () => { - createMock.mockResolvedValue({ - content: [{ type: 'text', text: 'Hello world.' }], - }); - const out = await complete({ - apiKey: 'sk-fake', - model: '', - system: 'sys', - prompt: 'hi', - }); - expect(out).toBe('Hello world.'); - }); - - it('joins multiple text blocks but ignores non-text blocks', async () => { - createMock.mockResolvedValue({ - content: [ - { type: 'text', text: 'First. ' }, - { type: 'tool_use', id: 'tool-1', input: {} }, - { type: 'text', text: 'Second.' }, - { type: 'thinking', thinking: 'should be ignored' }, - ], - }); - const out = await complete({ - apiKey: 'sk-fake', - model: '', - system: 'sys', - prompt: 'hi', - }); - expect(out).toBe('First. Second.'); - }); - - it('throws when the response has no content array', async () => { - createMock.mockResolvedValue({ content: [] }); - await expect( - complete({ apiKey: 'sk-fake', model: '', system: 's', prompt: 'p' }), - ).rejects.toThrow(/empty response/); - }); - - it('throws when the response contains only non-text blocks', async () => { - createMock.mockResolvedValue({ - content: [{ type: 'tool_use', id: 't', input: {} }], - }); - await expect( - complete({ apiKey: 'sk-fake', model: '', system: 's', prompt: 'p' }), - ).rejects.toThrow(/empty response/); - }); - - it("uses claude-sonnet-4-6 as the default model when none is provided", async () => { - createMock.mockResolvedValue({ - content: [{ type: 'text', text: 'ok' }], - }); - await complete({ apiKey: 'sk', model: '', system: 's', prompt: 'p' }); - const call = createMock.mock.calls.at(-1)[0]; - expect(call.model).toBe('claude-sonnet-4-6'); - }); - - it('respects an explicit model override', async () => { - createMock.mockResolvedValue({ - content: [{ type: 'text', text: 'ok' }], - }); - await complete({ - apiKey: 'sk', - model: 'claude-opus-4-5', - system: 's', - prompt: 'p', - }); - const call = createMock.mock.calls.at(-1)[0]; - expect(call.model).toBe('claude-opus-4-5'); - }); - - it('passes system prompt and user message through unchanged', async () => { - createMock.mockResolvedValue({ - content: [{ type: 'text', text: 'ok' }], - }); - await complete({ - apiKey: 'sk', - model: '', - system: 'system instructions here', - prompt: 'user message body', - }); - const call = createMock.mock.calls.at(-1)[0]; - expect(call.system).toBe('system instructions here'); - expect(call.messages).toEqual([ - { role: 'user', content: 'user message body' }, - ]); - expect(call.max_tokens).toBeGreaterThan(0); - }); - - it('uses 16384 as the default max_tokens (synthesis-friendly)', async () => { - createMock.mockResolvedValue({ content: [{ type: 'text', text: 'ok' }] }); - await complete({ apiKey: 'sk', model: '', system: 's', prompt: 'p' }); - expect(createMock.mock.calls.at(-1)[0].max_tokens).toBe(16384); - }); - - it('respects an explicit maxTokens override', async () => { - createMock.mockResolvedValue({ content: [{ type: 'text', text: 'ok' }] }); - await complete({ - apiKey: 'sk', - model: '', - system: 's', - prompt: 'p', - maxTokens: 4096, - }); - expect(createMock.mock.calls.at(-1)[0].max_tokens).toBe(4096); - }); - - it('configures the SDK with a generous retry budget for transient failures', async () => { - createMock.mockResolvedValue({ content: [{ type: 'text', text: 'ok' }] }); - await complete({ apiKey: 'sk', model: '', system: 's', prompt: 'p' }); - expect(constructorOpts.at(-1)).toEqual( - expect.objectContaining({ apiKey: 'sk', maxRetries: 4 }), - ); - }); - - it('propagates SDK errors so callers can handle them', async () => { - createMock.mockRejectedValue(new Error('429 Too Many Requests')); - await expect( - complete({ apiKey: 'sk', model: '', system: 's', prompt: 'p' }), - ).rejects.toThrow(/429 Too Many Requests/); - }); - - describe('streaming via onToken', () => { - function fakeStream(events) { - return { - async *[Symbol.asyncIterator]() { - for (const e of events) yield e; - }, - }; - } - - it('emits each text_delta to onToken and returns the accumulated string', async () => { - streamMock.mockReturnValue( - fakeStream([ - { type: 'message_start' }, - { type: 'content_block_start' }, - { type: 'content_block_delta', delta: { type: 'text_delta', text: 'Hello ' } }, - { type: 'content_block_delta', delta: { type: 'text_delta', text: 'world.' } }, - { type: 'content_block_stop' }, - { type: 'message_stop' }, - ]), - ); - - const tokens = []; - const result = await complete({ - apiKey: 'sk', - model: '', - system: 's', - prompt: 'p', - onToken: (chunk) => tokens.push(chunk), - }); - - expect(tokens).toEqual(['Hello ', 'world.']); - expect(result).toBe('Hello world.'); - expect(createMock).not.toHaveBeenCalled(); - }); - - it('ignores non-text-delta events while streaming', async () => { - streamMock.mockReturnValue( - fakeStream([ - { type: 'content_block_delta', delta: { type: 'text_delta', text: 'A' } }, - { type: 'content_block_delta', delta: { type: 'input_json_delta', partial_json: '{}' } }, - { type: 'content_block_delta', delta: { type: 'text_delta', text: 'B' } }, - { type: 'message_stop' }, - ]), - ); - - const tokens = []; - const result = await complete({ - apiKey: 'sk', - model: '', - system: 's', - prompt: 'p', - onToken: (chunk) => tokens.push(chunk), - }); - - expect(tokens).toEqual(['A', 'B']); - expect(result).toBe('AB'); - }); - - it('throws when the stream never emitted any text', async () => { - streamMock.mockReturnValue(fakeStream([{ type: 'message_stop' }])); - await expect( - complete({ - apiKey: 'sk', - model: '', - system: 's', - prompt: 'p', - onToken: () => {}, - }), - ).rejects.toThrow(/empty response/); - }); - - it('falls back to non-streaming when no onToken is given', async () => { - createMock.mockResolvedValue({ - content: [{ type: 'text', text: 'whole thing' }], - }); - const result = await complete({ - apiKey: 'sk', - model: '', - system: 's', - prompt: 'p', - }); - expect(result).toBe('whole thing'); - expect(streamMock).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/test/ai/spec-quality.test.js b/test/ai/spec-quality.test.js deleted file mode 100644 index 7012bac..0000000 --- a/test/ai/spec-quality.test.js +++ /dev/null @@ -1,113 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { - SPEC_LANGUAGE_RULES, - EDGE_CASE_DISCIPLINE, -} from '../../src/ai/prompts/spec-quality.js'; -import { - QUESTIONS_SYSTEM, - STACKS_SYSTEM, -} from '../../src/ai/prompts/greenfield.js'; -import { - PLAN_SYSTEM_BROWNFIELD, - PLAN_SYSTEM_GREENFIELD, - SPEC_SYSTEM_BROWNFIELD, - SPEC_SYSTEM_GREENFIELD, -} from '../../src/ai/prompts/new.js'; -import { - SYSTEM_BROWNFIELD as TECH_SYSTEM_BROWNFIELD, - SYSTEM_GREENFIELD as TECH_SYSTEM_GREENFIELD, -} from '../../src/ai/prompts/tech.js'; -import { - SYSTEM_BROWNFIELD as TASKS_SYSTEM_BROWNFIELD, - SYSTEM_GREENFIELD as TASKS_SYSTEM_GREENFIELD, -} from '../../src/ai/prompts/tasks.js'; - -describe('SPEC_LANGUAGE_RULES', () => { - it('is a non-empty string with all seven rules visible', () => { - expect(typeof SPEC_LANGUAGE_RULES).toBe('string'); - expect(SPEC_LANGUAGE_RULES.length).toBeGreaterThan(200); - expect(SPEC_LANGUAGE_RULES).toContain('Specific over generic'); - expect(SPEC_LANGUAGE_RULES).toContain('Active language'); - expect(SPEC_LANGUAGE_RULES).toContain('Same term every time'); - expect(SPEC_LANGUAGE_RULES).toContain('Cut filler'); - expect(SPEC_LANGUAGE_RULES).toContain('Concrete examples'); - expect(SPEC_LANGUAGE_RULES).toContain("Don't blame users"); - expect(SPEC_LANGUAGE_RULES).toContain('Equal-effort sections'); - }); -}); - -describe('EDGE_CASE_DISCIPLINE', () => { - it('names every category an engineer should cover', () => { - expect(typeof EDGE_CASE_DISCIPLINE).toBe('string'); - expect(EDGE_CASE_DISCIPLINE.length).toBeGreaterThan(150); - expect(EDGE_CASE_DISCIPLINE).toContain('Empty data'); - expect(EDGE_CASE_DISCIPLINE).toContain('Errors'); - expect(EDGE_CASE_DISCIPLINE).toContain('Loading'); - expect(EDGE_CASE_DISCIPLINE).toContain('Permissions'); - expect(EDGE_CASE_DISCIPLINE).toContain('Concurrency'); - expect(EDGE_CASE_DISCIPLINE).toContain('Large data'); - }); -}); - -describe('SPEC_LANGUAGE_RULES injection', () => { - // Sentinel chosen to be specific to spec-quality, not principles. - const sentinel = 'Specific over generic'; - - // Synthesis prompts (the ones that draft prose) MUST include the rules. - const includes = [ - ['new SPEC_SYSTEM_BROWNFIELD', SPEC_SYSTEM_BROWNFIELD], - ['new SPEC_SYSTEM_GREENFIELD', SPEC_SYSTEM_GREENFIELD], - ['tech SYSTEM_BROWNFIELD', TECH_SYSTEM_BROWNFIELD], - ['tech SYSTEM_GREENFIELD', TECH_SYSTEM_GREENFIELD], - ]; - - // JSON / plan / list-shaped prompts MUST NOT — these aren't drafting prose, - // and the extra tokens would just dilute the format-specific instructions. - const excludes = [ - ['greenfield QUESTIONS_SYSTEM', QUESTIONS_SYSTEM], - ['greenfield STACKS_SYSTEM', STACKS_SYSTEM], - ['new PLAN_SYSTEM_BROWNFIELD', PLAN_SYSTEM_BROWNFIELD], - ['new PLAN_SYSTEM_GREENFIELD', PLAN_SYSTEM_GREENFIELD], - ['tasks SYSTEM_BROWNFIELD', TASKS_SYSTEM_BROWNFIELD], - ['tasks SYSTEM_GREENFIELD', TASKS_SYSTEM_GREENFIELD], - ]; - - for (const [name, prompt] of includes) { - it(`${name} contains the language rules`, () => { - expect(prompt).toContain(sentinel); - }); - } - - for (const [name, prompt] of excludes) { - it(`${name} does not contain the language rules`, () => { - expect(prompt).not.toContain(sentinel); - }); - } -}); - -describe('EDGE_CASE_DISCIPLINE injection', () => { - // Edge-case discipline is a technical-spec concern only. - const sentinel = 'Edge cases for the technical spec'; - - it('appears in tech SYSTEM_BROWNFIELD', () => { - expect(TECH_SYSTEM_BROWNFIELD).toContain(sentinel); - }); - - it('appears in tech SYSTEM_GREENFIELD', () => { - expect(TECH_SYSTEM_GREENFIELD).toContain(sentinel); - }); - - it('does not appear in product spec prompts', () => { - expect(SPEC_SYSTEM_BROWNFIELD).not.toContain(sentinel); - expect(SPEC_SYSTEM_GREENFIELD).not.toContain(sentinel); - }); - - it('does not appear in plan, tasks, or greenfield discovery prompts', () => { - expect(PLAN_SYSTEM_BROWNFIELD).not.toContain(sentinel); - expect(PLAN_SYSTEM_GREENFIELD).not.toContain(sentinel); - expect(TASKS_SYSTEM_BROWNFIELD).not.toContain(sentinel); - expect(TASKS_SYSTEM_GREENFIELD).not.toContain(sentinel); - expect(QUESTIONS_SYSTEM).not.toContain(sentinel); - expect(STACKS_SYSTEM).not.toContain(sentinel); - }); -}); diff --git a/test/commands/explain.test.js b/test/commands/explain.test.js index f0c1788..0771f6a 100644 --- a/test/commands/explain.test.js +++ b/test/commands/explain.test.js @@ -35,8 +35,7 @@ describe('draftwise explain', () => { cwd: dir, log: (m) => logs.push(m), scan: async () => SAMPLE_SCAN, - loadConfig: async () => ({ mode: 'agent' }), - complete: async () => '', + loadConfig: async () => ({ projectState: 'brownfield' }), }), ).rejects.toThrow(/Missing flow name/); }); @@ -48,8 +47,7 @@ describe('draftwise explain', () => { cwd: dir, log: () => {}, scan: async () => SAMPLE_SCAN, - loadConfig: async () => ({ mode: 'agent' }), - complete: async () => '', + loadConfig: async () => ({ projectState: 'brownfield' }), }), ).rejects.toThrow(/Run `draftwise init` first/); }); @@ -59,8 +57,7 @@ describe('draftwise explain', () => { cwd: dir, log: (m) => logs.push(m), scan: async () => SAMPLE_SCAN, - loadConfig: async () => ({ mode: 'agent' }), - complete: async () => 'unused', + loadConfig: async () => ({ projectState: 'brownfield' }), }); const output = logs.join('\n'); @@ -68,15 +65,12 @@ describe('draftwise explain', () => { expect(output).toContain('user-signup.md'); }); - it('agent mode: prints scanner data + flow + instruction without writing the snapshot', async () => { + it('prints scanner data + flow + instruction without writing the snapshot', async () => { await explainCommand(['login'], { cwd: dir, log: (m) => logs.push(m), scan: async () => SAMPLE_SCAN, - loadConfig: async () => ({ mode: 'agent' }), - complete: async () => { - throw new Error('should not be called in agent mode'); - }, + loadConfig: async () => ({ projectState: 'brownfield' }), }); const output = logs.join('\n'); @@ -91,37 +85,6 @@ describe('draftwise explain', () => { ).rejects.toThrow(); }); - it('api mode: calls the model, prints the walkthrough, and writes the snapshot', async () => { - let captured; - await explainCommand(['login'], { - cwd: dir, - log: (m) => logs.push(m), - scan: async () => SAMPLE_SCAN, - loadConfig: async () => ({ - mode: 'api', - provider: 'claude', - apiKeyEnv: 'ANTHROPIC_API_KEY', - model: '', - }), - complete: async (req) => { - captured = req; - return '# Flow: login\n\nGenerated walkthrough.'; - }, - }); - - expect(captured.system).toContain('Draftwise'); - expect(captured.prompt).toContain('"login"'); - expect(captured.prompt).toContain('Express'); - // explain wires onToken so the walkthrough streams to stdout live. - expect(typeof captured.onToken).toBe('function'); - - const saved = await readFile( - join(dir, '.draftwise', 'flows', 'login.md'), - 'utf8', - ); - expect(saved).toContain('# Flow: login'); - }); - it('short-circuits in greenfield mode with a friendly message', async () => { let scanCalled = false; await explainCommand(['login'], { @@ -131,10 +94,7 @@ describe('draftwise explain', () => { scanCalled = true; return SAMPLE_SCAN; }, - loadConfig: async () => ({ mode: 'agent', projectState: 'greenfield' }), - complete: async () => { - throw new Error('should not be called in greenfield'); - }, + loadConfig: async () => ({ projectState: 'greenfield' }), }); expect(scanCalled).toBe(false); expect(logs.join('\n')).toContain('No code yet'); @@ -146,8 +106,7 @@ describe('draftwise explain', () => { cwd: dir, log: () => {}, scan: async () => ({ ...SAMPLE_SCAN, files: [] }), - loadConfig: async () => ({ mode: 'agent' }), - complete: async () => '', + loadConfig: async () => ({ projectState: 'brownfield' }), }), ).rejects.toThrow(/No source files/); }); diff --git a/test/commands/init.test.js b/test/commands/init.test.js index 2a15052..9af2b74 100644 --- a/test/commands/init.test.js +++ b/test/commands/init.test.js @@ -12,71 +12,6 @@ const interactiveTrue = () => true; const interactiveFalse = () => false; const detectBrownfield = async () => 'brownfield'; -const detectGreenfield = async () => 'greenfield'; - -function makeBrownfieldPrompts({ mode, provider, apiKeyEnv } = {}) { - return { - promptProjectState: async () => 'brownfield', - promptMode: async () => mode, - promptProvider: async () => provider, - promptApiKeyEnv: async ({ suggested }) => apiKeyEnv ?? suggested, - }; -} - -function makeGreenfieldPrompts({ - mode, - provider, - apiKeyEnv, - idea, - answers = [], - pickedStack, -}) { - let qIndex = 0; - return { - promptProjectState: async () => 'greenfield', - promptMode: async () => mode, - promptProvider: async () => provider, - promptApiKeyEnv: async ({ suggested }) => apiKeyEnv ?? suggested, - promptIdea: async () => idea, - askGreenfieldQuestion: async () => answers[qIndex++] ?? '', - pickStack: async () => pickedStack, - }; -} - -const SAMPLE_QUESTIONS = { - project_title: 'Recipe sharing app', - questions: [ - { text: 'Public or private recipes?', why: 'auth/sharing model' }, - { text: 'Mobile-first or web-first?', why: 'frontend choice' }, - ], -}; - -const SAMPLE_STACKS = { - stack_options: [ - { - name: 'Next.js + Postgres + Prisma', - summary: 'Full-stack web with strong type safety.', - rationale: 'You said web-first; Next gives SSR + API routes in one repo.', - pros: ['One framework, one deploy', 'Great DX with Prisma'], - cons: ['React learning curve', 'Vercel coupling on hosting'], - directory_structure: '```\napp/\n├── page.tsx\n└── api/\n```', - initial_files: [ - { path: 'app/page.tsx', purpose: 'home route' }, - ], - setup_commands: ['npx create-next-app@latest .'], - }, - { - name: 'Remix + Postgres + Drizzle', - summary: 'Web-standards-first stack.', - rationale: 'Form-heavy app — Remix actions are a clean fit.', - pros: ['Web fundamentals', 'No hidden magic'], - cons: ['Smaller ecosystem'], - directory_structure: '```\napp/\n└── routes/\n```', - initial_files: [], - setup_commands: ['npx create-remix@latest'], - }, - ], -}; describe('draftwise init', () => { let dir; @@ -96,19 +31,18 @@ describe('draftwise init', () => { init([], { cwd: dir, log: () => {}, - prompts: makeBrownfieldPrompts({ mode: 'agent' }), + prompts: { promptIdea: async () => 'unused' }, isInteractive: interactiveTrue, scan: fakeScan(['src/foo.js']), }), ).rejects.toThrow(/already exists/); }); - describe('brownfield path (interactive)', () => { - it('creates .draftwise/ skeleton in agent mode and saves project state', async () => { + describe('brownfield path', () => { + it('writes the .draftwise/ skeleton with project.state and no ai block', async () => { await init([], { cwd: dir, log: () => {}, - prompts: makeBrownfieldPrompts({ mode: 'agent' }), isInteractive: interactiveTrue, detectProjectState: detectBrownfield, scan: fakeScan(['src/foo.js', 'src/bar.ts']), @@ -122,67 +56,20 @@ describe('draftwise init', () => { expect(overview).toContain('Codebase overview'); const config = await readFile(join(drafts, 'config.yaml'), 'utf8'); - expect(config).toContain('mode: agent'); expect(config).toContain('state: brownfield'); + expect(config).not.toContain('ai:'); expect(config).not.toContain('provider:'); + expect(config).not.toContain('mode:'); const gitignore = await readFile(join(drafts, '.gitignore'), 'utf8'); expect(gitignore).toContain('.cache/'); }); - it('writes provider and api_key_env in api mode', async () => { - await init([], { - cwd: dir, - log: () => {}, - prompts: makeBrownfieldPrompts({ - mode: 'api', - provider: 'claude', - }), - isInteractive: interactiveTrue, - detectProjectState: detectBrownfield, - scan: fakeScan(['src/foo.js']), - }); - - const config = await readFile( - join(dir, '.draftwise', 'config.yaml'), - 'utf8', - ); - expect(config).toContain('mode: api'); - expect(config).toContain('provider: claude'); - expect(config).toContain('api_key_env: ANTHROPIC_API_KEY'); - expect(config).toContain('state: brownfield'); - }); - - it('respects a custom api_key_env name', async () => { - await init([], { - cwd: dir, - log: () => {}, - prompts: makeBrownfieldPrompts({ - mode: 'api', - provider: 'openai', - apiKeyEnv: 'WORK_OPENAI_KEY', - }), - isInteractive: interactiveTrue, - detectProjectState: detectBrownfield, - scan: fakeScan(['src/foo.js']), - }); - - const config = await readFile( - join(dir, '.draftwise', 'config.yaml'), - 'utf8', - ); - expect(config).toContain('provider: openai'); - expect(config).toContain('api_key_env: WORK_OPENAI_KEY'); - }); - it('errors when --mode=brownfield is forced but the repo has no source files', async () => { - // The user explicitly overrode auto-detection with --mode=brownfield - // even though the repo is empty. Brownfield then bails on the scan. await expect( init(['--mode=brownfield'], { cwd: dir, log: () => {}, - prompts: makeBrownfieldPrompts({ mode: 'agent' }), isInteractive: interactiveTrue, scan: fakeScan([]), }), @@ -190,21 +77,14 @@ describe('draftwise init', () => { }); }); - describe('greenfield path (interactive)', () => { - it('agent mode prints the conversation instruction and writes a placeholder', async () => { + describe('greenfield path', () => { + it('prints the conversation instruction and writes a placeholder', async () => { const logs = []; - await init([], { + await init(['--mode=greenfield', '--idea=a recipe sharing app for home cooks'], { cwd: dir, log: (m) => logs.push(m), - prompts: makeGreenfieldPrompts({ - mode: 'agent', - idea: 'a recipe sharing app for home cooks', - }), isInteractive: interactiveTrue, scan: fakeScan([]), - complete: async () => { - throw new Error('should not be called in agent mode'); - }, }); const output = logs.join('\n'); @@ -225,85 +105,40 @@ describe('draftwise init', () => { join(dir, '.draftwise', 'config.yaml'), 'utf8', ); - expect(config).toContain('mode: agent'); expect(config).toContain('state: greenfield'); + expect(config).not.toContain('ai:'); expect(config).not.toContain('stack:'); }); - it('api mode walks questions, picks a stack, and writes the full plan', async () => { - let callCount = 0; - const captured = []; - await init([], { + it('prompts for --idea when interactive and not supplied', async () => { + let promptCalls = 0; + await init(['--mode=greenfield'], { cwd: dir, log: () => {}, - prompts: makeGreenfieldPrompts({ - mode: 'api', - provider: 'claude', - apiKeyEnv: 'ANTHROPIC_API_KEY', - idea: 'a recipe sharing app for home cooks', - answers: ['Public', 'Web-first'], - pickedStack: 'Next.js + Postgres + Prisma', - }), isInteractive: interactiveTrue, - scan: fakeScan([]), - complete: async (req) => { - callCount++; - captured.push(req); - if (callCount === 1) { - return '```json\n' + JSON.stringify(SAMPLE_QUESTIONS) + '\n```'; - } - return '```json\n' + JSON.stringify(SAMPLE_STACKS) + '\n```'; + prompts: { + promptIdea: async () => { + promptCalls++; + return 'a brand new idea'; + }, }, + scan: fakeScan([]), }); - expect(callCount).toBe(2); - expect(captured[0].system).toContain('clarifying questions'); - expect(captured[0].prompt).toContain( - 'a recipe sharing app for home cooks', - ); - expect(captured[1].system).toContain('2-3 stack options'); - expect(captured[1].prompt).toContain('Recipe sharing app'); - expect(captured[1].prompt).toContain('Public'); - + expect(promptCalls).toBe(1); const overview = await readFile( join(dir, '.draftwise', 'overview.md'), 'utf8', ); - expect(overview).toContain('Recipe sharing app'); - expect(overview).toContain('Next.js + Postgres + Prisma'); - expect(overview).toContain('Public'); - expect(overview).toContain('npx create-next-app'); - expect(overview).toContain('### Pros'); - expect(overview).toContain('### Cons'); - - const config = await readFile( - join(dir, '.draftwise', 'config.yaml'), - 'utf8', - ); - expect(config).toContain('state: greenfield'); - expect(config).toContain('Next.js + Postgres + Prisma'); - - const scaffoldRaw = await readFile( - join(dir, '.draftwise', 'scaffold.json'), - 'utf8', - ); - const scaffold = JSON.parse(scaffoldRaw); - expect(scaffold.stack).toBe('Next.js + Postgres + Prisma'); - expect(scaffold.initial_files).toHaveLength(1); - expect(scaffold.setup_commands).toContain('npx create-next-app@latest .'); + expect(overview).toContain('a brand new idea'); }); it('does NOT require source files (empty repo is fine)', async () => { - await init([], { + await init(['--mode=greenfield', '--idea=a brand new idea'], { cwd: dir, log: () => {}, - prompts: makeGreenfieldPrompts({ - mode: 'agent', - idea: 'a brand new idea', - }), isInteractive: interactiveTrue, scan: fakeScan([]), - complete: async () => '', }); await access(join(dir, '.draftwise', 'overview.md')); @@ -315,23 +150,16 @@ describe('draftwise init', () => { const fail = () => { throw new Error('inquirer prompt fired in non-TTY test'); }; - return { - promptProjectState: fail, - promptMode: fail, - promptProvider: fail, - promptApiKeyEnv: fail, - promptIdea: fail, - askGreenfieldQuestion: fail, - pickStack: fail, - }; + return { promptIdea: fail }; } - it('brownfield + agent runs end-to-end with flags only', async () => { - await init(['--mode=brownfield', '--ai-mode=agent'], { + it('brownfield runs end-to-end with no flags (no questions to ask)', async () => { + await init([], { cwd: dir, log: () => {}, isInteractive: interactiveFalse, prompts: noPrompts(), + detectProjectState: detectBrownfield, scan: fakeScan(['src/foo.js']), }); @@ -339,67 +167,17 @@ describe('draftwise init', () => { join(dir, '.draftwise', 'config.yaml'), 'utf8', ); - expect(config).toContain('mode: agent'); expect(config).toContain('state: brownfield'); }); - it('brownfield + api with custom env var, no inquirer fired', async () => { - await init( - [ - '--mode=brownfield', - '--ai-mode=api', - '--provider=claude', - '--api-key-env=MY_ANTHROPIC_KEY', - ], - { - cwd: dir, - log: () => {}, - isInteractive: interactiveFalse, - prompts: noPrompts(), - scan: fakeScan(['src/foo.js']), - }, - ); - - const config = await readFile( - join(dir, '.draftwise', 'config.yaml'), - 'utf8', - ); - expect(config).toContain('api_key_env: MY_ANTHROPIC_KEY'); - }); - - it('api mode without --api-key-env defaults to provider standard env var', async () => { - await init( - ['--mode=brownfield', '--ai-mode=api', '--provider=claude'], - { - cwd: dir, - log: () => {}, - isInteractive: interactiveFalse, - prompts: noPrompts(), - scan: fakeScan(['src/foo.js']), - }, - ); - - const config = await readFile( - join(dir, '.draftwise', 'config.yaml'), - 'utf8', - ); - expect(config).toContain('api_key_env: ANTHROPIC_API_KEY'); - }); - - it('greenfield + agent + --idea writes the placeholder plan', async () => { - await init( - ['--mode=greenfield', '--ai-mode=agent', '--idea=A new idea'], - { - cwd: dir, - log: () => {}, - isInteractive: interactiveFalse, - prompts: noPrompts(), - scan: fakeScan([]), - complete: async () => { - throw new Error('should not be called in agent mode'); - }, - }, - ); + it('greenfield + --idea writes the placeholder plan', async () => { + await init(['--mode=greenfield', '--idea=A new idea'], { + cwd: dir, + log: () => {}, + isInteractive: interactiveFalse, + prompts: noPrompts(), + scan: fakeScan([]), + }); const overview = await readFile( join(dir, '.draftwise', 'overview.md'), @@ -408,190 +186,30 @@ describe('draftwise init', () => { expect(overview).toContain('A new idea'); }); - it('greenfield + api + --idea + --stack runs end-to-end with no prompts', async () => { - let callCount = 0; - await init( - [ - '--mode=greenfield', - '--ai-mode=api', - '--provider=claude', - '--idea=a recipe sharing app', - '--stack=Next.js + Postgres + Prisma', - ], - { - cwd: dir, - log: () => {}, - isInteractive: interactiveFalse, - prompts: noPrompts(), - scan: fakeScan([]), - complete: async () => { - callCount++; - if (callCount === 1) { - return '```json\n' + JSON.stringify(SAMPLE_QUESTIONS) + '\n```'; - } - return '```json\n' + JSON.stringify(SAMPLE_STACKS) + '\n```'; - }, - }, - ); - - const config = await readFile( - join(dir, '.draftwise', 'config.yaml'), - 'utf8', - ); - expect(config).toContain('Next.js + Postgres + Prisma'); - }); - - it('greenfield + api with --answers @file populates the model prompt', async () => { - const answersPath = join(dir, 'answers.json'); - await writeFile( - answersPath, - JSON.stringify(['Private', 'Mobile-first']), - 'utf8', - ); - - const captured = []; - let callCount = 0; - await init( - [ - '--mode=greenfield', - '--ai-mode=api', - '--provider=claude', - '--idea=a fitness app', - `--answers=@${answersPath}`, - ], - { - cwd: dir, - log: () => {}, - isInteractive: interactiveFalse, - prompts: noPrompts(), - scan: fakeScan([]), - complete: async (req) => { - captured.push(req); - callCount++; - if (callCount === 1) { - return '```json\n' + JSON.stringify(SAMPLE_QUESTIONS) + '\n```'; - } - return '```json\n' + JSON.stringify(SAMPLE_STACKS) + '\n```'; - }, - }, - ); - - // Stack-options call should include the supplied answers - expect(captured[1].prompt).toContain('Private'); - expect(captured[1].prompt).toContain('Mobile-first'); - }); - - it('greenfield + api without --stack picks the first option', async () => { - let callCount = 0; - await init( - [ - '--mode=greenfield', - '--ai-mode=api', - '--provider=claude', - '--idea=a thing', - ], - { - cwd: dir, - log: () => {}, - isInteractive: interactiveFalse, - prompts: noPrompts(), - scan: fakeScan([]), - complete: async () => { - callCount++; - if (callCount === 1) { - return '```json\n' + JSON.stringify(SAMPLE_QUESTIONS) + '\n```'; - } - return '```json\n' + JSON.stringify(SAMPLE_STACKS) + '\n```'; - }, - }, - ); - - const config = await readFile( - join(dir, '.draftwise', 'config.yaml'), - 'utf8', - ); - expect(config).toContain('Next.js + Postgres + Prisma'); - }); - - it('prints the structured handoff when no flags supplied (instead of erroring)', async () => { + it('prints the structured handoff when greenfield + non-TTY + no --idea', async () => { const logs = []; await init([], { cwd: dir, log: (m) => logs.push(m), isInteractive: interactiveFalse, prompts: noPrompts(), - scan: fakeScan(['src/foo.js']), + scan: fakeScan([]), }); const out = logs.join('\n'); expect(out).toContain('coding agent should pick this up'); - expect(out).toContain('INIT — answer these in chat'); - // Project state is auto-detected, not asked: empty dir → greenfield. + expect(out).toContain('INIT — answer'); expect(out).toContain('Detected: new project'); - // AI mode + provider + idea are still open questions. - expect(out).toContain('AI mode'); - expect(out).toContain('AI provider'); expect(out).toContain('What do you want to build'); expect(out).toContain('INSTRUCTION'); - expect(out).toContain('draftwise init --ai-mode='); + expect(out).toContain('draftwise init --mode=greenfield --idea='); - // No files written — init bailed before scanning or writing. + // No files written — init bailed before writing. await expect( readFile(join(dir, '.draftwise', 'config.yaml'), 'utf8'), ).rejects.toThrow(); }); - it('handoff omits questions for fields already supplied', async () => { - const logs = []; - await init(['--mode=brownfield'], { - cwd: dir, - log: (m) => logs.push(m), - isInteractive: interactiveFalse, - prompts: noPrompts(), - scan: fakeScan(['src/foo.js']), - }); - - const out = logs.join('\n'); - // Only --ai-mode is missing — handoff should ask for that and not for - // idea (irrelevant once mode=brownfield). Project state was set via - // --mode flag, so it's announced in the orienting line, not asked. - expect(out).toContain('AI mode'); - expect(out).toContain('Project state set via --mode'); - expect(out).not.toContain('What do you want to build'); - }); - - it('handoff fires when api + non-TTY + no --provider', async () => { - const logs = []; - await init(['--mode=brownfield', '--ai-mode=api'], { - cwd: dir, - log: (m) => logs.push(m), - isInteractive: interactiveFalse, - prompts: noPrompts(), - scan: fakeScan(['src/foo.js']), - }); - - const out = logs.join('\n'); - expect(out).toContain('AI provider'); - expect(out).toContain('Project state set via --mode'); - // AI mode question (em-dash form) is not asked — flag was supplied. - expect(out).not.toContain('AI mode —'); - }); - - it('handoff fires when greenfield + non-TTY + no --idea', async () => { - const logs = []; - await init(['--mode=greenfield', '--ai-mode=agent'], { - cwd: dir, - log: (m) => logs.push(m), - isInteractive: interactiveFalse, - prompts: noPrompts(), - scan: fakeScan([]), - }); - - const out = logs.join('\n'); - expect(out).toContain('What do you want to build'); - expect(out).toContain('Project state set via --mode'); - }); - it('still throws (does NOT handoff) on a .draftwise/ that already exists', async () => { await mkdir(join(dir, '.draftwise')); await expect( @@ -607,7 +225,7 @@ describe('draftwise init', () => { it('rejects --mode with an invalid value', async () => { await expect( - init(['--mode=halffield', '--ai-mode=agent'], { + init(['--mode=halffield'], { cwd: dir, log: () => {}, isInteractive: interactiveFalse, @@ -619,7 +237,7 @@ describe('draftwise init', () => { it('rejects unknown flags via parseArgs strict mode', async () => { await expect( - init(['--mode=brownfield', '--ai-mode=agent', '--bogus=yes'], { + init(['--mode=brownfield', '--bogus=yes'], { cwd: dir, log: () => {}, isInteractive: interactiveFalse, @@ -628,48 +246,16 @@ describe('draftwise init', () => { }), ).rejects.toThrow(/Invalid arguments to draftwise init/); }); - - it('rejects --stack that does not match any proposed option', async () => { - let callCount = 0; - await expect( - init( - [ - '--mode=greenfield', - '--ai-mode=api', - '--provider=claude', - '--idea=a thing', - '--stack=NotARealStack', - ], - { - cwd: dir, - log: () => {}, - isInteractive: interactiveFalse, - prompts: noPrompts(), - scan: fakeScan([]), - complete: async () => { - callCount++; - if (callCount === 1) { - return '```json\n' + JSON.stringify(SAMPLE_QUESTIONS) + '\n```'; - } - return '```json\n' + JSON.stringify(SAMPLE_STACKS) + '\n```'; - }, - }, - ), - ).rejects.toThrow(/--stack "NotARealStack" doesn't match/); - }); }); describe('auto-detect project state', () => { it('auto-detects brownfield from on-disk source files when --mode is omitted', async () => { - // Seed a real source file so the default detector returns brownfield. await writeFile(join(dir, 'index.ts'), 'export {};', 'utf8'); const logs = []; - await init(['--ai-mode=agent'], { + await init([], { cwd: dir, log: (m) => logs.push(m), - // Use the real detector — no detectProjectState injection. - prompts: makeBrownfieldPrompts({ mode: 'agent' }), isInteractive: interactiveTrue, scan: fakeScan(['index.ts']), }); @@ -685,15 +271,10 @@ describe('draftwise init', () => { }); it('auto-detects greenfield when the cwd has no source files', async () => { - // Empty dir — no source files anywhere. Real detector returns greenfield. const logs = []; - await init(['--ai-mode=agent', '--idea=a thing'], { + await init(['--idea=a thing'], { cwd: dir, log: (m) => logs.push(m), - prompts: makeGreenfieldPrompts({ - mode: 'agent', - idea: 'a thing', - }), isInteractive: interactiveTrue, scan: fakeScan([]), }); @@ -709,23 +290,15 @@ describe('draftwise init', () => { }); it('--mode flag wins over filesystem detection', async () => { - // Seed source files but force greenfield via flag. await writeFile(join(dir, 'app.py'), 'print(1)', 'utf8'); const logs = []; - await init( - ['--mode=greenfield', '--ai-mode=agent', '--idea=a thing'], - { - cwd: dir, - log: (m) => logs.push(m), - prompts: makeGreenfieldPrompts({ - mode: 'agent', - idea: 'a thing', - }), - isInteractive: interactiveTrue, - scan: fakeScan(['app.py']), - }, - ); + await init(['--mode=greenfield', '--idea=a thing'], { + cwd: dir, + log: (m) => logs.push(m), + isInteractive: interactiveTrue, + scan: fakeScan(['app.py']), + }); const config = await readFile( join(dir, '.draftwise', 'config.yaml'), diff --git a/test/commands/new.test.js b/test/commands/new.test.js index 6ac27a3..acbc9b3 100644 --- a/test/commands/new.test.js +++ b/test/commands/new.test.js @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { mkdtemp, rm, mkdir, readFile, writeFile } from 'node:fs/promises'; +import { mkdtemp, rm, mkdir, readFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import newCommand from '../../src/commands/new.js'; @@ -19,38 +19,6 @@ const SAMPLE_SCAN = { models: [{ name: 'Album', file: 'prisma/schema.prisma', fields: ['id', 'title'] }], }; -const SAMPLE_PLAN = { - feature_slug: 'collab-albums', - feature_title: 'Collaborative Albums', - affected_flows: [ - { - name: 'album-create', - files: ['src/api/albums.ts'], - impact: 'now accepts collaborator IDs', - }, - ], - clarifying_questions: [ - { text: 'Who can invite?', why: 'permissions are unclear' }, - { text: 'Notification on invite?', why: 'no notification system in scanner' }, - ], - adjacent_opportunities: [ - { - flow: 'sharing', - suggestion: 'unify share + invite', - rationale: 'avoid drift', - }, - ], -}; - -function fakePrompts({ answers, decisions }) { - let qIndex = 0; - let dIndex = 0; - return { - askQuestion: async () => answers[qIndex++], - decideOpportunity: async () => decisions[dIndex++], - }; -} - describe('draftwise new', () => { let dir; let logs; @@ -71,8 +39,7 @@ describe('draftwise new', () => { cwd: dir, log: () => {}, scan: async () => SAMPLE_SCAN, - loadConfig: async () => ({ mode: 'agent' }), - complete: async () => '', + loadConfig: async () => ({ projectState: 'brownfield' }), }), ).rejects.toThrow(/Missing idea/); }); @@ -84,21 +51,17 @@ describe('draftwise new', () => { cwd: dir, log: () => {}, scan: async () => SAMPLE_SCAN, - loadConfig: async () => ({ mode: 'agent' }), - complete: async () => '', + loadConfig: async () => ({ projectState: 'brownfield' }), }), ).rejects.toThrow(/Run `draftwise init` first/); }); - it('agent mode dumps scanner data + idea + 3-phase instruction without writing the spec', async () => { + it('dumps scanner data + idea + 3-phase instruction without writing the spec', async () => { await newCommand(['add', 'collab', 'albums'], { cwd: dir, log: (m) => logs.push(m), scan: async () => SAMPLE_SCAN, - loadConfig: async () => ({ mode: 'agent' }), - complete: async () => { - throw new Error('should not be called in agent mode'); - }, + loadConfig: async () => ({ projectState: 'brownfield' }), }); const output = logs.join('\n'); @@ -114,292 +77,18 @@ describe('draftwise new', () => { ).rejects.toThrow(); }); - it('api mode walks the user through Q&A then writes the spec', async () => { - let callCount = 0; - const captured = []; - await newCommand(['add', 'collab', 'albums'], { - cwd: dir, - log: (m) => logs.push(m), - scan: async () => SAMPLE_SCAN, - loadConfig: async () => ({ - mode: 'api', - provider: 'claude', - apiKeyEnv: 'ANTHROPIC_API_KEY', - model: '', - }), - complete: async (req) => { - callCount++; - captured.push(req); - if (callCount === 1) { - return '```json\n' + JSON.stringify(SAMPLE_PLAN) + '\n```'; - } - return '# Collaborative Albums\n\nFinal spec body.'; - }, - prompts: fakePrompts({ - answers: ['Anyone in the album', 'Yes — email + in-app'], - decisions: ['accepted'], - }), - }); - - expect(callCount).toBe(2); - expect(captured[0].system).toContain('plan the conversation'); - expect(captured[1].system).toContain('product-spec.md'); - expect(captured[1].prompt).toContain('Anyone in the album'); - expect(captured[1].prompt).toContain('"decision": "accepted"'); - // The synthesis call (#1) streams; the plan call (#0) does not. - expect(typeof captured[1].onToken).toBe('function'); - expect(captured[0].onToken).toBeUndefined(); - - const spec = await readFile( - join(dir, '.draftwise', 'specs', 'collab-albums', 'product-spec.md'), - 'utf8', - ); - expect(spec).toContain('# Collaborative Albums'); - }); - - it('api mode handles plans with no adjacent opportunities', async () => { - const planWithoutOpportunities = { - ...SAMPLE_PLAN, - adjacent_opportunities: [], - }; - - let callCount = 0; - await newCommand(['idea'], { - cwd: dir, - log: () => {}, - scan: async () => SAMPLE_SCAN, - loadConfig: async () => ({ - mode: 'api', - provider: 'claude', - apiKeyEnv: 'ANTHROPIC_API_KEY', - model: '', - }), - complete: async () => { - callCount++; - if (callCount === 1) { - return JSON.stringify(planWithoutOpportunities); - } - return '# Spec\n'; - }, - prompts: fakePrompts({ - answers: ['', ''], - decisions: [], - }), - }); - - expect(callCount).toBe(2); - const spec = await readFile( - join(dir, '.draftwise', 'specs', 'collab-albums', 'product-spec.md'), - 'utf8', - ); - expect(spec).toContain('# Spec'); - }); - - it('prompts before overwriting an existing product-spec.md, and bails if user declines', async () => { - // Pre-seed the spec dir with a hand-edited file the user would lose. - const specDir = join(dir, '.draftwise', 'specs', 'collab-albums'); - await mkdir(specDir, { recursive: true }); - await writeFile( - join(specDir, 'product-spec.md'), - '# Hand-edited product spec\n', - 'utf8', - ); - - let callCount = 0; - let promptCalls = 0; - - await newCommand(['add', 'collab', 'albums'], { - cwd: dir, - log: (m) => logs.push(m), - scan: async () => SAMPLE_SCAN, - loadConfig: async () => ({ - mode: 'api', - provider: 'claude', - apiKeyEnv: 'ANTHROPIC_API_KEY', - model: '', - }), - complete: async () => { - callCount++; - if (callCount === 1) { - return '```json\n' + JSON.stringify(SAMPLE_PLAN) + '\n```'; - } - // Synthesis call must NOT fire when the user declines overwrite. - throw new Error('synthesis call should not happen when user cancels'); - }, - prompts: { - ...fakePrompts({ answers: [], decisions: [] }), - confirmOverwrite: async () => { - promptCalls++; - return false; - }, - }, - }); - - expect(callCount).toBe(1); // plan only, no synthesis - expect(promptCalls).toBe(1); - expect(logs.join('\n')).toContain('Cancelled'); - - const spec = await readFile(join(specDir, 'product-spec.md'), 'utf8'); - expect(spec).toBe('# Hand-edited product spec\n'); - }); - - it('overwrites product-spec.md when user confirms the prompt', async () => { - const specDir = join(dir, '.draftwise', 'specs', 'collab-albums'); - await mkdir(specDir, { recursive: true }); - await writeFile( - join(specDir, 'product-spec.md'), - '# Old product spec\n', - 'utf8', - ); - - let callCount = 0; - - await newCommand(['add', 'collab', 'albums'], { - cwd: dir, - log: () => {}, - scan: async () => SAMPLE_SCAN, - loadConfig: async () => ({ - mode: 'api', - provider: 'claude', - apiKeyEnv: 'ANTHROPIC_API_KEY', - model: '', - }), - complete: async () => { - callCount++; - if (callCount === 1) { - return JSON.stringify(SAMPLE_PLAN); - } - return '# Regenerated product spec'; - }, - prompts: { - ...fakePrompts({ - answers: ['Anyone in the album', 'Yes'], - decisions: ['declined'], - }), - confirmOverwrite: async () => true, - }, - }); - - expect(callCount).toBe(2); - const spec = await readFile(join(specDir, 'product-spec.md'), 'utf8'); - expect(spec).toBe('# Regenerated product spec'); - }); - - it('overwrites product-spec.md without prompting when --force is passed', async () => { - const specDir = join(dir, '.draftwise', 'specs', 'collab-albums'); - await mkdir(specDir, { recursive: true }); - await writeFile( - join(specDir, 'product-spec.md'), - '# Old', - 'utf8', - ); - - let callCount = 0; - let promptCalls = 0; - - await newCommand(['--force', 'add', 'collab', 'albums'], { - cwd: dir, - log: () => {}, - scan: async () => SAMPLE_SCAN, - loadConfig: async () => ({ - mode: 'api', - provider: 'claude', - apiKeyEnv: 'ANTHROPIC_API_KEY', - model: '', - }), - complete: async () => { - callCount++; - if (callCount === 1) return JSON.stringify(SAMPLE_PLAN); - return '# Regenerated'; - }, - prompts: { - ...fakePrompts({ - answers: ['', ''], - decisions: ['declined'], - }), - confirmOverwrite: async () => { - promptCalls++; - return false; - }, - }, - }); - - expect(promptCalls).toBe(0); - expect(callCount).toBe(2); - const spec = await readFile(join(specDir, 'product-spec.md'), 'utf8'); - expect(spec).toBe('# Regenerated'); - }); - - it('greenfield api mode: skips scanner, reads overview, calls greenfield prompts', async () => { - let scanCalled = false; - let callCount = 0; - const captured = []; - - const greenfieldPlan = { - feature_slug: 'recipe-uploads', - feature_title: 'Recipe Uploads', - clarifying_questions: [ - { text: 'Photos required?', why: 'data model decision' }, - ], - }; - - await newCommand(['add', 'recipe', 'uploads'], { - cwd: dir, - log: () => {}, - scan: async () => { - scanCalled = true; - return SAMPLE_SCAN; - }, - loadConfig: async () => ({ - mode: 'api', - provider: 'claude', - apiKeyEnv: 'ANTHROPIC_API_KEY', - model: '', - projectState: 'greenfield', - }), - readOverview: async () => '# Recipe app — Greenfield plan\n\nNext.js + Postgres + Prisma\n', - complete: async (req) => { - callCount++; - captured.push(req); - if (callCount === 1) return JSON.stringify(greenfieldPlan); - return '# Recipe Uploads\n\nGreenfield spec body.'; - }, - prompts: fakePrompts({ answers: ['Yes'], decisions: [] }), - }); - - expect(scanCalled).toBe(false); - expect(callCount).toBe(2); - expect(captured[0].system).toContain('GREENFIELD'); - expect(captured[0].prompt).toContain('Recipe app'); - expect(captured[1].system).toContain('GREENFIELD'); - expect(captured[1].prompt).not.toContain('"affected_flows"'); - - const spec = await readFile( - join(dir, '.draftwise', 'specs', 'recipe-uploads', 'product-spec.md'), - 'utf8', - ); - expect(spec).toContain('# Recipe Uploads'); - }); - - it('greenfield agent mode: dumps PROJECT PLAN instead of SCANNER OUTPUT', async () => { - const localLogs = []; + it('greenfield: dumps PROJECT PLAN instead of SCANNER OUTPUT', async () => { await newCommand(['recipe', 'app'], { cwd: dir, - log: (m) => localLogs.push(m), + log: (m) => logs.push(m), scan: async () => { throw new Error('scan should not be called in greenfield'); }, - loadConfig: async () => ({ - mode: 'agent', - projectState: 'greenfield', - }), + loadConfig: async () => ({ projectState: 'greenfield' }), readOverview: async () => '# Recipe app — Greenfield plan\n', - complete: async () => { - throw new Error('complete should not be called in agent mode'); - }, }); - const out = localLogs.join('\n'); + const out = logs.join('\n'); expect(out).toContain('PROJECT PLAN'); expect(out).not.toContain('SCANNER OUTPUT'); expect(out).toContain('Recipe app'); @@ -411,140 +100,9 @@ describe('draftwise new', () => { cwd: dir, log: () => {}, scan: async () => SAMPLE_SCAN, - loadConfig: async () => ({ - mode: 'agent', - projectState: 'greenfield', - }), + loadConfig: async () => ({ projectState: 'greenfield' }), readOverview: async () => '', - complete: async () => '', }), ).rejects.toThrow(/overview\.md is missing or empty/); }); - - describe('non-TTY (flags-driven)', () => { - function noPrompts() { - const fail = () => { - throw new Error('inquirer prompt fired in non-TTY test'); - }; - return { - askQuestion: fail, - decideOpportunity: fail, - confirmOverwrite: fail, - }; - } - - it('runs end-to-end with --answers, no prompts fired', async () => { - let callCount = 0; - const captured = []; - await newCommand( - [ - 'add', - 'collab', - 'albums', - '--answers', - JSON.stringify(['Anyone in the album', 'Yes']), - ], - { - cwd: dir, - log: () => {}, - isInteractive: () => false, - prompts: noPrompts(), - scan: async () => SAMPLE_SCAN, - loadConfig: async () => ({ - mode: 'api', - provider: 'claude', - apiKeyEnv: 'ANTHROPIC_API_KEY', - model: '', - }), - complete: async (req) => { - callCount++; - captured.push(req); - if (callCount === 1) return JSON.stringify(SAMPLE_PLAN); - return '# Spec body\n'; - }, - }, - ); - - expect(callCount).toBe(2); - expect(captured[1].prompt).toContain('Anyone in the album'); - // Adjacent opportunity declined automatically in non-TTY - expect(captured[1].prompt).toContain('"decision": "declined"'); - }); - - it('runs without --answers, leaves questions blank and declines opportunities', async () => { - let callCount = 0; - const captured = []; - await newCommand(['idea'], { - cwd: dir, - log: () => {}, - isInteractive: () => false, - prompts: noPrompts(), - scan: async () => SAMPLE_SCAN, - loadConfig: async () => ({ - mode: 'api', - provider: 'claude', - apiKeyEnv: 'ANTHROPIC_API_KEY', - model: '', - }), - complete: async (req) => { - callCount++; - captured.push(req); - if (callCount === 1) return JSON.stringify(SAMPLE_PLAN); - return '# Spec\n'; - }, - }); - - expect(callCount).toBe(2); - expect(captured[1].prompt).toContain('"decision": "declined"'); - }); - - it('errors when product-spec.md exists in non-TTY without --force', async () => { - const specDir = join(dir, '.draftwise', 'specs', 'collab-albums'); - await mkdir(specDir, { recursive: true }); - await writeFile(join(specDir, 'product-spec.md'), '# existing\n', 'utf8'); - - let callCount = 0; - await expect( - newCommand(['add', 'collab', 'albums'], { - cwd: dir, - log: () => {}, - isInteractive: () => false, - prompts: noPrompts(), - scan: async () => SAMPLE_SCAN, - loadConfig: async () => ({ - mode: 'api', - provider: 'claude', - apiKeyEnv: 'ANTHROPIC_API_KEY', - model: '', - }), - complete: async () => { - callCount++; - return JSON.stringify(SAMPLE_PLAN); - }, - }), - ).rejects.toThrow(/already exists\. Pass --force/); - - // Plan ran but synthesis didn't. - expect(callCount).toBe(1); - }); - - it('rejects --answers with bad JSON', async () => { - await expect( - newCommand(['idea', '--answers=not-json{'], { - cwd: dir, - log: () => {}, - isInteractive: () => false, - prompts: noPrompts(), - scan: async () => SAMPLE_SCAN, - loadConfig: async () => ({ - mode: 'api', - provider: 'claude', - apiKeyEnv: 'ANTHROPIC_API_KEY', - model: '', - }), - complete: async () => JSON.stringify(SAMPLE_PLAN), - }), - ).rejects.toThrow(/--answers must be a JSON array/); - }); - }); }); diff --git a/test/commands/scan.test.js b/test/commands/scan.test.js index 1688ecc..0f48b2f 100644 --- a/test/commands/scan.test.js +++ b/test/commands/scan.test.js @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { mkdtemp, rm, mkdir, writeFile, readFile } from 'node:fs/promises'; +import { mkdtemp, rm, mkdir, readFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import scanCommand from '../../src/commands/scan.js'; @@ -36,21 +36,17 @@ describe('draftwise scan', () => { cwd: dir, log: (m) => logs.push(m), scan: async () => SAMPLE_SCAN, - loadConfig: async () => ({ mode: 'agent' }), - complete: async () => 'unused', + loadConfig: async () => ({ projectState: 'brownfield' }), }), ).rejects.toThrow(/Run `draftwise init` first/); }); - it('in agent mode, prints scanner data and instruction without writing overview.md', async () => { + it('prints scanner data and instruction without writing overview.md', async () => { await scanCommand([], { cwd: dir, log: (m) => logs.push(m), scan: async () => SAMPLE_SCAN, - loadConfig: async () => ({ mode: 'agent' }), - complete: async () => { - throw new Error('should not be called in agent mode'); - }, + loadConfig: async () => ({ projectState: 'brownfield' }), }); const output = logs.join('\n'); @@ -64,39 +60,6 @@ describe('draftwise scan', () => { await expect(readFile(join(dir, '.draftwise', 'overview.md'))).rejects.toThrow(); }); - it('in api mode, calls the model and writes overview.md', async () => { - let captured; - await scanCommand([], { - cwd: dir, - log: (m) => logs.push(m), - scan: async () => SAMPLE_SCAN, - loadConfig: async () => ({ - mode: 'api', - provider: 'claude', - apiKeyEnv: 'ANTHROPIC_API_KEY', - model: '', - }), - complete: async (args) => { - captured = args; - return '# Overview\n\nGenerated content here.'; - }, - }); - - expect(captured.provider).toBe('claude'); - expect(captured.system).toContain('Draftwise'); - expect(captured.prompt).toContain('Next.js'); - // scan streams the overview live to stdout while it generates. - expect(typeof captured.onToken).toBe('function'); - - const overview = await readFile(join(dir, '.draftwise', 'overview.md'), 'utf8'); - expect(overview).toContain('# Overview'); - - // First-time users need a nudge toward the next command after scan. - const out = logs.join('\n'); - expect(out).toContain('Next:'); - expect(out).toContain('draftwise new'); - }); - it('short-circuits in greenfield mode with a friendly message', async () => { let scanCalled = false; await scanCommand([], { @@ -106,10 +69,7 @@ describe('draftwise scan', () => { scanCalled = true; return SAMPLE_SCAN; }, - loadConfig: async () => ({ mode: 'agent', projectState: 'greenfield' }), - complete: async () => { - throw new Error('should not be called in greenfield'); - }, + loadConfig: async () => ({ projectState: 'greenfield' }), }); expect(scanCalled).toBe(false); @@ -124,8 +84,7 @@ describe('draftwise scan', () => { cwd: dir, log: () => {}, scan: async () => ({ ...SAMPLE_SCAN, files: [] }), - loadConfig: async () => ({ mode: 'agent' }), - complete: async () => '', + loadConfig: async () => ({ projectState: 'brownfield' }), }), ).rejects.toThrow(/No source files/); }); diff --git a/test/commands/tasks.test.js b/test/commands/tasks.test.js index 3d41ee2..0f6ac69 100644 --- a/test/commands/tasks.test.js +++ b/test/commands/tasks.test.js @@ -54,8 +54,7 @@ describe('draftwise tasks', () => { cwd: dir, log: () => {}, scan: async () => SAMPLE_SCAN, - loadConfig: async () => ({ mode: 'agent' }), - complete: async () => '', + loadConfig: async () => ({ projectState: 'brownfield' }), }), ).rejects.toThrow(/Run `draftwise init` first/); }); @@ -67,8 +66,7 @@ describe('draftwise tasks', () => { cwd: dir, log: () => {}, scan: async () => SAMPLE_SCAN, - loadConfig: async () => ({ mode: 'agent' }), - complete: async () => '', + loadConfig: async () => ({ projectState: 'brownfield' }), }), ).rejects.toThrow(/No technical specs found/); }); @@ -76,34 +74,14 @@ describe('draftwise tasks', () => { it('auto-picks the only spec with a technical-spec.md', async () => { await seedSpec(dir, 'collab-albums', { technical: '# Tech\n\nReal.' }); - let captured; await tasksCommand([], { cwd: dir, log: (m) => logs.push(m), scan: async () => SAMPLE_SCAN, - loadConfig: async () => ({ - mode: 'api', - provider: 'claude', - apiKeyEnv: 'ANTHROPIC_API_KEY', - model: '', - }), - complete: async (req) => { - captured = req; - return '# Tasks\n\n1. Schema change'; - }, + loadConfig: async () => ({ projectState: 'brownfield' }), }); expect(logs.join('\n')).toContain('Using the only technical spec: collab-albums'); - expect(captured.system).toContain('tasks.md'); - expect(captured.prompt).toContain('# Tech'); - // tasks streams the synthesis live to stdout. - expect(typeof captured.onToken).toBe('function'); - - const tasks = await readFile( - join(dir, '.draftwise', 'specs', 'collab-albums', 'tasks.md'), - 'utf8', - ); - expect(tasks).toContain('# Tasks'); }); it('uses the slug arg when given', async () => { @@ -112,22 +90,12 @@ describe('draftwise tasks', () => { await tasksCommand(['beta'], { cwd: dir, - log: () => {}, + log: (m) => logs.push(m), scan: async () => SAMPLE_SCAN, - loadConfig: async () => ({ - mode: 'api', - provider: 'claude', - apiKeyEnv: 'ANTHROPIC_API_KEY', - model: '', - }), - complete: async () => '# Beta tasks', + loadConfig: async () => ({ projectState: 'brownfield' }), }); - const tasks = await readFile( - join(dir, '.draftwise', 'specs', 'beta', 'tasks.md'), - 'utf8', - ); - expect(tasks).toBe('# Beta tasks'); + expect(logs.join('\n')).toContain('SPEC: beta'); }); it('errors when an unknown slug is requested', async () => { @@ -137,8 +105,7 @@ describe('draftwise tasks', () => { cwd: dir, log: () => {}, scan: async () => SAMPLE_SCAN, - loadConfig: async () => ({ mode: 'agent' }), - complete: async () => '', + loadConfig: async () => ({ projectState: 'brownfield' }), }), ).rejects.toThrow(/No technical spec found for "ghost"/); }); @@ -151,32 +118,20 @@ describe('draftwise tasks', () => { cwd: dir, log: (m) => logs.push(m), scan: async () => SAMPLE_SCAN, - loadConfig: async () => ({ - mode: 'api', - provider: 'claude', - apiKeyEnv: 'ANTHROPIC_API_KEY', - model: '', - }), - complete: async () => '# Tasks', + loadConfig: async () => ({ projectState: 'brownfield' }), }); expect(logs.join('\n')).toContain('Using the only technical spec: beta'); - await expect( - readFile(join(dir, '.draftwise', 'specs', 'alpha', 'tasks.md')), - ).rejects.toThrow(); }); - it('agent mode dumps technical spec + scanner + instruction without writing', async () => { + it('dumps technical spec + scanner + instruction without writing', async () => { await seedSpec(dir, 'collab-albums', { technical: '# Tech\n\nReal stuff.' }); await tasksCommand([], { cwd: dir, log: (m) => logs.push(m), scan: async () => SAMPLE_SCAN, - loadConfig: async () => ({ mode: 'agent' }), - complete: async () => { - throw new Error('should not be called in agent mode'); - }, + loadConfig: async () => ({ projectState: 'brownfield' }), }); const output = logs.join('\n'); @@ -200,174 +155,31 @@ describe('draftwise tasks', () => { cwd: dir, log: () => {}, scan: async () => SAMPLE_SCAN, - loadConfig: async () => ({ mode: 'agent' }), - complete: async () => '', + loadConfig: async () => ({ projectState: 'brownfield' }), }), ).rejects.toThrow(/empty/); }); - it('greenfield: skips scanner, reads overview, uses greenfield prompts', async () => { + it('greenfield: skips scanner, reads overview, dumps PROJECT PLAN', async () => { await seedSpec(dir, 'collab-albums', { technical: '# Tech\n\nGreenfield.' }); let scanCalled = false; - let captured; - await tasksCommand([], { cwd: dir, - log: () => {}, + log: (m) => logs.push(m), scan: async () => { scanCalled = true; return SAMPLE_SCAN; }, - loadConfig: async () => ({ - mode: 'api', - provider: 'claude', - apiKeyEnv: 'ANTHROPIC_API_KEY', - model: '', - projectState: 'greenfield', - }), + loadConfig: async () => ({ projectState: 'greenfield' }), readOverview: async () => '# Plan\n\nNext.js + Prisma\n', - complete: async (req) => { - captured = req; - return '# Tasks\n\n1. Scaffold project'; - }, }); expect(scanCalled).toBe(false); - expect(captured.system).toContain('GREENFIELD'); - expect(captured.prompt).toContain('Plan'); - expect(captured.prompt).not.toContain('"frameworks"'); - - const tasks = await readFile( - join(dir, '.draftwise', 'specs', 'collab-albums', 'tasks.md'), - 'utf8', - ); - expect(tasks).toContain('Scaffold project'); - }); - - it('prompts before overwriting an existing tasks.md, and bails if user declines', async () => { - const specDir = await seedSpec(dir, 'collab-albums'); - await writeFile( - join(specDir, 'tasks.md'), - '# Hand-edited tasks\n', - 'utf8', - ); - - let promptCalls = 0; - let completeCalled = false; - - await tasksCommand([], { - cwd: dir, - log: (m) => logs.push(m), - scan: async () => SAMPLE_SCAN, - loadConfig: async () => ({ - mode: 'api', - provider: 'claude', - apiKeyEnv: 'ANTHROPIC_API_KEY', - model: '', - }), - complete: async () => { - completeCalled = true; - return '# Regenerated tasks'; - }, - prompts: { - confirmOverwrite: async () => { - promptCalls++; - return false; - }, - }, - }); - - expect(promptCalls).toBe(1); - expect(completeCalled).toBe(false); - expect(logs.join('\n')).toContain('Cancelled'); - - const tasks = await readFile(join(specDir, 'tasks.md'), 'utf8'); - expect(tasks).toBe('# Hand-edited tasks\n'); - }); - - it('overwrites tasks.md without prompting when --force is passed', async () => { - const specDir = await seedSpec(dir, 'collab-albums'); - await writeFile( - join(specDir, 'tasks.md'), - '# Hand-edited tasks\n', - 'utf8', - ); - - let promptCalls = 0; - - await tasksCommand(['--force'], { - cwd: dir, - log: () => {}, - scan: async () => SAMPLE_SCAN, - loadConfig: async () => ({ - mode: 'api', - provider: 'claude', - apiKeyEnv: 'ANTHROPIC_API_KEY', - model: '', - }), - complete: async () => '# Regenerated tasks', - prompts: { - confirmOverwrite: async () => { - promptCalls++; - return false; - }, - }, - }); - - expect(promptCalls).toBe(0); - const tasks = await readFile(join(specDir, 'tasks.md'), 'utf8'); - expect(tasks).toBe('# Regenerated tasks'); - }); - - it('agent mode does not trigger the overwrite prompt for tasks.md', async () => { - const specDir = await seedSpec(dir, 'collab-albums'); - await writeFile(join(specDir, 'tasks.md'), '# Old', 'utf8'); - - let promptCalls = 0; - - await tasksCommand([], { - cwd: dir, - log: () => {}, - scan: async () => SAMPLE_SCAN, - loadConfig: async () => ({ mode: 'agent' }), - complete: async () => '', - prompts: { - confirmOverwrite: async () => { - promptCalls++; - return false; - }, - }, - }); - - expect(promptCalls).toBe(0); - const tasks = await readFile(join(specDir, 'tasks.md'), 'utf8'); - expect(tasks).toBe('# Old'); - }); - - it('greenfield agent mode: dumps PROJECT PLAN instead of SCANNER OUTPUT', async () => { - await seedSpec(dir, 'collab-albums', { technical: '# Tech' }); - - const localLogs = []; - await tasksCommand([], { - cwd: dir, - log: (m) => localLogs.push(m), - scan: async () => { - throw new Error('should not be called in greenfield agent mode'); - }, - loadConfig: async () => ({ - mode: 'agent', - projectState: 'greenfield', - }), - readOverview: async () => '# Plan\n\nNext.js + Prisma\n', - complete: async () => { - throw new Error('should not be called in agent mode'); - }, - }); - - const out = localLogs.join('\n'); + const out = logs.join('\n'); expect(out).toContain('PROJECT PLAN'); expect(out).not.toContain('SCANNER OUTPUT'); + expect(out).toContain('Plan'); }); describe('non-TTY (flags-driven)', () => { @@ -375,7 +187,7 @@ describe('draftwise tasks', () => { const fail = () => { throw new Error('inquirer prompt fired in non-TTY test'); }; - return { pickSpec: fail, confirmOverwrite: fail }; + return { pickSpec: fail }; } it('errors when multiple specs exist and no slug arg, instead of prompting', async () => { @@ -389,43 +201,11 @@ describe('draftwise tasks', () => { isInteractive: () => false, prompts: noPrompts(), scan: async () => SAMPLE_SCAN, - loadConfig: async () => ({ - mode: 'api', - provider: 'claude', - apiKeyEnv: 'ANTHROPIC_API_KEY', - model: '', - }), - complete: async () => '# Tasks', + loadConfig: async () => ({ projectState: 'brownfield' }), }), ).rejects.toThrow( /Multiple technical specs.*Available:.*collab-albums/, ); }); - - it('errors when tasks.md exists in non-TTY without --force', async () => { - const specDir = await seedSpec(dir, 'collab-albums', { technical: '# T' }); - const tasksPath = join(specDir, 'tasks.md'); - await writeFile(tasksPath, '# Hand-edited tasks\n', 'utf8'); - - await expect( - tasksCommand([], { - cwd: dir, - log: () => {}, - isInteractive: () => false, - prompts: noPrompts(), - scan: async () => SAMPLE_SCAN, - loadConfig: async () => ({ - mode: 'api', - provider: 'claude', - apiKeyEnv: 'ANTHROPIC_API_KEY', - model: '', - }), - complete: async () => '# Regenerated', - }), - ).rejects.toThrow(/already exists\. Pass --force/); - - const preserved = await readFile(tasksPath, 'utf8'); - expect(preserved).toBe('# Hand-edited tasks\n'); - }); }); }); diff --git a/test/commands/tech.test.js b/test/commands/tech.test.js index 9ae034a..fec5109 100644 --- a/test/commands/tech.test.js +++ b/test/commands/tech.test.js @@ -43,8 +43,7 @@ describe('draftwise tech', () => { cwd: dir, log: () => {}, scan: async () => SAMPLE_SCAN, - loadConfig: async () => ({ mode: 'agent' }), - complete: async () => '', + loadConfig: async () => ({ projectState: 'brownfield' }), }), ).rejects.toThrow(/Run `draftwise init` first/); }); @@ -55,42 +54,21 @@ describe('draftwise tech', () => { cwd: dir, log: () => {}, scan: async () => SAMPLE_SCAN, - loadConfig: async () => ({ mode: 'agent' }), - complete: async () => '', + loadConfig: async () => ({ projectState: 'brownfield' }), }), ).rejects.toThrow(/No product specs found/); }); it('auto-picks the only spec when there is exactly one', async () => { await seedSpec(dir, 'collab-albums'); - let captured; await techCommand([], { cwd: dir, log: (m) => logs.push(m), scan: async () => SAMPLE_SCAN, - loadConfig: async () => ({ - mode: 'api', - provider: 'claude', - apiKeyEnv: 'ANTHROPIC_API_KEY', - model: '', - }), - complete: async (req) => { - captured = req; - return '# Tech\n\nWritten.'; - }, + loadConfig: async () => ({ projectState: 'brownfield' }), }); expect(logs.join('\n')).toContain('Using the only product spec: collab-albums'); - expect(captured.system).toContain('technical-spec.md'); - expect(captured.prompt).toContain('# Product Spec'); - // tech streams the synthesis live to stdout. - expect(typeof captured.onToken).toBe('function'); - - const tech = await readFile( - join(dir, '.draftwise', 'specs', 'collab-albums', 'technical-spec.md'), - 'utf8', - ); - expect(tech).toBe('# Tech\n\nWritten.'); }); it('uses the slug arg to pick a specific spec when given', async () => { @@ -99,26 +77,12 @@ describe('draftwise tech', () => { await techCommand(['beta'], { cwd: dir, - log: () => {}, + log: (m) => logs.push(m), scan: async () => SAMPLE_SCAN, - loadConfig: async () => ({ - mode: 'api', - provider: 'claude', - apiKeyEnv: 'ANTHROPIC_API_KEY', - model: '', - }), - complete: async () => '# Beta tech', + loadConfig: async () => ({ projectState: 'brownfield' }), }); - const tech = await readFile( - join(dir, '.draftwise', 'specs', 'beta', 'technical-spec.md'), - 'utf8', - ); - expect(tech).toBe('# Beta tech'); - - await expect( - readFile(join(dir, '.draftwise', 'specs', 'alpha', 'technical-spec.md')), - ).rejects.toThrow(); + expect(logs.join('\n')).toContain('SPEC: beta'); }); it('errors when an unknown slug is requested', async () => { @@ -129,8 +93,7 @@ describe('draftwise tech', () => { cwd: dir, log: () => {}, scan: async () => SAMPLE_SCAN, - loadConfig: async () => ({ mode: 'agent' }), - complete: async () => '', + loadConfig: async () => ({ projectState: 'brownfield' }), }), ).rejects.toThrow(/No product spec found for "ghost"/); }); @@ -141,36 +104,23 @@ describe('draftwise tech', () => { await techCommand([], { cwd: dir, - log: () => {}, + log: (m) => logs.push(m), scan: async () => SAMPLE_SCAN, - loadConfig: async () => ({ - mode: 'api', - provider: 'claude', - apiKeyEnv: 'ANTHROPIC_API_KEY', - model: '', - }), - complete: async () => '# Beta tech', + loadConfig: async () => ({ projectState: 'brownfield' }), prompts: { pickSpec: async () => 'beta' }, }); - const tech = await readFile( - join(dir, '.draftwise', 'specs', 'beta', 'technical-spec.md'), - 'utf8', - ); - expect(tech).toBe('# Beta tech'); + expect(logs.join('\n')).toContain('SPEC: beta'); }); - it('agent mode dumps product spec + scanner + instruction without writing', async () => { + it('dumps product spec + scanner + instruction without writing', async () => { await seedSpec(dir, 'collab-albums', '# Product\n\nThe spec.'); await techCommand([], { cwd: dir, log: (m) => logs.push(m), scan: async () => SAMPLE_SCAN, - loadConfig: async () => ({ mode: 'agent' }), - complete: async () => { - throw new Error('should not be called in agent mode'); - }, + loadConfig: async () => ({ projectState: 'brownfield' }), }); const output = logs.join('\n'); @@ -194,217 +144,31 @@ describe('draftwise tech', () => { cwd: dir, log: () => {}, scan: async () => SAMPLE_SCAN, - loadConfig: async () => ({ mode: 'agent' }), - complete: async () => '', + loadConfig: async () => ({ projectState: 'brownfield' }), }), ).rejects.toThrow(/empty/); }); - it('greenfield: skips scanner, reads overview, uses greenfield prompts', async () => { - await seedSpec(dir, 'collab-albums', '# Product\n\nGreenfield product.'); + it('greenfield: skips scanner, reads overview, dumps PROJECT PLAN', async () => { + await seedSpec(dir, 'collab-albums', '# Product'); let scanCalled = false; - let captured; - await techCommand([], { cwd: dir, - log: () => {}, + log: (m) => logs.push(m), scan: async () => { scanCalled = true; return SAMPLE_SCAN; }, - loadConfig: async () => ({ - mode: 'api', - provider: 'claude', - apiKeyEnv: 'ANTHROPIC_API_KEY', - model: '', - projectState: 'greenfield', - }), + loadConfig: async () => ({ projectState: 'greenfield' }), readOverview: async () => '# Plan\n\nNext.js + Prisma\n', - complete: async (req) => { - captured = req; - return '# Tech\n\nWith (new) markers.'; - }, }); expect(scanCalled).toBe(false); - expect(captured.system).toContain('GREENFIELD'); - expect(captured.prompt).toContain('Plan'); - expect(captured.prompt).not.toContain('"frameworks"'); - - const tech = await readFile( - join(dir, '.draftwise', 'specs', 'collab-albums', 'technical-spec.md'), - 'utf8', - ); - expect(tech).toContain('# Tech'); - }); - - it('prompts before overwriting an existing technical-spec.md, and bails if user declines', async () => { - const specDir = await seedSpec(dir, 'collab-albums'); - await writeFile( - join(specDir, 'technical-spec.md'), - '# Hand-edited tech spec\n', - 'utf8', - ); - - let promptCalls = 0; - let completeCalled = false; - - await techCommand([], { - cwd: dir, - log: (m) => logs.push(m), - scan: async () => SAMPLE_SCAN, - loadConfig: async () => ({ - mode: 'api', - provider: 'claude', - apiKeyEnv: 'ANTHROPIC_API_KEY', - model: '', - }), - complete: async () => { - completeCalled = true; - return '# Regenerated tech'; - }, - prompts: { - confirmOverwrite: async () => { - promptCalls++; - return false; - }, - }, - }); - - expect(promptCalls).toBe(1); - expect(completeCalled).toBe(false); - expect(logs.join('\n')).toContain('Cancelled'); - - // The hand-edited spec must remain on disk untouched. - const tech = await readFile( - join(specDir, 'technical-spec.md'), - 'utf8', - ); - expect(tech).toBe('# Hand-edited tech spec\n'); - }); - - it('overwrites without prompting when --force is passed', async () => { - const specDir = await seedSpec(dir, 'collab-albums'); - await writeFile( - join(specDir, 'technical-spec.md'), - '# Hand-edited tech spec\n', - 'utf8', - ); - - let promptCalls = 0; - - await techCommand(['--force'], { - cwd: dir, - log: () => {}, - scan: async () => SAMPLE_SCAN, - loadConfig: async () => ({ - mode: 'api', - provider: 'claude', - apiKeyEnv: 'ANTHROPIC_API_KEY', - model: '', - }), - complete: async () => '# Regenerated tech', - prompts: { - confirmOverwrite: async () => { - promptCalls++; - return false; - }, - }, - }); - - expect(promptCalls).toBe(0); - - const tech = await readFile( - join(specDir, 'technical-spec.md'), - 'utf8', - ); - expect(tech).toBe('# Regenerated tech'); - }); - - it('--force works in any arg position (before or after the slug)', async () => { - await seedSpec(dir, 'alpha'); - const betaDir = await seedSpec(dir, 'beta'); - await writeFile( - join(betaDir, 'technical-spec.md'), - 'old', - 'utf8', - ); - - await techCommand(['--force', 'beta'], { - cwd: dir, - log: () => {}, - scan: async () => SAMPLE_SCAN, - loadConfig: async () => ({ - mode: 'api', - provider: 'claude', - apiKeyEnv: 'ANTHROPIC_API_KEY', - model: '', - }), - complete: async () => '# new', - }); - - const tech = await readFile(join(betaDir, 'technical-spec.md'), 'utf8'); - expect(tech).toBe('# new'); - }); - - it('agent mode does not trigger the overwrite prompt (host agent writes the file)', async () => { - const specDir = await seedSpec(dir, 'collab-albums', '# Product'); - await writeFile( - join(specDir, 'technical-spec.md'), - '# Old', - 'utf8', - ); - - let promptCalls = 0; - - await techCommand([], { - cwd: dir, - log: () => {}, - scan: async () => SAMPLE_SCAN, - loadConfig: async () => ({ mode: 'agent' }), - complete: async () => '', - prompts: { - confirmOverwrite: async () => { - promptCalls++; - return false; - }, - }, - }); - - expect(promptCalls).toBe(0); - // Existing file should still be on disk — agent mode never writes from - // the CLI; the host agent will handle it. - const tech = await readFile( - join(specDir, 'technical-spec.md'), - 'utf8', - ); - expect(tech).toBe('# Old'); - }); - - it('greenfield agent mode: dumps PROJECT PLAN instead of SCANNER OUTPUT', async () => { - await seedSpec(dir, 'collab-albums', '# Product'); - - const localLogs = []; - await techCommand([], { - cwd: dir, - log: (m) => localLogs.push(m), - scan: async () => { - throw new Error('should not be called in greenfield agent mode'); - }, - loadConfig: async () => ({ - mode: 'agent', - projectState: 'greenfield', - }), - readOverview: async () => '# Plan\n\nNext.js + Prisma\n', - complete: async () => { - throw new Error('should not be called in agent mode'); - }, - }); - - const out = localLogs.join('\n'); + const out = logs.join('\n'); expect(out).toContain('PROJECT PLAN'); expect(out).not.toContain('SCANNER OUTPUT'); + expect(out).toContain('Plan'); }); describe('non-TTY (flags-driven)', () => { @@ -412,7 +176,7 @@ describe('draftwise tech', () => { const fail = () => { throw new Error('inquirer prompt fired in non-TTY test'); }; - return { pickSpec: fail, confirmOverwrite: fail }; + return { pickSpec: fail }; } it('errors when multiple specs exist and no slug arg, instead of prompting', async () => { @@ -426,78 +190,9 @@ describe('draftwise tech', () => { isInteractive: () => false, prompts: noPrompts(), scan: async () => SAMPLE_SCAN, - loadConfig: async () => ({ - mode: 'api', - provider: 'claude', - apiKeyEnv: 'ANTHROPIC_API_KEY', - model: '', - }), - complete: async () => '# Tech', + loadConfig: async () => ({ projectState: 'brownfield' }), }), ).rejects.toThrow(/Multiple product specs.*Available:.*collab-albums/); }); - - it('errors when technical-spec.md exists in non-TTY without --force', async () => { - await seedSpec(dir, 'collab-albums', '# Product'); - const techPath = join( - dir, - '.draftwise', - 'specs', - 'collab-albums', - 'technical-spec.md', - ); - await writeFile(techPath, '# Hand-edited tech spec\n', 'utf8'); - - await expect( - techCommand([], { - cwd: dir, - log: () => {}, - isInteractive: () => false, - prompts: noPrompts(), - scan: async () => SAMPLE_SCAN, - loadConfig: async () => ({ - mode: 'api', - provider: 'claude', - apiKeyEnv: 'ANTHROPIC_API_KEY', - model: '', - }), - complete: async () => '# Regenerated', - }), - ).rejects.toThrow(/already exists\. Pass --force/); - - const preserved = await readFile(techPath, 'utf8'); - expect(preserved).toBe('# Hand-edited tech spec\n'); - }); - - it('runs end-to-end with positional slug + --force in non-TTY', async () => { - await seedSpec(dir, 'collab-albums', '# Product'); - await seedSpec(dir, 'photo-uploads', '# Product'); - const techPath = join( - dir, - '.draftwise', - 'specs', - 'collab-albums', - 'technical-spec.md', - ); - await writeFile(techPath, '# Old\n', 'utf8'); - - await techCommand(['collab-albums', '--force'], { - cwd: dir, - log: () => {}, - isInteractive: () => false, - prompts: noPrompts(), - scan: async () => SAMPLE_SCAN, - loadConfig: async () => ({ - mode: 'api', - provider: 'claude', - apiKeyEnv: 'ANTHROPIC_API_KEY', - model: '', - }), - complete: async () => '# New tech spec', - }); - - const written = await readFile(techPath, 'utf8'); - expect(written).toBe('# New tech spec'); - }); }); }); diff --git a/test/integration/pipeline.test.js b/test/integration/pipeline.test.js index bf64864..e022c0c 100644 --- a/test/integration/pipeline.test.js +++ b/test/integration/pipeline.test.js @@ -4,10 +4,9 @@ // seams the unit tests can't (e.g. init writing a config that subsequent // commands fail to parse, list/show finding specs in the wrong shape). // -// Scope: agent mode only for now. Agent mode dumps scanner data + an -// instruction to stdout for the host coding agent — no AI call to mock. -// API mode integration would need canned `complete` responses for each -// phase; layered on later if needed. +// All commands run against a host coding agent — they print scanner data + +// instruction to stdout and let the agent do the writing. Nothing here calls +// an LLM directly. import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { @@ -58,7 +57,7 @@ const FAKE_PACKAGE_JSON = JSON.stringify( 2, ); -describe('integration: pipeline (brownfield, agent mode)', () => { +describe('integration: pipeline (brownfield)', () => { let dir; let logs; @@ -79,14 +78,7 @@ describe('integration: pipeline (brownfield, agent mode)', () => { } it('init creates the .draftwise/ skeleton and a parseable config', async () => { - await init([], { - cwd: dir, - log, - prompts: { - promptProjectState: async () => 'brownfield', - promptMode: async () => 'agent', - }, - }); + await init([], { cwd: dir, log, isInteractive: () => false }); expect(await exists(join(dir, '.draftwise'))).toBe(true); expect(await exists(join(dir, '.draftwise', 'specs'))).toBe(true); @@ -98,21 +90,14 @@ describe('integration: pipeline (brownfield, agent mode)', () => { join(dir, '.draftwise', 'config.yaml'), 'utf8', ); - expect(config).toContain('mode: agent'); expect(config).toContain('state: brownfield'); + expect(config).not.toContain('ai:'); }); it('scan, explain, new — all reach correct agent-mode output', async () => { - await init([], { - cwd: dir, - log, - prompts: { - promptProjectState: async () => 'brownfield', - promptMode: async () => 'agent', - }, - }); - - // scan — agent mode dumps scanner output + instruction. + await init([], { cwd: dir, log, isInteractive: () => false }); + + // scan dumps scanner output + instruction. logs.length = 0; await scan([], { cwd: dir, log }); let out = logs.join('\n'); @@ -139,18 +124,11 @@ describe('integration: pipeline (brownfield, agent mode)', () => { }); it('list and show find specs that the host agent would have written', async () => { - await init([], { - cwd: dir, - log, - prompts: { - promptProjectState: async () => 'brownfield', - promptMode: async () => 'agent', - }, - }); - - // Agent mode doesn't write specs from inside draftwise — the host - // coding agent does. Simulate that step by seeding spec files at - // the same paths the agent would. + await init([], { cwd: dir, log, isInteractive: () => false }); + + // Agent doesn't write specs from inside draftwise — the host coding + // agent does. Simulate that step by seeding spec files at the same + // paths the agent would. const featureDir = join(dir, '.draftwise', 'specs', 'mute-notifications'); await mkdir(featureDir, { recursive: true }); await writeFile( @@ -194,14 +172,7 @@ describe('integration: pipeline (brownfield, agent mode)', () => { }); it('show errors gracefully when the requested type is missing', async () => { - await init([], { - cwd: dir, - log, - prompts: { - promptProjectState: async () => 'brownfield', - promptMode: async () => 'agent', - }, - }); + await init([], { cwd: dir, log, isInteractive: () => false }); const featureDir = join(dir, '.draftwise', 'specs', 'half-baked'); await mkdir(featureDir, { recursive: true }); @@ -217,7 +188,7 @@ describe('integration: pipeline (brownfield, agent mode)', () => { }); }); -describe('integration: pipeline (greenfield, agent mode)', () => { +describe('integration: pipeline (greenfield)', () => { let dir; let logs; @@ -234,16 +205,11 @@ describe('integration: pipeline (greenfield, agent mode)', () => { logs.push(m); } - it('init greenfield + agent → scan / explain short-circuit cleanly', async () => { - await init([], { - cwd: dir, - log, - prompts: { - promptProjectState: async () => 'greenfield', - promptMode: async () => 'agent', - promptIdea: async () => 'a recipe sharing app for home cooks', - }, - }); + it('init greenfield → scan / explain short-circuit cleanly', async () => { + await init( + ['--mode=greenfield', '--idea=a recipe sharing app for home cooks'], + { cwd: dir, log, isInteractive: () => false }, + ); expect(await exists(join(dir, '.draftwise', 'config.yaml'))).toBe(true); const config = await readFile( diff --git a/test/utils/answers-flag.test.js b/test/utils/answers-flag.test.js deleted file mode 100644 index 1f66cf1..0000000 --- a/test/utils/answers-flag.test.js +++ /dev/null @@ -1,60 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { mkdtemp, rm, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { loadAnswersFlag } from '../../src/utils/answers-flag.js'; - -describe('loadAnswersFlag', () => { - let dir; - - beforeEach(async () => { - dir = await mkdtemp(join(tmpdir(), 'draftwise-answers-')); - }); - - afterEach(async () => { - await rm(dir, { recursive: true, force: true }); - }); - - it('returns null when value is undefined', async () => { - expect(await loadAnswersFlag(undefined)).toBeNull(); - }); - - it('returns null when value is empty string', async () => { - expect(await loadAnswersFlag('')).toBeNull(); - }); - - it('parses an inline JSON array of strings', async () => { - expect(await loadAnswersFlag('["one", "two"]')).toEqual(['one', 'two']); - }); - - it('reads and parses a JSON file referenced via @path', async () => { - const path = join(dir, 'answers.json'); - await writeFile(path, '["a", "b", "c"]', 'utf8'); - expect(await loadAnswersFlag(`@${path}`)).toEqual(['a', 'b', 'c']); - }); - - it('throws with the missing path when @file does not exist', async () => { - const path = join(dir, 'nope.json'); - await expect(loadAnswersFlag(`@${path}`)).rejects.toThrow( - /Could not read --answers file/, - ); - }); - - it('throws when JSON is malformed', async () => { - await expect(loadAnswersFlag('not-json')).rejects.toThrow( - /--answers must be a JSON array/, - ); - }); - - it('throws when JSON is not an array', async () => { - await expect(loadAnswersFlag('{"a":1}')).rejects.toThrow( - /--answers must be a JSON array of strings/, - ); - }); - - it('throws when array contains non-string entries', async () => { - await expect(loadAnswersFlag('["ok", 42]')).rejects.toThrow( - /--answers must be a JSON array of strings/, - ); - }); -}); diff --git a/test/utils/config.test.js b/test/utils/config.test.js index 5f21b1d..9ae648f 100644 --- a/test/utils/config.test.js +++ b/test/utils/config.test.js @@ -16,19 +16,14 @@ describe('loadConfig', () => { await rm(dir, { recursive: true, force: true }); }); - it('reads agent mode config', async () => { + it('reads a minimal brownfield config', async () => { await writeFile( join(dir, '.draftwise', 'config.yaml'), - 'ai:\n mode: agent\n', + 'project:\n state: brownfield\n', 'utf8', ); const config = await loadConfig(dir); expect(config).toEqual({ - mode: 'agent', - provider: undefined, - apiKeyEnv: undefined, - model: '', - maxTokens: undefined, projectState: 'brownfield', stack: undefined, scanMaxFiles: undefined, @@ -38,7 +33,7 @@ describe('loadConfig', () => { it('reads project state and stack when present', async () => { await writeFile( join(dir, '.draftwise', 'config.yaml'), - 'ai:\n mode: agent\nproject:\n state: greenfield\n stack: "Next.js + Postgres + Prisma"\n', + 'project:\n state: greenfield\n stack: "Next.js + Postgres + Prisma"\n', 'utf8', ); const config = await loadConfig(dir); @@ -46,12 +41,8 @@ describe('loadConfig', () => { expect(config.stack).toBe('Next.js + Postgres + Prisma'); }); - it('defaults projectState to brownfield when missing (back-compat with v0.0.1 configs)', async () => { - await writeFile( - join(dir, '.draftwise', 'config.yaml'), - 'ai:\n mode: agent\n', - 'utf8', - ); + it('defaults projectState to brownfield when missing', async () => { + await writeFile(join(dir, '.draftwise', 'config.yaml'), '{}\n', 'utf8'); const config = await loadConfig(dir); expect(config.projectState).toBe('brownfield'); }); @@ -59,17 +50,17 @@ describe('loadConfig', () => { it('parses scan.max_files when set', async () => { await writeFile( join(dir, '.draftwise', 'config.yaml'), - 'ai:\n mode: agent\nscan:\n max_files: 10000\n', + 'project:\n state: brownfield\nscan:\n max_files: 10000\n', 'utf8', ); const config = await loadConfig(dir); expect(config.scanMaxFiles).toBe(10000); }); - it('leaves scanMaxFiles undefined when not set (scanner uses its default)', async () => { + it('leaves scanMaxFiles undefined when not set', async () => { await writeFile( join(dir, '.draftwise', 'config.yaml'), - 'ai:\n mode: agent\n', + 'project:\n state: brownfield\n', 'utf8', ); const config = await loadConfig(dir); @@ -79,64 +70,37 @@ describe('loadConfig', () => { it('coerces scan.max_files to a positive integer', async () => { await writeFile( join(dir, '.draftwise', 'config.yaml'), - 'ai:\n mode: agent\nscan:\n max_files: 250.7\n', + 'project:\n state: brownfield\nscan:\n max_files: 250.7\n', 'utf8', ); const config = await loadConfig(dir); expect(config.scanMaxFiles).toBe(250); }); - it('parses ai.max_tokens when set', async () => { + it('logs a notice when an orphaned `ai:` block is present', async () => { await writeFile( join(dir, '.draftwise', 'config.yaml'), - 'ai:\n mode: agent\n max_tokens: 32000\n', + 'ai:\n mode: agent\nproject:\n state: brownfield\n', 'utf8', ); - const config = await loadConfig(dir); - expect(config.maxTokens).toBe(32000); - }); - - it('leaves maxTokens undefined when not set (adapter falls back to default)', async () => { - await writeFile( - join(dir, '.draftwise', 'config.yaml'), - 'ai:\n mode: agent\n', - 'utf8', - ); - const config = await loadConfig(dir); - expect(config.maxTokens).toBeUndefined(); + const messages = []; + const config = await loadConfig(dir, { log: (m) => messages.push(m) }); + expect(config.projectState).toBe('brownfield'); + expect(messages.some((m) => /`ai:` block/.test(m))).toBe(true); }); - it('reads api mode config with provider and api_key_env', async () => { + it('does not log the orphaned-ai notice when no ai block is present', async () => { await writeFile( join(dir, '.draftwise', 'config.yaml'), - 'ai:\n mode: api\n provider: claude\n api_key_env: ANTHROPIC_API_KEY\n model: ""\n', + 'project:\n state: brownfield\n', 'utf8', ); - const config = await loadConfig(dir); - expect(config.mode).toBe('api'); - expect(config.provider).toBe('claude'); - expect(config.apiKeyEnv).toBe('ANTHROPIC_API_KEY'); + const messages = []; + await loadConfig(dir, { log: (m) => messages.push(m) }); + expect(messages.some((m) => /`ai:` block/.test(m))).toBe(false); }); it('errors when config.yaml is missing', async () => { await expect(loadConfig(dir)).rejects.toThrow(/config\.yaml not found/); }); - - it('errors on invalid mode', async () => { - await writeFile( - join(dir, '.draftwise', 'config.yaml'), - 'ai:\n mode: weird\n', - 'utf8', - ); - await expect(loadConfig(dir)).rejects.toThrow(/valid `ai\.mode`/); - }); - - it('errors when api mode is missing api_key_env', async () => { - await writeFile( - join(dir, '.draftwise', 'config.yaml'), - 'ai:\n mode: api\n provider: claude\n', - 'utf8', - ); - await expect(loadConfig(dir)).rejects.toThrow(/api_key_env/); - }); }); diff --git a/test/utils/overwrite-guard.test.js b/test/utils/overwrite-guard.test.js deleted file mode 100644 index 19b22ea..0000000 --- a/test/utils/overwrite-guard.test.js +++ /dev/null @@ -1,102 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { mkdtemp, rm, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { confirmOverwriteOrCancel } from '../../src/utils/overwrite-guard.js'; - -describe('confirmOverwriteOrCancel', () => { - let dir; - let existing; - let absent; - - beforeEach(async () => { - dir = await mkdtemp(join(tmpdir(), 'draftwise-overwrite-')); - existing = join(dir, 'product-spec.md'); - absent = join(dir, 'missing.md'); - await writeFile(existing, '# previously written', 'utf8'); - }); - - afterEach(async () => { - await rm(dir, { recursive: true, force: true }); - }); - - it('returns true when the target does not exist (nothing to overwrite)', async () => { - const confirmOverwrite = vi.fn(); - const proceed = await confirmOverwriteOrCancel({ - targetPath: absent, - slug: 'feat', - file: 'product-spec.md', - force: false, - isInteractive: () => true, - log: () => {}, - confirmOverwrite, - }); - expect(proceed).toBe(true); - expect(confirmOverwrite).not.toHaveBeenCalled(); - }); - - it('returns true when --force is passed, even with an existing file', async () => { - const confirmOverwrite = vi.fn(); - const proceed = await confirmOverwriteOrCancel({ - targetPath: existing, - slug: 'feat', - file: 'product-spec.md', - force: true, - isInteractive: () => true, - log: () => {}, - confirmOverwrite, - }); - expect(proceed).toBe(true); - expect(confirmOverwrite).not.toHaveBeenCalled(); - }); - - it('prompts and returns true when the user confirms in a TTY', async () => { - const confirmOverwrite = vi.fn().mockResolvedValue(true); - const proceed = await confirmOverwriteOrCancel({ - targetPath: existing, - slug: 'feat', - file: 'product-spec.md', - force: false, - isInteractive: () => true, - log: () => {}, - confirmOverwrite, - }); - expect(proceed).toBe(true); - expect(confirmOverwrite).toHaveBeenCalledWith({ - slug: 'feat', - file: 'product-spec.md', - }); - }); - - it('logs the cancel hint and returns false when the user declines in a TTY', async () => { - const confirmOverwrite = vi.fn().mockResolvedValue(false); - const log = vi.fn(); - const proceed = await confirmOverwriteOrCancel({ - targetPath: existing, - slug: 'feat', - file: 'product-spec.md', - force: false, - isInteractive: () => true, - log, - confirmOverwrite, - }); - expect(proceed).toBe(false); - expect(log).toHaveBeenCalledWith( - 'Cancelled. No changes written. (Pass --force to skip this prompt.)', - ); - }); - - it('throws in non-TTY when the file exists and --force wasn\'t passed', async () => { - await expect( - confirmOverwriteOrCancel({ - targetPath: existing, - slug: 'feat', - file: 'product-spec.md', - force: false, - isInteractive: () => false, - log: () => {}, - confirmOverwrite: vi.fn(), - }), - ).rejects.toThrow(/feat\/product-spec\.md already exists\. Pass --force/); - }); -});