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 @@ -8,6 +8,8 @@ Each released version is tagged in git (`v0.0.1`, `v0.1.0`, etc.) and includes t

### Added

- **`product-spec.md` opens with optional YAML frontmatter declaring `depends_on:` and `related:` slugs; `draftwise list` surfaces them.** `draftwise new` now instructs the host coding agent to list `.draftwise/specs/` first, then write a frontmatter block at the top of `product-spec.md` — `depends_on` for specs that must ship before this one, `related` for same-area specs that aren't a hard dependency. Both keys take a list of existing slugs (the agent is told never to invent slugs that aren't on disk; if there are no other specs yet the agent skips the block entirely). Frontmatter is parsed by a new `src/utils/frontmatter.js` (small wrapper over the `yaml` package — handles CRLF, malformed YAML, and non-object roots by falling back to empty data so a broken spec file doesn't break the whole list). `draftwise list` grew a fourth column — DEPENDS ON — between STATUS and TITLE; a spec with no frontmatter or no `depends_on` shows blank. Why: cross-spec dependencies were a deferred-for-later item in CLAUDE.md, but the cheap version (declarative, in the spec, surfaced in `list`) is enough to answer the actual question PMs ask ("which specs are ready to start?") without needing graph traversal, drift detection, or any of the heavier machinery. Stays in a frontmatter block so it doesn't bleed into the rendered spec body. — Ankur

- **`.draftwise/constitution.md` — user-editable rules file the host coding agent reads on every drafting command.** `draftwise init` writes a default template into the project on both the brownfield and greenfield paths. Five stable section headings every prompt module references by name: **Voice** (how the agent talks — no filler, push back on weak ideas, ground every claim, turn gaps into questions), **Spec language** (specific over generic, active voice, same term every time), **Edge case discipline** (technical specs only — empty data / errors / loading / permissions / concurrency / large data), **Project conventions** (placeholder filled in by `scan` from observed code or by the greenfield Phase 3 from the chosen stack), and **Domain glossary** (empty by default; project-specific terms). Replaces the role that `src/ai/prompts/principles.js` and `src/ai/prompts/spec-quality.js` used to play before the api-mode drop — those modules injected collaboration + spec-language rules into every system prompt; with synthesis moved entirely into the host coding agent, those rules now live in the user's repo as a markdown file the agent reads on each call. Every drafting prompt (`new`, `tech`, `tasks`, `scan`, `explain`, `greenfield` Phase 3) got a one-line "before drafting, read `.draftwise/constitution.md` if it exists and apply its Voice / Spec language / Edge case discipline sections; skip silently if absent" — back-compat for projects that ran `init` before this feature landed. `scan` and the greenfield Phase 3 additionally instruct the agent to refine the **Project conventions** section from observed code / chosen stack, replacing the placeholder. Reader utility at `src/utils/constitution.js` (`readConstitution(cwd)` returns null on ENOENT, propagates other errors); template at `src/utils/constitution-template.js`. Why: with the api path gone, the project needed somewhere stable for users to tune Draftwise's voice and spec discipline without editing prompts in the npm package — the constitution is that surface, and it's version-controlled alongside the code so changes are reviewable. — Ankur

### Removed
Expand Down
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,8 @@ scan:

**The constitution is the user's voice + spec-quality dial.** `draftwise init` writes `.draftwise/constitution.md` with five stable sections — Voice, Spec language, Edge case discipline, Project conventions, Domain glossary. Every drafting prompt (`new`, `tech`, `tasks`, `scan`, `explain`, greenfield Phase 3) tells the host agent to read this file before drafting and apply the relevant sections. `scan` and the greenfield Phase 3 also refine the **Project conventions** section from observed code / chosen stack, replacing the placeholder. The file is user-editable and version-controlled — changes are reviewable and travel with the repo. Backward compat: prompts say "skip silently if absent" so projects that ran `init` before this feature landed keep working. Reader at `src/utils/constitution.js` returns null on ENOENT. Replaces the role `principles.js` + `spec-quality.js` used to play before the api-mode drop — those modules injected rules into every system prompt; the constitution is the new home for those rules now that synthesis lives in the host agent.

**Cross-spec dependencies live in product-spec frontmatter.** The agent writes an optional YAML block at the top of `product-spec.md` — `depends_on: [<slug>...]` for specs that must ship before this one, `related: [<slug>...]` for same-area specs that aren't a hard dependency. `draftwise new`'s instruction tells the agent to list `.draftwise/specs/` first and only reference slugs that exist on disk. `draftwise list`'s DEPENDS ON column surfaces the `depends_on` array. This is the cheap version of cross-spec tracking — declarative, in the spec, no graph traversal or drift detection. The richer machinery (drift detection, full graph view, ripple analysis) stays deferred; the frontmatter is enough to answer "which specs are ready to start?" without dragging the rest in.

**Single repo, single feature spec at a time.** No cross-spec dependency tracking. No multi-repo. Keep scope tight.

---
Expand Down
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,15 @@ Markdown. Version-controlled. Travels with your repo. (Draftwise also writes a `
<details>
<summary><strong>Product spec sections</strong></summary>

Optional YAML frontmatter at the top of `product-spec.md` declares cross-spec relationships, surfaced in `draftwise list`'s `DEPENDS ON` column:

```yaml
---
depends_on: [auth, billing] # specs that must ship before this one
related: [profile-page] # same area; not a hard dependency
---
```

```
Problem → what's broken, with evidence
User stories → who wants what and why
Expand Down
16 changes: 14 additions & 2 deletions src/ai/prompts/new.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,17 @@ PHASE 2 — Walk the PM through the questions:
- Ask one clarifying question at a time. Wait for the answer.

PHASE 3 — Generate product-spec.md:
- Open the file with a YAML frontmatter block — three dashes, the keys below, three dashes, blank line, then the H1. Both keys are required; use \`[]\` when the list is empty.
---
depends_on: [<slug>, <slug>] # specs that must ship before this one
related: [<slug>, <slug>] # specs in the same area; not a hard dependency
---
- List \`.draftwise/specs/\` first to see what slugs already exist; only reference real ones. Skip frontmatter entirely if there are no other specs yet (this is the first one).
- Sections in order: Problem, Users, User stories, Acceptance criteria, Edge cases, Test cases, Scope (covered/assumed/hypothesized/out of scope), Core metrics, Counter metrics.
- Skip "Affected flows" and "Adjacent changes" — they don't apply for greenfield.
- Save to .draftwise/specs/<feature-slug>/product-spec.md.

Hard rule: ASK don't assume; ground every claim in the answers and the project plan, never invented detail.`;
Hard rule: ASK don't assume; ground every claim in the answers and the project plan, never invented detail; never invent slugs in \`depends_on\` / \`related\` — only list specs that exist on disk.`;
}
return `The PM has proposed: "${idea}".

Expand All @@ -37,10 +43,16 @@ PHASE 2 — Walk the PM through the questions and opportunities:
- For each adjacent opportunity, present it and ask the PM to accept, decline, or defer.

PHASE 3 — Generate product-spec.md:
- Open the file with a YAML frontmatter block — three dashes, the keys below, three dashes, blank line, then the H1. Both keys are required; use \`[]\` when the list is empty.
---
depends_on: [<slug>, <slug>] # specs that must ship before this one
related: [<slug>, <slug>] # specs in the same area; not a hard dependency
---
- List \`.draftwise/specs/\` first to see what slugs already exist; only reference real ones. Skip frontmatter entirely if there are no other specs yet (this is the first one).
- Use the PM's idea, scanner output, answers, and accept/decline decisions.
- Follow the section order: Problem, Users, User stories, Acceptance criteria, Affected flows, Adjacent changes, Edge cases, Test cases, Scope (covered/assumed/hypothesized/out of scope), Core metrics, Counter metrics.
- Reference real files/routes/models — do not invent.
- Save to .draftwise/specs/<feature-slug>/product-spec.md (create the directory if needed).

Hard rules: ground every claim in the scanner; turn every gap into a question, not an assumption; keep the spec tight.`;
Hard rules: ground every claim in the scanner; turn every gap into a question, not an assumption; keep the spec tight; never invent slugs in \`depends_on\` / \`related\` — only list specs that exist on disk.`;
}
46 changes: 27 additions & 19 deletions src/commands/list.js
Original file line number Diff line number Diff line change
@@ -1,25 +1,21 @@
import { readFile } from 'node:fs/promises';
import { listSpecs as defaultListSpecs } from '../utils/specs.js';
import { requireDraftwiseDir } from '../utils/draftwise-dir.js';
import { readFrontmatter, asSlugList } from '../utils/frontmatter.js';

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

