diff --git a/CHANGELOG.md b/CHANGELOG.md index f653b7c..0f55044 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index ed605cf..8156290 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -108,6 +108,8 @@ scan: **The constitution is the user's voice + spec-quality dial.** `draftwise init` writes `.draftwise/constitution.md` with five stable sections — Voice, Spec language, Edge case discipline, Project conventions, Domain glossary. Every drafting prompt (`new`, `tech`, `tasks`, `scan`, `explain`, greenfield Phase 3) tells the host agent to read this file before drafting and apply the relevant sections. `scan` and the greenfield Phase 3 also refine the **Project conventions** section from observed code / chosen stack, replacing the placeholder. The file is user-editable and version-controlled — changes are reviewable and travel with the repo. Backward compat: prompts say "skip silently if absent" so projects that ran `init` before this feature landed keep working. Reader at `src/utils/constitution.js` returns null on ENOENT. Replaces the role `principles.js` + `spec-quality.js` used to play before the api-mode drop — those modules injected rules into every system prompt; the constitution is the new home for those rules now that synthesis lives in the host agent. +**Cross-spec dependencies live in product-spec frontmatter.** The agent writes an optional YAML block at the top of `product-spec.md` — `depends_on: [...]` for specs that must ship before this one, `related: [...]` for same-area specs that aren't a hard dependency. `draftwise new`'s instruction tells the agent to list `.draftwise/specs/` first and only reference slugs that exist on disk. `draftwise list`'s DEPENDS ON column surfaces the `depends_on` array. This is the cheap version of cross-spec tracking — declarative, in the spec, no graph traversal or drift detection. The richer machinery (drift detection, full graph view, ripple analysis) stays deferred; the frontmatter is enough to answer "which specs are ready to start?" without dragging the rest in. + **Single repo, single feature spec at a time.** No cross-spec dependency tracking. No multi-repo. Keep scope tight. --- diff --git a/README.md b/README.md index 84f5519..f6299a1 100644 --- a/README.md +++ b/README.md @@ -139,6 +139,15 @@ Markdown. Version-controlled. Travels with your repo. (Draftwise also writes a `
Product spec sections +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 diff --git a/src/ai/prompts/new.js b/src/ai/prompts/new.js index 8038500..15a817c 100644 --- a/src/ai/prompts/new.js +++ b/src/ai/prompts/new.js @@ -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: [, ] # specs that must ship before this one + related: [, ] # 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//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}". @@ -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: [, ] # specs that must ship before this one + related: [, ] # 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//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.`; } diff --git a/src/commands/list.js b/src/commands/list.js index e2a2a9f..6ac9484 100644 --- a/src/commands/list.js +++ b/src/commands/list.js @@ -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) { @@ -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}`, + ); } } diff --git a/src/utils/frontmatter.js b/src/utils/frontmatter.js new file mode 100644 index 0000000..4129d02 --- /dev/null +++ b/src/utils/frontmatter.js @@ -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); +} diff --git a/test/commands/list.test.js b/test/commands/list.test.js index 02fbc15..0b018d6 100644 --- a/test/commands/list.test.js +++ b/test/commands/list.test.js @@ -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'); + }); }); diff --git a/test/utils/frontmatter.test.js b/test/utils/frontmatter.test.js new file mode 100644 index 0000000..a2f54d1 --- /dev/null +++ b/test/utils/frontmatter.test.js @@ -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([]); + }); +});