Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ Each released version is tagged in git (`v0.0.1`, `v0.1.0`, etc.) and includes t

- **`loadAnswersFlag` extracted into a shared `src/utils/answers-flag.js` helper.** The same `--answers` parsing function (accept inline JSON or `@path/to/file.json`, read the file if `@`-prefixed, JSON-parse, validate it's an array of strings, throw with usage hints on failure) was defined byte-identically in `src/commands/init.js` and `src/commands/new.js` — both consume the flag for clarifying-question answers (greenfield project setup vs feature spec drafting). New module hosts it as a named export. Adds `test/utils/answers-flag.test.js` with eight direct cases (undefined, empty, inline JSON, `@file` happy path, missing `@file`, malformed JSON, non-array JSON, mixed-type array) — previously the function was only exercised indirectly through command-level tests. — Ankur

- **Drafting-command boilerplate extracted into three shared utility helpers.** Three near-identical patterns lived across `new` / `tech` / `tasks` (and a chunk of one across all eight non-init commands): (1) the greenfield/brownfield scan-context branch — read `overview.md` for greenfield, run `scan + compactScan + describeScanWarnings` for brownfield — copy-pasted three times in new/tech/tasks; (2) the overwrite-confirm dance (`if !force && exists { TTY ? confirm or cancel : throw }`) repeated three times with the same default-`false` `confirm({...})` prompt; (3) the `.draftwise/` existence guard (`if (!await pathExists(dir)) throw '.draftwise/ not found...'`) repeated at the top of eight commands. New `src/utils/scan-context.js` exposes `loadScanContext({ cwd, config, log, scan, readOverview, commandName })` returning `{ scanForPrompt, packageMeta, overview }`; `src/utils/overwrite-guard.js` exposes `confirmOverwriteOrCancel({ targetPath, slug, file, force, isInteractive, log, confirmOverwrite })` returning a `proceed` boolean (and throwing in non-TTY without `--force`); `src/utils/draftwise-dir.js` exposes `requireDraftwiseDir(cwd)` returning the resolved path. Each helper has direct test coverage (12 new tests across `test/utils/{scan-context,overwrite-guard,draftwise-dir}.test.js`). Net: production code in command files drops by ~130 lines; the helpers add ~80 lines but consolidate the single source of truth for "how Draftwise loads scan data into a prompt," "how Draftwise guards a hand-edited spec from accidental overwrite," and "how Draftwise refuses to run before init." `init.js` keeps its bespoke "refuse if `.draftwise/` exists" check — opposite invariant. The default `confirm({...})` prompt now lives in the helper module; commands no longer carry per-file `confirmOverwrite` definitions in their `DEFAULT_PROMPTS` (test injection still works through the same `deps.prompts` seam). — Ankur

### Removed

- **Dead backwards-compat aliases on the prompt modules.** `src/ai/prompts/new.js` exported `PLAN_SYSTEM` / `SPEC_SYSTEM` and `src/ai/prompts/{tech,tasks}.js` each exported a top-level `SYSTEM` — three aliases marked "Backwards compatibility — keep the old names alive" that never had real callers. Verified by grep across `src/` and `test/`: every consumer either uses the explicit `_BROWNFIELD` / `_GREENFIELD` constants or the `selectSystem` / `selectPlanSystem` / `selectSpecSystem` helpers. The package hasn't shipped a version where these were the canonical names, so there's no compat to preserve. Pre-publish hygiene: shrink the public API surface before users import what we don't intend to support. `scan.js` and `explain.js` keep their `SYSTEM` exports — those are primary, not aliases, and are imported by the matching command files. — Ankur
Expand Down
7 changes: 2 additions & 5 deletions src/commands/explain.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ 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 { pathExists } from '../utils/fs.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';
Expand Down Expand Up @@ -39,10 +39,7 @@ export default async function explainCommand(args = [], deps = {}) {
);
}

const draftwiseDir = join(cwd, '.draftwise');
if (!(await pathExists(draftwiseDir))) {
throw new Error('.draftwise/ not found. Run `draftwise init` first.');
}
const draftwiseDir = await requireDraftwiseDir(cwd);

const config = await loadConfig(cwd);

Expand Down
8 changes: 2 additions & 6 deletions src/commands/list.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { listSpecs as defaultListSpecs } from '../utils/specs.js';
import { pathExists } from '../utils/fs.js';
import { requireDraftwiseDir } from '../utils/draftwise-dir.js';