Usage:
draftwise list

Three columns: slug, status (which artifacts exist —
product · tech · tasks), and the title from product-spec.md's H1.
Four columns: slug, status (which artifacts exist —
product · tech · tasks), depends_on (from the product-spec.md
frontmatter), and the title from product-spec.md's H1.
Empty spec dirs show as "(empty)".
`;

async function readTitle(file) {
try {
const content = await readFile(file, 'utf8');
const m = content.match(/^\s*#\s+(.+)$/m);
return m ? m[1].trim() : '';
} catch {
return '';
}
function extractTitle(body) {
const m = body.match(/^\s*#\s+(.+)$/m);
return m ? m[1].trim() : '';
}

function buildStatus(spec) {
Expand Down Expand Up @@ -50,23 +46,35 @@ export default async function listCommand(_args = [], deps = {}) {
}

const rows = await Promise.all(
specs.map(async (s) => ({
slug: s.slug,
status: buildStatus(s),
title: s.hasProductSpec ? await readTitle(s.productSpec) : '',
})),
specs.map(async (s) => {
if (!s.hasProductSpec) {
return { slug: s.slug, status: buildStatus(s), dependsOn: '', title: '' };
}
const { data, body } = await readFrontmatter(s.productSpec);
return {
slug: s.slug,
status: buildStatus(s),
dependsOn: asSlugList(data.depends_on).join(', '),
title: extractTitle(body),
};
}),
);

const slugWidth = Math.max(4, ...rows.map((r) => r.slug.length));
const statusWidth = Math.max(6, ...rows.map((r) => r.status.length));
const dependsWidth = Math.max(10, ...rows.map((r) => r.dependsOn.length));

log(`${rows.length} spec${rows.length === 1 ? '' : 's'} in .draftwise/specs/`);
log('');
log(`${pad('SLUG', slugWidth)} ${pad('STATUS', statusWidth)} TITLE`);
log(
`${'-'.repeat(slugWidth)} ${'-'.repeat(statusWidth)} ${'-'.repeat(20)}`,
`${pad('SLUG', slugWidth)} ${pad('STATUS', statusWidth)} ${pad('DEPENDS ON', dependsWidth)} TITLE`,
);
log(
`${'-'.repeat(slugWidth)} ${'-'.repeat(statusWidth)} ${'-'.repeat(dependsWidth)} ${'-'.repeat(20)}`,
);
for (const r of rows) {
log(`${pad(r.slug, slugWidth)} ${pad(r.status, statusWidth)} ${r.title}`);
log(
`${pad(r.slug, slugWidth)} ${pad(r.status, statusWidth)} ${pad(r.dependsOn, dependsWidth)} ${r.title}`,
);
}
}
37 changes: 37 additions & 0 deletions src/utils/frontmatter.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { readFile } from 'node:fs/promises';
import { parse as yamlParse } from 'yaml';

const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/;

export function parseFrontmatter(source) {
const match = source.match(FRONTMATTER_RE);
if (!match) return { data: {}, body: source };
let data;
try {
data = yamlParse(match[1]) ?? {};
} catch {
return { data: {}, body: source };
}
if (typeof data !== 'object' || Array.isArray(data)) {
return { data: {}, body: source };
}
return { data, body: source.slice(match[0].length) };
}

export async function readFrontmatter(file) {
let source;
try {
source = await readFile(file, 'utf8');
} catch {
return { data: {}, body: '' };
}
return parseFrontmatter(source);
}

export function asSlugList(value) {
if (!Array.isArray(value)) return [];
return value
.filter((v) => typeof v === 'string')
.map((v) => v.trim())
.filter((v) => v.length > 0);
}
25 changes: 25 additions & 0 deletions test/commands/list.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -61,4 +61,29 @@ describe('draftwise list', () => {
await listCommand([], { cwd: dir, log: (m) => logs.push(m) });
expect(logs.join('\n')).toContain('(empty)');
});

it('renders DEPENDS ON from product-spec.md frontmatter', async () => {
await seedSpec(dir, 'auth', {
'product-spec.md': '# Auth\n\nBody.',
});
await seedSpec(dir, 'profile', {
'product-spec.md': `---\ndepends_on: [auth]\nrelated: []\n---\n\n# Profile page\n`,
});

await listCommand([], { cwd: dir, log: (m) => logs.push(m) });
const out = logs.join('\n');
expect(out).toContain('DEPENDS ON');
expect(out).toMatch(/profile\s+product\s+auth\s+Profile page/);
// The auth row has no depends_on, so its DEPENDS ON column is blank.
expect(out).toMatch(/auth\s+product\s{2,}Auth/);
});

it('handles malformed frontmatter without crashing', async () => {
await seedSpec(dir, 'broken', {
'product-spec.md': '---\nnot: valid: yaml: here\n---\n\n# Broken\n',
});

await listCommand([], { cwd: dir, log: (m) => logs.push(m) });
expect(logs.join('\n')).toContain('broken');
});
});
88 changes: 88 additions & 0 deletions test/utils/frontmatter.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
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 {
parseFrontmatter,
readFrontmatter,
asSlugList,
} from '../../src/utils/frontmatter.js';

describe('parseFrontmatter', () => {
it('parses a simple frontmatter block and returns the body', () => {
const src = `---\ndepends_on: [auth, billing]\nrelated: []\n---\n\n# Title\n\nBody.\n`;
const { data, body } = parseFrontmatter(src);
expect(data.depends_on).toEqual(['auth', 'billing']);
expect(data.related).toEqual([]);
expect(body).toBe('\n# Title\n\nBody.\n');
});

it('returns empty data and the full source when no frontmatter is present', () => {
const src = '# Just a title\n\nBody.\n';
const { data, body } = parseFrontmatter(src);
expect(data).toEqual({});
expect(body).toBe(src);
});

it('returns empty data when the YAML is malformed', () => {
const src = '---\nnot: valid: yaml: here\n---\n\n# Title\n';
const { data } = parseFrontmatter(src);
expect(data).toEqual({});
});

it('returns empty data when the frontmatter parses to a non-object', () => {
const src = '---\n- just\n- a\n- list\n---\n\nbody\n';
const { data } = parseFrontmatter(src);
expect(data).toEqual({});
});

it('handles CRLF line endings', () => {
const src = `---\r\ndepends_on: [a]\r\n---\r\n\r\n# T\r\n`;
const { data } = parseFrontmatter(src);
expect(data.depends_on).toEqual(['a']);
});
});