export const HELP = `draftwise list — list all specs in .draftwise/specs/

Expand Down Expand Up @@ -42,10 +41,7 @@ export default async function listCommand(_args = [], deps = {}) {
const log = deps.log ?? ((msg) => console.log(msg));
const listSpecs = deps.listSpecs ?? defaultListSpecs;

const draftwiseDir = join(cwd, '.draftwise');
if (!(await pathExists(draftwiseDir))) {
throw new Error('.draftwise/ not found. Run `draftwise init` first.');
}
await requireDraftwiseDir(cwd);

const specs = await listSpecs(cwd);
if (specs.length === 0) {
Expand Down
83 changes: 23 additions & 60 deletions src/commands/new.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import { writeFile, mkdir } from 'node:fs/promises';
import { join } from 'node:path';
import { parseArgs } from 'node:util';
import { input, select, confirm } from '@inquirer/prompts';
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 { describeScanWarnings } from '../utils/scan-warnings.js';
import { pathExists } from '../utils/fs.js';
import { compactScan } from '../utils/scan-projection.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';
Expand Down Expand Up @@ -75,11 +75,6 @@ const DEFAULT_PROMPTS = {
],
default: 'declined',
}),
confirmOverwrite: ({ slug, file }) =>
confirm({
message: `${slug}/${file} already exists. Overwrite?`,
default: false,
}),
};

export default async function newCommand(args = [], deps = {}) {
Expand Down Expand Up @@ -112,44 +107,21 @@ export default async function newCommand(args = [], deps = {}) {
throw new Error('Missing idea. Usage: draftwise new "<your feature idea>"');
}

const draftwiseDir = join(cwd, '.draftwise');
if (!(await pathExists(draftwiseDir))) {
throw new Error('.draftwise/ not found. Run `draftwise init` first.');
}
const draftwiseDir = await requireDraftwiseDir(cwd);

const config = await loadConfig(cwd);
const isGreenfield = config.projectState === 'greenfield';

log(`Idea: "${idea}"`);

let scanForPrompt;
let packageMeta;
let overview;

if (isGreenfield) {
log('Reading project plan from overview.md...');
overview = await readOverview(cwd);
if (!overview.trim()) {
throw new Error(
'Greenfield project but .draftwise/overview.md is missing or empty. Re-run `draftwise init` to generate the plan, or switch the config to brownfield once code exists.',
);
}
scanForPrompt = null;
packageMeta = null;
} else {
log('Scanning repo...');
const result = await scan(cwd, { maxFiles: config.scanMaxFiles });
if (!result.files || result.files.length === 0) {
throw new Error(
`No source files found under ${cwd}. Run \`draftwise new\` from your repo root.`,
);
}
for (const warning of describeScanWarnings(result)) {
log(warning);
}
scanForPrompt = compactScan(result);
packageMeta = result.packageMeta;
}
const { scanForPrompt, packageMeta, overview } = await loadScanContext({
cwd,
config,
log,
scan,
readOverview,
commandName: 'new',
});