describe('readFrontmatter', () => {
let dir;

beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), 'draftwise-fm-'));
});

afterEach(async () => {
await rm(dir, { recursive: true, force: true });
});

it('reads frontmatter from a file', async () => {
const file = join(dir, 'spec.md');
await writeFile(file, '---\ndepends_on: [x]\n---\n\n# T\n', 'utf8');
const { data } = await readFrontmatter(file);
expect(data.depends_on).toEqual(['x']);
});

it('returns empty data + body for a missing file', async () => {
const result = await readFrontmatter(join(dir, 'nope.md'));
expect(result).toEqual({ data: {}, body: '' });
});
});

describe('asSlugList', () => {
it('returns the array of strings unchanged when valid', () => {
expect(asSlugList(['a', 'b'])).toEqual(['a', 'b']);
});

it('trims whitespace and drops empties', () => {
expect(asSlugList([' a ', '', 'b'])).toEqual(['a', 'b']);
});

it('drops non-string values', () => {
expect(asSlugList(['a', 5, null, 'b'])).toEqual(['a', 'b']);
});

it('returns an empty array for non-array input', () => {
expect(asSlugList(undefined)).toEqual([]);
expect(asSlugList('a,b')).toEqual([]);
expect(asSlugList({ a: 1 })).toEqual([]);
});
});