if (config.mode === 'agent') {
log('');
Expand Down Expand Up @@ -213,25 +185,16 @@ export default async function newCommand(args = [], deps = {}) {
const slug = slugify(plan.featureSlug);
const specDir = join(draftwiseDir, 'specs', slug);
const productSpecPath = join(specDir, 'product-spec.md');
if (!force && (await pathExists(productSpecPath))) {
if (isInteractive()) {
log('');
const proceed = await prompts.confirmOverwrite({
slug,
file: 'product-spec.md',
});
if (!proceed) {
log(
'Cancelled. No changes written. (Pass --force to skip this prompt.)',
);
return;
}
} else {
throw new Error(
`${slug}/product-spec.md already exists. Pass --force to overwrite (or delete the file first).`,
);
}
}
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('');
Expand Down
6 changes: 2 additions & 4 deletions src/commands/scaffold.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { join, dirname, resolve, sep } from 'node:path';
import { parseArgs } from 'node:util';
import { confirm } from '@inquirer/prompts';
import { pathExists } from '../utils/fs.js';
import { requireDraftwiseDir } from '../utils/draftwise-dir.js';
import { loadConfig as defaultLoadConfig } from '../utils/config.js';
import { isInteractive as defaultIsInteractive } from '../utils/tty.js';

Expand Down Expand Up @@ -89,10 +90,7 @@ export default async function scaffoldCommand(args = [], deps = {}) {
}
const skipConfirm = Boolean(parsed.values.yes);

const draftwiseDir = join(cwd, '.draftwise');
if (!(await pathExists(draftwiseDir))) {
throw new Error('.draftwise/ not found. Run `draftwise init` first.');
}
const draftwiseDir = await requireDraftwiseDir(cwd);

// Short-circuit for brownfield projects — scaffold has nothing to do, and
// the missing-scaffold.json error message would mislead the user toward
Expand Down
9 changes: 2 additions & 7 deletions src/commands/scan.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ 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 { pathExists } from '../utils/fs.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';

Expand Down Expand Up @@ -39,12 +39,7 @@ export default async function scanCommand(_args = [], deps = {}) {
const loadConfig = deps.loadConfig ?? defaultLoadConfig;
const complete = deps.complete ?? defaultComplete;

const draftwiseDir = join(cwd, '.draftwise');
if (!(await pathExists(draftwiseDir))) {
throw new Error(
'.draftwise/ not found. Run `draftwise init` first.',
);
}
const draftwiseDir = await requireDraftwiseDir(cwd);

const config = await loadConfig(cwd);

Expand Down
7 changes: 2 additions & 5 deletions src/commands/show.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { listSpecs as defaultListSpecs } from '../utils/specs.js';
import { pathExists } from '../utils/fs.js';
import { requireDraftwiseDir } from '../utils/draftwise-dir.js';

export const HELP = `draftwise show <feature> [type] — print a spec to terminal

Expand Down Expand Up @@ -50,10 +50,7 @@ export default async function showCommand(args = [], deps = {}) {
);
}

const draftwiseDir = join(cwd, '.draftwise');
if (!(await pathExists(draftwiseDir))) {
throw new Error('.draftwise/ not found. Run `draftwise init` first.');
}
await requireDraftwiseDir(cwd);

const specs = await listSpecs(cwd);
const target = specs.find((s) => s.slug === slug);
Expand Down
87 changes: 24 additions & 63 deletions src/commands/tasks.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
import { readFile, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { parseArgs } from 'node:util';
import { select, confirm } from '@inquirer/prompts';
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 { describeScanWarnings } from '../utils/scan-warnings.js';
import { pathExists } from '../utils/fs.js';
import { compactScan } from '../utils/scan-projection.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 {
Expand Down Expand Up @@ -53,11 +52,6 @@ const DEFAULT_PROMPTS = {
value: s.slug,
})),
}),
confirmOverwrite: ({ slug, file }) =>
confirm({
message: `${slug}/${file} already exists. Overwrite?`,
default: false,
}),
};

export default async function tasksCommand(args = [], deps = {}) {
Expand All @@ -71,10 +65,7 @@ export default async function tasksCommand(args = [], deps = {}) {
const isInteractive = deps.isInteractive ?? defaultIsInteractive;
const prompts = { ...DEFAULT_PROMPTS, ...(deps.prompts ?? {}) };

const draftwiseDir = join(cwd, '.draftwise');
if (!(await pathExists(draftwiseDir))) {
throw new Error('.draftwise/ not found. Run `draftwise init` first.');
}
await requireDraftwiseDir(cwd);

let parsed;
try {
Expand Down Expand Up @@ -134,57 +125,27 @@ 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 (
!force &&
config.mode !== 'agent' &&
(await pathExists(target.tasks))
) {
if (isInteractive()) {
const proceed = await prompts.confirmOverwrite({
slug: target.slug,
file: 'tasks.md',
});
if (!proceed) {
log(
'Cancelled. No changes written. (Pass --force to skip this prompt.)',
);
return;
}
} else {
throw new Error(
`${target.slug}/tasks.md already exists. Pass --force to overwrite.`,
);
}
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;
}

let scanForPrompt;
let packageMeta;
let overview;

if (isGreenfield) {
log('Reading project plan from overview.md...');
overview = await readOverview(cwd);
if (!overview.trim()) {
throw new Error(
'Greenfield project but .draftwise/overview.md is missing or empty. Re-run `draftwise init` to generate the plan.',
);
}
scanForPrompt = null;
packageMeta = null;
} else {
log('Scanning repo...');
const result = await scan(cwd, { maxFiles: config.scanMaxFiles });
if (!result.files || result.files.length === 0) {
throw new Error(
`No source files found under ${cwd}. Run \`draftwise tasks\` from your repo root.`,
);
}
for (const warning of describeScanWarnings(result)) {
log(warning);
}
scanForPrompt = compactScan(result);
packageMeta = result.packageMeta;
}
const { scanForPrompt, packageMeta, overview } = await loadScanContext({
cwd,
config,
log,
scan,
readOverview,
commandName: 'tasks',
});

if (config.mode === 'agent') {
log('');
Expand Down
Loading