diff --git a/cmd/chief/main.go b/cmd/chief/main.go index 2de81bfb..1670db0a 100644 --- a/cmd/chief/main.go +++ b/cmd/chief/main.go @@ -93,7 +93,7 @@ func findAvailablePRD() string { for _, entry := range entries { if entry.IsDir() { - prdPath := filepath.Join(prdsDir, entry.Name(), "prd.json") + prdPath := filepath.Join(prdsDir, entry.Name(), "prd.md") if _, err := os.Stat(prdPath); err == nil { return prdPath } @@ -113,7 +113,7 @@ func listAvailablePRDs() []string { var names []string for _, entry := range entries { if entry.IsDir() { - prdPath := filepath.Join(prdsDir, entry.Name(), "prd.json") + prdPath := filepath.Join(prdsDir, entry.Name(), "prd.md") if _, err := os.Stat(prdPath); err == nil { names = append(names, entry.Name()) } @@ -241,11 +241,11 @@ func parseTUIFlags() *TUIOptions { os.Exit(1) default: // Positional argument: PRD name or path - if strings.HasSuffix(arg, ".json") || strings.HasSuffix(arg, "/") { + if strings.HasSuffix(arg, ".md") || strings.HasSuffix(arg, ".json") || strings.HasSuffix(arg, "/") { opts.PRDPath = arg } else { // Treat as PRD name - opts.PRDPath = fmt.Sprintf(".chief/prds/%s/prd.json", arg) + opts.PRDPath = fmt.Sprintf(".chief/prds/%s/prd.md", arg) } } } @@ -282,18 +282,11 @@ func runNew() { func runEdit() { opts := cmd.EditOptions{} - // Parse arguments: chief edit [name] [--merge] [--force] [--agent X] [--agent-path X] + // Parse arguments: chief edit [name] [--agent X] [--agent-path X] flagAgent, flagPath, remaining := parseAgentFlags(os.Args, 2) for _, arg := range remaining { - switch { - case arg == "--merge": - opts.Merge = true - case arg == "--force": - opts.Force = true - default: - if opts.Name == "" && !strings.HasPrefix(arg, "-") { - opts.Name = arg - } + if opts.Name == "" && !strings.HasPrefix(arg, "-") { + opts.Name = arg } } @@ -368,7 +361,7 @@ func runTUIWithOptions(opts *TUIOptions) { // If no PRD specified, try to find one if prdPath == "" { // Try "main" first - mainPath := ".chief/prds/main/prd.json" + mainPath := ".chief/prds/main/prd.md" if _, err := os.Stat(mainPath); err == nil { prdPath = mainPath } else { @@ -411,7 +404,7 @@ func runTUIWithOptions(opts *TUIOptions) { } // Restart TUI with the new PRD - opts.PRDPath = fmt.Sprintf(".chief/prds/%s/prd.json", result.PRDName) + opts.PRDPath = fmt.Sprintf(".chief/prds/%s/prd.md", result.PRDName) runTUIWithOptions(opts) return } @@ -419,22 +412,15 @@ func runTUIWithOptions(opts *TUIOptions) { prdDir := filepath.Dir(prdPath) - // Check if prd.md is newer than prd.json and run conversion if needed - needsConvert, err := prd.NeedsConversion(prdDir) - if err != nil { - fmt.Printf("Warning: failed to check conversion status: %v\n", err) - } else if needsConvert { - fmt.Println("prd.md is newer than prd.json, running conversion...") - if err := cmd.RunConvertWithOptions(cmd.ConvertOptions{ - PRDDir: prdDir, - Merge: opts.Merge, - Force: opts.Force, - Provider: provider, - }); err != nil { - fmt.Printf("Error converting PRD: %v\n", err) - os.Exit(1) + // Auto-migrate: if prd.json exists alongside prd.md, migrate status + jsonPath := filepath.Join(prdDir, "prd.json") + if _, err := os.Stat(jsonPath); err == nil { + fmt.Println("Migrating status from prd.json to prd.md...") + if err := prd.MigrateFromJSON(prdDir); err != nil { + fmt.Printf("Warning: migration failed: %v\n", err) + } else { + fmt.Println("Migration complete (prd.json renamed to prd.json.bak).") } - fmt.Println("Conversion complete.") } app, err := tui.NewAppWithOptions(prdPath, opts.MaxIterations, provider) @@ -492,15 +478,13 @@ func runTUIWithOptions(opts *TUIOptions) { os.Exit(1) } // Restart TUI with the new PRD - opts.PRDPath = fmt.Sprintf(".chief/prds/%s/prd.json", finalApp.PostExitPRD) + opts.PRDPath = fmt.Sprintf(".chief/prds/%s/prd.md", finalApp.PostExitPRD) runTUIWithOptions(opts) case tui.PostExitEdit: // Run edit command then restart TUI editOpts := cmd.EditOptions{ Name: finalApp.PostExitPRD, - Merge: opts.Merge, - Force: opts.Force, Provider: provider, } if err := cmd.RunEdit(editOpts); err != nil { @@ -508,7 +492,7 @@ func runTUIWithOptions(opts *TUIOptions) { os.Exit(1) } // Restart TUI with the edited PRD - opts.PRDPath = fmt.Sprintf(".chief/prds/%s/prd.json", finalApp.PostExitPRD) + opts.PRDPath = fmt.Sprintf(".chief/prds/%s/prd.md", finalApp.PostExitPRD) runTUIWithOptions(opts) } } @@ -518,7 +502,7 @@ func printHelp() { fmt.Println(`Chief - Autonomous PRD Agent Usage: - chief [options] [|] + chief [options] [|] chief [arguments] Commands: @@ -545,13 +529,13 @@ Edit Options: --force Auto-overwrite on conversion conflicts Positional Arguments: - PRD name (loads .chief/prds//prd.json) - Direct path to a prd.json file + PRD name (loads .chief/prds//prd.md) + Direct path to a prd.md file Examples: chief Launch TUI with default PRD (.chief/prds/main/) chief auth Launch TUI with named PRD (.chief/prds/auth/) - chief ./my-prd.json Launch TUI with specific PRD file + chief ./my-prd.md Launch TUI with specific PRD file chief -n 20 Launch with 20 max iterations chief --max-iterations=5 auth Launch auth PRD with 5 max iterations diff --git a/docs/concepts/chief-directory.md b/docs/concepts/chief-directory.md index 644433cf..5db21266 100644 --- a/docs/concepts/chief-directory.md +++ b/docs/concepts/chief-directory.md @@ -18,8 +18,7 @@ your-project/ ├── config.yaml # Project settings (worktree, auto-push, PR) ├── prds/ │ └── my-feature/ - │ ├── prd.md # Human-readable PRD (you write this) - │ ├── prd.json # Machine-readable PRD (Chief reads/writes) + │ ├── prd.md # Structured PRD (you write, Chief reads/updates) │ ├── progress.md # Progress log (Chief appends after each story) │ └── claude.log # Raw agent output (for debugging) └── worktrees/ # Isolated checkouts for parallel PRDs @@ -45,42 +44,21 @@ Chief uses this folder as the working context for the entire run. All reads and ### `prd.md` -The human-readable product requirements document. You write this file (or generate it with `chief new`). It contains context, background, technical notes, and anything else that helps the agent understand what to build. +The structured product requirements document. You write this file (or generate it with `chief new`). It contains freeform context at the top (background, technical notes, design guidance) and structured user stories that Chief parses and updates. -This file is included in the prompt sent to the agent at the start of each iteration. Write it as if you're briefing a senior developer who's new to the project — the more context you provide, the better the output. +Chief reads this file at the start of each iteration to determine which story to work on, and updates status fields after completing a story. The agent also reads the freeform context to understand what you're building and how. -```markdown -# My Feature - -## Background -We need to add user authentication to our API... - -## Technical Notes -- We use Express.js with TypeScript -- Database is PostgreSQL with Prisma ORM -- Follow existing middleware patterns in `src/middleware/` -``` - -### `prd.json` - -The structured, machine-readable PRD. This is where user stories, their priorities, and their completion status live. Chief reads this file at the start of each iteration to determine which story to work on, and writes to it after completing a story. - -Key fields: +Key story fields (parsed from markdown): -| Field | Type | Description | -|-------|------|-------------| -| `project` | string | Project name | -| `description` | string | Brief project description | -| `userStories` | array | List of user stories | -| `userStories[].id` | string | Story identifier (e.g., `US-001`) | -| `userStories[].title` | string | Short story title | -| `userStories[].description` | string | User story in "As a... I want... so that..." format | -| `userStories[].acceptanceCriteria` | array | List of criteria that must be met | -| `userStories[].priority` | number | Execution order (lower = higher priority) | -| `userStories[].passes` | boolean | Whether the story is complete | -| `userStories[].inProgress` | boolean | Whether Chief is currently working on this story | +| Field | Format | Description | +|-------|--------|-------------| +| ID + Title | `### US-001: Story Title` | Story heading parsed by Chief | +| Status | `**Status:** done\|in-progress\|todo` | Completion state, updated by Chief | +| Priority | `**Priority:** N` | Execution order (lower = higher priority) | +| Description | `**Description:** ...` | Story description | +| Acceptance Criteria | `- [ ]` / `- [x]` | Checkbox items tracked by Chief | -Chief selects the next story by finding the highest-priority story (lowest `priority` number) where `passes` is `false`. See the [PRD Format](/concepts/prd-format) reference for full details. +Chief selects the next story by finding the highest-priority story (lowest `**Priority:**` number) without `**Status:** done`. See the [PRD Format](/concepts/prd-format) reference for full details. ### `progress.md` @@ -184,15 +162,12 @@ A single project can have multiple PRDs, each tracking a separate feature or ini ├── prds/ │ ├── auth-system/ │ │ ├── prd.md -│ │ ├── prd.json │ │ └── progress.md │ ├── payment-integration/ │ │ ├── prd.md -│ │ ├── prd.json │ │ └── progress.md │ └── admin-dashboard/ │ ├── prd.md -│ ├── prd.json │ └── progress.md └── worktrees/ ├── auth-system/ @@ -244,8 +219,7 @@ If you want collaborators to see progress and continue where you left off, commi ``` This shares: -- `prd.md`: Your requirements, the source of truth for what to build -- `prd.json`: Story state and progress, so collaborators see what's done +- `prd.md`: Your requirements and story state — the source of truth for what to build and what's done - `progress.md`: Implementation history and learnings, valuable project context The `claude.log` files are large, regenerated each run, and only useful for debugging. diff --git a/docs/concepts/how-it-works.md b/docs/concepts/how-it-works.md index 44d0b6a8..a0bfa588 100644 --- a/docs/concepts/how-it-works.md +++ b/docs/concepts/how-it-works.md @@ -70,7 +70,7 @@ Here's what happens in each step: 2. **Invoke Agent**: Constructs a prompt with the story details and project context, then spawns the agent 3. **Agent Codes**: The agent reads files, writes code, runs tests, and fixes issues until the story is complete 4. **Commit**: The agent commits the changes with a message like `feat: [US-001] - Feature Title` -5. **Mark Complete**: Chief updates the project state and records progress +5. **Mark Complete**: Chief updates the story status in `prd.md` and records progress 6. **Repeat**: If more stories remain, the loop continues This isolation is intentional. If something breaks, you know exactly which story caused it. Each commit represents one complete feature. diff --git a/docs/concepts/prd-format.md b/docs/concepts/prd-format.md index 5c797442..ff4f2c80 100644 --- a/docs/concepts/prd-format.md +++ b/docs/concepts/prd-format.md @@ -1,10 +1,10 @@ --- -description: Complete guide to Chief's PRD format including prd.md and prd.json structure, user story fields, selection logic, and best practices. +description: Complete guide to Chief's PRD format. How prd.md structures user stories, status tracking, selection logic, and best practices. --- # PRD Format -Chief uses a structured PRD format with two files: a human-readable markdown file (`prd.md`) and a machine-readable JSON file (`prd.json`). Together, they give Chief everything it needs to autonomously build your feature. +Chief uses a single `prd.md` file that serves as both human-readable context and machine-readable structured data. Chief parses structured markdown headings, status fields, and checkbox items directly from this file — no separate JSON file is needed. ::: info Multi-agent support Chief supports multiple agent backends: **Claude Code** (default), **Codex CLI**, and **OpenCode CLI**. This page uses "the agent" to refer to whichever backend you've configured. See [Configuration](/reference/configuration) for setup details. @@ -16,22 +16,24 @@ Each PRD lives in its own subdirectory inside `.chief/prds/`: ``` .chief/prds/my-feature/ -├── prd.md # Human-readable context for the agent -├── prd.json # Structured data Chief reads and updates +├── prd.md # Structured PRD (you write, Chief reads and updates) ├── progress.md # Auto-generated progress log └── claude.log # Raw agent output from each iteration ``` -- **`prd.md`** — Written by you. Provides context, background, and guidance. -- **`prd.json`** — The source of truth. Chief reads, updates, and drives execution from this file. +- **`prd.md`** — Written by you, read and updated by Chief. Contains project context and structured user stories. - **`progress.md`** — Written by the agent. Tracks what was done, what changed, and what was learned. - **`claude.log`** (or `codex.log` / `opencode.log`) — Written by Chief. Raw output from the agent for debugging. -## prd.md — The Human-Readable File +## prd.md — The PRD File -The markdown file is your chance to give the agent context that doesn't fit into structured fields. Write whatever helps the agent understand the project — there's no required format. +The `prd.md` file has two parts: **freeform context** at the top and **structured user stories** below. The freeform section gives the agent background on your project. The structured stories use specific markdown patterns that Chief parses to drive execution. -### What to Include +### Freeform Context + +The top of the file is your chance to give the agent context that doesn't fit into structured fields. Write whatever helps the agent understand the project — there's no required format for this section. + +**What to Include:** - **Overview** — What are you building and why? - **Technical context** — What stack, frameworks, and patterns does the project use? @@ -39,6 +41,16 @@ The markdown file is your chance to give the agent context that doesn't fit into - **Examples** — Reference implementations, API shapes, or UI mockups. - **Links** — Related docs, design files, or prior art. +### Structured User Stories + +Below the freeform context, define your user stories using structured markdown headings that Chief parses: + +- `### US-001: Story Title` — story heading (ID + title) +- `**Status:** done|in-progress|todo` — tracked by Chief +- `**Priority:** N` — execution order (optional; defaults to document order) +- `**Description:** ...` — story description (or freeform prose after heading) +- `- [ ] criterion` / `- [x] criterion` — acceptance criteria as checkboxes + ### Example prd.md ```markdown @@ -63,73 +75,59 @@ Users need to register, log in, reset passwords, and manage sessions. ## Reference - Existing user model: `prisma/schema.prisma` - API route pattern: `src/routes/health.ts` + +## User Stories + +### US-001: User Registration + +**Status:** todo +**Priority:** 1 +**Description:** As a new user, I want to register an account so that I can access the application. + +- [ ] Registration form with email and password fields +- [ ] Email format validation +- [ ] Password minimum 8 characters +- [ ] Confirmation email sent on registration +- [ ] User redirected to login after registration + +### US-002: User Login + +**Status:** todo +**Priority:** 2 +**Description:** As a registered user, I want to log in so that I can access my account. + +- [ ] Login form with email and password fields +- [ ] Error message for invalid credentials +- [ ] JWT token issued on success +- [ ] Redirect to dashboard on success + +### US-003: Password Reset + +**Status:** todo +**Priority:** 3 +**Description:** As a user, I want to reset my password so that I can recover my account. + +- [ ] "Forgot password" link on login page +- [ ] Email with reset link sent to user +- [ ] Reset token expires after 1 hour +- [ ] New password form with confirmation field ``` -This file is included in the agent's context but never parsed programmatically. The agent reads it to understand what you're building and how. +This file is included in the agent's context and also parsed by Chief to track story status and selection. ::: tip -The better your `prd.md`, the better the agent's output. Spend time here — it pays off across every story. +The better your `prd.md`, the better the agent's output. Spend time on the freeform context — it pays off across every story. ::: -## prd.json — The Machine-Readable File - -The JSON file is what Chief actually uses to drive execution. It defines the project metadata, optional settings, and an ordered list of user stories. - -### Top-Level Schema - -| Field | Type | Required | Description | -|-------|------|----------|-------------| -| `project` | `string` | Yes | Project name, used in logs and TUI | -| `description` | `string` | Yes | Brief description of what you're building | -| `userStories` | `array` | Yes | Ordered list of user stories | - -### UserStory Object - -Each story in the `userStories` array has the following fields: - -| Field | Type | Required | Default | Description | -|-------|------|----------|---------|-------------| -| `id` | `string` | Yes | — | Unique identifier (e.g., `US-001`). Appears in commit messages. | -| `title` | `string` | Yes | — | Short, descriptive title. Keep under 50 characters. | -| `description` | `string` | Yes | — | Full description. User story format recommended. | -| `acceptanceCriteria` | `string[]` | Yes | — | List of requirements. The agent uses these to know when the story is done. | -| `priority` | `number` | Yes | — | Execution order. Lower number = higher priority. | -| `passes` | `boolean` | Yes | `false` | Whether the story has been completed and verified. | -| `inProgress` | `boolean` | Yes | `false` | Whether the agent is currently working on this story. | - -### Minimal Example - -```json -{ - "project": "My Feature", - "description": "A new feature for my application", - "userStories": [ - { - "id": "US-001", - "title": "Basic Setup", - "description": "As a developer, I want the project scaffolded so I can start building.", - "acceptanceCriteria": [ - "Project directory created", - "Dependencies installed", - "Dev server starts successfully" - ], - "priority": 1, - "passes": false, - "inProgress": false - } - ] -} -``` - ## Story Selection Logic Chief picks the next story to work on using a simple, deterministic algorithm: ``` -1. Filter stories where passes = false -2. Sort remaining stories by priority (ascending) +1. Filter stories without **Status:** done +2. Sort remaining stories by **Priority:** (ascending), or document order if unset 3. Pick the first one -4. Set inProgress = true on that story +4. Mark it as **Status:** in-progress 5. Start the iteration ``` @@ -137,103 +135,81 @@ Chief picks the next story to work on using a simple, deterministic algorithm: Priority is a number where **lower = higher priority**. Chief always picks the lowest-numbered incomplete story: -| Story | Priority | Passes | Selected? | +| Story | Priority | Status | Selected? | |-------|----------|--------|-----------| -| US-001 | 1 | `true` | No — already complete | -| US-002 | 2 | `false` | **Yes — lowest priority number with passes: false** | -| US-003 | 3 | `false` | No — US-002 goes first | +| US-001 | 1 | `done` | No — already complete | +| US-002 | 2 | `todo` | **Yes — lowest priority number that isn't done** | +| US-003 | 3 | `todo` | No — US-002 goes first | -### What `inProgress` Does +### What `in-progress` Does -When Chief starts working on a story, it sets `inProgress: true`. This serves as a signal that the story is being actively worked on. When the story completes: +When Chief starts working on a story, it sets `**Status:** in-progress`. This serves as a signal that the story is being actively worked on. When the story completes: -- `passes` is set to `true` -- `inProgress` is set back to `false` +- `**Status:**` is set to `done` +- Acceptance criteria checkboxes are checked (`- [x]`) -If Chief is interrupted mid-iteration (e.g., you stop it), `inProgress` may remain `true`. On the next run, Chief will pick up the same story and continue. +If Chief is interrupted mid-iteration, the status may remain `in-progress`. On the next run, Chief will pick up the same story and continue. ### Completion Signal -When all stories have `passes: true`, the iteration ends and Chief reports completion. No more iterations are started. +When the agent finishes a story, it outputs `` to signal that the current story is complete. Chief then marks the story as done in `prd.md` and selects the next one. When no incomplete stories remain, the loop ends naturally. ## Annotated Example PRD -Here's a complete `prd.json` with annotations explaining each part: - -```json -{ - // The project name — shown in the TUI header and logs - "project": "User Authentication", - - // A brief description — helps the agent understand scope - "description": "Complete auth system with login, registration, and password reset", - - "userStories": [ - { - // Unique ID — appears in commit messages as: feat: [US-001] - User Registration - "id": "US-001", - - // Short title — keep it under 50 chars for clean commits - "title": "User Registration", - - // Description — user story format gives the agent clear context - "description": "As a new user, I want to register an account so that I can access the application.", - - // Acceptance criteria — the agent checks these off as it works - // Each item should be specific and verifiable - "acceptanceCriteria": [ - "Registration form with email and password fields", - "Email format validation", - "Password minimum 8 characters", - "Confirmation email sent on registration", - "User redirected to login after registration" - ], - - // Priority 1 = done first - "priority": 1, - - // Chief sets this to true when the story passes all checks - "passes": false, - - // Chief sets this to true while the agent is working on it - "inProgress": false - }, - { - "id": "US-002", - "title": "User Login", - "description": "As a registered user, I want to log in so that I can access my account.", - "acceptanceCriteria": [ - "Login form with email and password fields", - "Error message for invalid credentials", - "JWT token issued on success", - "Redirect to dashboard on success" - ], - // Priority 2 = done after US-001 - "priority": 2, - "passes": false, - "inProgress": false - }, - { - "id": "US-003", - "title": "Password Reset", - "description": "As a user, I want to reset my password so that I can recover my account.", - "acceptanceCriteria": [ - "\"Forgot password\" link on login page", - "Email with reset link sent to user", - "Reset token expires after 1 hour", - "New password form with confirmation field" - ], - "priority": 3, - "passes": false, - "inProgress": false - } - ] -} -``` +Here's a complete `prd.md` with annotations explaining each part: -::: info -JSON doesn't support comments. The annotations above are for illustration only — your actual `prd.json` should be valid JSON without comments. -::: +```markdown +# User Authentication ← Project heading (shown in TUI) + +## Overview +Complete auth system with login, ← Freeform context for the agent +registration, and password reset. + +## User Stories + +### US-001: User Registration ← Story ID + title (appears in commits) + +**Status:** done ← Chief tracks this (done/in-progress/todo) +**Priority:** 1 ← Execution order (1 = first) +**Description:** As a new user, I want ← Story description +to register an account so that I can +access the application. + +- [x] Registration form with email ← Acceptance criteria (checked = done) + and password fields +- [x] Email format validation +- [x] Password minimum 8 characters +- [x] Confirmation email sent +- [x] User redirected to login + +### US-002: User Login ← Next story + +**Status:** in-progress ← Currently being worked on +**Priority:** 2 +**Description:** As a registered user, +I want to log in so that I can access +my account. + +- [x] Login form with email and ← Some criteria already met + password fields +- [ ] Error message for invalid ← Still in progress + credentials +- [ ] JWT token issued on success +- [ ] Redirect to dashboard on success + +### US-003: Password Reset ← Pending story + +**Status:** todo +**Priority:** 3 +**Description:** As a user, I want to +reset my password so that I can recover +my account. + +- [ ] "Forgot password" link on login +- [ ] Email with reset link sent +- [ ] Reset token expires after 1 hour +- [ ] New password form with confirmation +``` ## Best Practices @@ -241,21 +217,17 @@ JSON doesn't support comments. The annotations above are for illustration only Each criterion should be concrete and verifiable. The agent uses these to determine what to build and when the story is done. -```json -// ✓ Good — specific and testable -"acceptanceCriteria": [ - "Login form with email and password fields", - "Error message shown for invalid credentials", - "JWT token stored in httpOnly cookie on success", - "Redirect to /dashboard after login" -] - -// ✗ Bad — vague and subjective -"acceptanceCriteria": [ - "Nice login page", - "Good error handling", - "Secure authentication" -] +```markdown + +- [ ] Login form with email and password fields +- [ ] Error message shown for invalid credentials +- [ ] JWT token stored in httpOnly cookie on success +- [ ] Redirect to /dashboard after login + + +- [ ] Nice login page +- [ ] Good error handling +- [ ] Secure authentication ``` ### Keep Stories Small @@ -263,38 +235,44 @@ Each criterion should be concrete and verifiable. The agent uses these to determ A story should represent one logical piece of work. If a story has more than 5–7 acceptance criteria, consider splitting it into multiple stories. **Too large:** -```json -{ - "title": "Complete Authentication System", - "acceptanceCriteria": [ - "Registration form", "Login form", "Password reset", - "Email verification", "OAuth integration", "Session management", - "Rate limiting", "Account lockout", "Audit logging" - ] -} +```markdown +### US-001: Complete Authentication System + +- [ ] Registration form +- [ ] Login form +- [ ] Password reset +- [ ] Email verification +- [ ] OAuth integration +- [ ] Session management +- [ ] Rate limiting +- [ ] Account lockout +- [ ] Audit logging ``` **Better — split into focused stories:** -```json -[ - { "id": "US-001", "title": "User Registration", "priority": 1, ... }, - { "id": "US-002", "title": "User Login", "priority": 2, ... }, - { "id": "US-003", "title": "Password Reset", "priority": 3, ... }, - { "id": "US-004", "title": "OAuth Integration", "priority": 4, ... } -] +```markdown +### US-001: User Registration +### US-002: User Login +### US-003: Password Reset +### US-004: OAuth Integration ``` ### Order Stories by Dependency Use priority to ensure foundational stories are completed before dependent ones. The agent works through stories sequentially, so earlier stories can set up what later stories need. -```json -[ - { "id": "US-001", "title": "Database Schema", "priority": 1 }, - { "id": "US-002", "title": "API Endpoints", "priority": 2 }, - { "id": "US-003", "title": "Frontend Forms", "priority": 3 }, - { "id": "US-004", "title": "Integration Tests", "priority": 4 } -] +```markdown +### US-001: Database Schema +**Priority:** 1 + +### US-002: API Endpoints +**Priority:** 2 + +### US-003: Frontend Forms +**Priority:** 3 + +### US-004: Integration Tests +**Priority:** 4 ``` ### Use Consistent ID Patterns @@ -305,9 +283,9 @@ Story IDs appear in commit messages (`feat: [US-001] - User Registration`). Pick - `AUTH-001`, `AUTH-002` — feature-scoped prefixes - `BUG-001`, `FIX-001` — for bug fix PRDs -### Give the Agent Context in prd.md +### Give the Agent Context -The more context you provide in `prd.md`, the better the output. Include: +The freeform context section at the top of `prd.md` is where you set the agent up for success. Since the context and structured stories live in the same file, the agent sees everything in one place. Include: - What frameworks and tools the project uses - Where to find existing patterns to follow @@ -316,10 +294,10 @@ The more context you provide in `prd.md`, the better the output. Include: ### Use `chief new` to Get Started -Running `chief new` scaffolds both files with a template. You can also run `chief edit` to open an existing PRD for editing. This is the easiest way to create a well-structured PRD. +Running `chief new` scaffolds a `prd.md` with a template. You can also run `chief edit` to open an existing PRD for editing. This is the easiest way to create a well-structured PRD. ## What's Next -- [PRD Schema Reference](/reference/prd-schema) — Complete TypeScript type definitions and field details +- [PRD Format Reference](/reference/prd-schema) — Complete field documentation and validation rules - [The .chief Directory](/concepts/chief-directory) — Understanding the full directory structure - [How Chief Works](/concepts/how-it-works) — How Chief uses these files during execution diff --git a/docs/concepts/ralph-loop.md b/docs/concepts/ralph-loop.md index f4a3d550..34f2f543 100644 --- a/docs/concepts/ralph-loop.md +++ b/docs/concepts/ralph-loop.md @@ -54,7 +54,7 @@ Here's the complete Ralph Loop as a flowchart: └──────┬──────┘ │ │ │ │ │ ▼ │ │ - ┌─────────────┐ │ │ + ┌─────────────┐ │ │ │Stream Output├────────────────────────────▶┘ │ └──────┬──────┘ │ │ session ends │ @@ -91,7 +91,7 @@ Chief reads all the files it needs to understand the current situation: | File | What Chief Learns | |------|-------------------| -| `prd.json` | Which stories are complete (`passes: true`), which are pending, and which is in progress | +| `prd.md` | Which stories are complete (`**Status:** done`), which are pending, and which is in progress | | `progress.md` | What happened in previous iterations: learnings, patterns, and context | | Codebase files | Current state of the code (via the agent's file reading) | @@ -99,20 +99,20 @@ This step ensures the agent always has fresh, accurate information about what's ### 2. Select Next Story -Chief picks the next story to work on by looking at `prd.json`: +Chief picks the next story to work on by looking at `prd.md`: -1. Find all stories where `passes: false` -2. Sort by `priority` (lowest number = highest priority) +1. Find all stories without `**Status:** done` +2. Sort by `**Priority:**` (lowest number = highest priority), or document order if unset 3. Pick the first one -If a story has `inProgress: true`, Chief continues with that story instead of starting a new one. This handles cases where the agent was interrupted mid-story. +If a story has `**Status:** in-progress`, Chief continues with that story instead of starting a new one. This handles cases where the agent was interrupted mid-story. ### 3. Build Prompt Chief constructs a prompt that tells the agent exactly what to do. The prompt includes: - **The user story**: ID, title, description, and acceptance criteria -- **Instructions**: Read the PRD, pick the next story, implement it, run checks, commit +- **Instructions**: Read the PRD, implement the story, run checks, commit - **Progress context**: Any patterns or learnings from `progress.md` Here's a simplified version of what the agent receives: @@ -120,15 +120,13 @@ Here's a simplified version of what the agent receives: ```markdown ## Your Task -1. Read the PRD at `.chief/prds/your-prd/prd.json` +1. Read the PRD at `.chief/prds/your-prd/prd.md` 2. Read `progress.md` if it exists (check Codebase Patterns first) -3. Pick the highest priority story where `passes: false` -4. Mark it as `inProgress: true` in the PRD -5. Implement that single user story -6. Run quality checks (typecheck, lint, test) -7. If checks pass, commit with message: `feat: [Story ID] - [Story Title]` -8. Update the PRD to set `passes: true` and `inProgress: false` -9. Append your progress to `progress.md` +3. Implement the assigned user story +4. Run quality checks (typecheck, lint, test) +5. If checks pass, commit with message: `feat: [Story ID] - [Story Title]` +6. Output `` when the story is complete +7. Append your progress to `progress.md` ``` The prompt is embedded directly in Chief's code. There's no external template file to manage. @@ -157,7 +155,7 @@ Here's what the output stream looks like: ┌─────────────────────────────────────────────────────────────┐ │ Agent Output Stream (stream-json format) │ ├─────────────────────────────────────────────────────────────┤ -│ {"type":"text","content":"Reading prd.json..."} │ +│ {"type":"text","content":"Reading prd.md..."} │ │ {"type":"tool_use","name":"Read","input":{...}} │ │ {"type":"text","content":"Found story US-012..."} │ │ {"type":"tool_use","name":"Write","input":{...}} │ @@ -175,15 +173,13 @@ Chief parses this stream to display progress in the TUI. When the agent's sessio ### 6. The Completion Signal -When the agent determines that **all stories are complete**, it outputs a special marker: +When the agent finishes working on a story, it outputs a special marker: ``` - + ``` -This signal tells Chief to break out of the loop early. There's no need to spawn another iteration just to discover there's nothing left to do. It's an optimization, not the primary mechanism for tracking story completion. - -Individual story completion is tracked through the PRD itself (`passes: true`), not through this signal. +This signal tells Chief that the **current story** is done. Chief then marks the story as `**Status:** done` in `prd.md` and selects the next incomplete story. When no stories remain, the loop ends naturally. ### 7. Continue the Loop @@ -193,7 +189,7 @@ After each agent session ends, Chief: 2. Checks if max iterations is reached 3. If not at limit, loops back to step 1 (Read State) -The next iteration starts fresh. The agent reads the updated PRD, sees the completed story, and picks the next one. If all stories are done, Chief stops. +The next iteration starts fresh. The agent reads the updated `prd.md`, sees the completed story, and picks the next one. If all stories are done, Chief stops. ## Iteration Limits diff --git a/docs/guide/quick-start.md b/docs/guide/quick-start.md index ed8a3722..c7214d43 100644 --- a/docs/guide/quick-start.md +++ b/docs/guide/quick-start.md @@ -67,13 +67,12 @@ chief new This launches your agent CLI with a preloaded prompt. Work with the agent to describe what you want to build—your project goals, user stories, and acceptance criteria. The agent will help structure your requirements and write the `prd.md` file. -When you're done, type `/exit` to leave the agent session. Chief then parses `prd.md` and generates `prd.json`: +When you're done, type `/exit` to leave the agent session. Chief validates the markdown structure can be parsed: -- `prd.md` - Human-readable project requirements (written collaboratively with the agent) -- `prd.json` - Machine-readable user stories for Chief to execute (generated by Chief) +- `prd.md` - Structured PRD with freeform context and user stories (written collaboratively with the agent) ::: tip Iterating on your PRD -Run `chief edit` to reopen the agent and refine your `prd.md`. Chief will regenerate `prd.json` when you `/exit`. +Run `chief edit` to reopen the agent and refine your `prd.md`. Chief will validate the structure when you `/exit`. ::: ## Step 3: Launch the TUI diff --git a/docs/reference/cli.md b/docs/reference/cli.md index bcc59c10..7b4d92b0 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -94,7 +94,7 @@ chief new [name] [context] 2. You describe your project, goals, and user stories conversationally 3. The agent helps structure your requirements and writes `prd.md` 4. When done, type `/exit` to leave the agent session -5. Chief parses `prd.md` and generates `prd.json` +5. Chief validates the `prd.md` can be parsed **What it creates:** @@ -102,8 +102,7 @@ chief new [name] [context] .chief/ └── prds/ └── / - ├── prd.md # Markdown PRD (written with the agent) - └── prd.json # Structured stories (generated by Chief) + └── prd.md # Structured PRD (written with the agent) ``` **Examples:** @@ -136,7 +135,7 @@ Open an existing PRD for editing via the agent CLI. chief edit ``` -Launches the agent with your PRD loaded, allowing you to refine requirements, add stories, or update `prd.md` conversationally. When you `/exit`, Chief regenerates `prd.json` from the updated `prd.md`. +Launches the agent with your PRD loaded, allowing you to refine requirements, add stories, or update `prd.md` conversationally. When you `/exit`, Chief validates the updated `prd.md` can be parsed. **Arguments:** @@ -144,13 +143,6 @@ Launches the agent with your PRD loaded, allowing you to refine requirements, ad |----------|-------------| | `name` | PRD name to edit (optional, auto-detects if omitted) | -**Flags:** - -| Flag | Description | -|------|-------------| -| `--merge` | Auto-merge progress on conversion conflicts | -| `--force` | Auto-overwrite on conversion conflicts | - **Examples:** ```bash @@ -159,9 +151,6 @@ chief edit # Edit a specific PRD chief edit auth-system - -# Edit and auto-merge progress -chief edit auth-system --merge ``` --- diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 8c3d404c..4774531a 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -93,8 +93,6 @@ These settings are saved to `.chief/config.yaml` and can be changed at any time | `--max-iterations `, `-n` | Loop iteration limit | Dynamic | | `--no-retry` | Disable auto-retry on agent crashes | `false` | | `--verbose` | Show raw agent output in log | `false` | -| `--merge` | Auto-merge progress on conversion conflicts | `false` | -| `--force` | Auto-overwrite on conversion conflicts | `false` | Agent resolution order: `--agent` / `--agent-path` → `CHIEF_AGENT` / `CHIEF_AGENT_PATH` env vars → `agent.provider` / `agent.cliPath` in `.chief/config.yaml` → default `claude`. diff --git a/docs/reference/prd-schema.md b/docs/reference/prd-schema.md index 431631b1..671519c5 100644 --- a/docs/reference/prd-schema.md +++ b/docs/reference/prd-schema.md @@ -1,102 +1,119 @@ --- -description: Complete prd.json schema reference for Chief. TypeScript interfaces, field types, and validation rules for PRD files. +description: Complete prd.md format reference for Chief. Heading structure, field types, status values, and validation rules. --- -# PRD Schema Reference +# PRD Format Reference -Complete schema documentation for `prd.json`. +Complete format documentation for `prd.md`. -## Top-Level Schema +## Story Heading Format -```typescript -interface PRD { - project: string; // Project name - description: string; // Brief description - userStories: UserStory[]; // Array of user stories -} +Each user story is defined by a level-3 markdown heading with an ID and title: + +```markdown +### ID: Title +``` + +**Examples:** +```markdown +### US-001: User Registration +### AUTH-003: Password Reset Flow +### BUG-012: Fix Login Redirect ``` -## UserStory Object - -```typescript -interface UserStory { - id: string; // Unique identifier - title: string; // Short title - description: string; // Full description - acceptanceCriteria: string[]; // What must be true - priority: number; // Lower = higher priority - passes: boolean; // Is this complete? - inProgress: boolean; // Being worked on? -} +## Story Fields + +Below each story heading, Chief recognizes these bold-label fields: + +| Field | Format | Required | Default | Description | +|-------|--------|----------|---------|-------------| +| Status | `**Status:** value` | No | `todo` | Current state: `done`, `in-progress`, or `todo` | +| Priority | `**Priority:** N` | No | Document order | Execution order (lower = higher priority) | +| Description | `**Description:** text` | No | — | Story description (or use freeform prose) | + +## Acceptance Criteria + +Acceptance criteria use markdown checkboxes: + +```markdown +- [ ] Criterion not yet met +- [x] Criterion completed ``` +Chief reads checkbox state to track progress. The agent checks boxes as it completes each criterion. + +## Status Values + +| Value | Meaning | +|-------|---------| +| `done` | Story is complete — Chief skips it | +| `in-progress` | Agent is actively working on this story | +| `todo` | Story is pending (also the default if Status is absent) | + ## Full Example -```json -{ - "project": "User Authentication", - "description": "Complete auth system with login, registration, and password reset", - "userStories": [ - { - "id": "US-001", - "title": "User Registration", - "description": "As a new user, I want to register an account so that I can access the application.", - "acceptanceCriteria": [ - "Registration form with email and password fields", - "Email format validation", - "Password minimum 8 characters", - "Confirmation email sent on registration", - "User redirected to login after registration" - ], - "priority": 1, - "passes": false, - "inProgress": false - }, - { - "id": "US-002", - "title": "User Login", - "description": "As a registered user, I want to log in so that I can access my account.", - "acceptanceCriteria": [ - "Login form with email and password fields", - "Error message for invalid credentials", - "Remember me checkbox", - "Redirect to dashboard on success" - ], - "priority": 2, - "passes": false, - "inProgress": false - } - ] -} +```markdown +# User Authentication + +## Overview +Complete auth system with login, registration, and password reset. + +## Technical Context +- Backend: Express.js with TypeScript +- Database: PostgreSQL with Prisma ORM +- Auth: JWT tokens in httpOnly cookies + +## User Stories + +### US-001: User Registration + +**Status:** done +**Priority:** 1 +**Description:** As a new user, I want to register an account so that I can access the application. + +- [x] Registration form with email and password fields +- [x] Email format validation +- [x] Password minimum 8 characters +- [x] Confirmation email sent on registration +- [x] User redirected to login after registration + +### US-002: User Login + +**Status:** todo +**Priority:** 2 +**Description:** As a registered user, I want to log in so that I can access my account. + +- [ ] Login form with email and password fields +- [ ] Error message for invalid credentials +- [ ] Remember me checkbox +- [ ] Redirect to dashboard on success ``` ## Field Details -### id - -A unique identifier for the story. Appears in commit messages. +### id (from heading) -**Format:** Any string, but `US-XXX` pattern recommended. +Parsed from the story heading: `### US-001: Title` → id is `US-001`. -**Example:** `"US-001"`, `"US-042"`, `"AUTH-001"` +**Format:** Any string before the colon, but `US-XXX` pattern recommended. -### title +**Example:** `US-001`, `US-042`, `AUTH-001` -Short, descriptive title. Should fit in a commit message. +### title (from heading) -**Length:** Keep under 50 characters +Parsed from the story heading: `### US-001: User Registration` → title is `User Registration`. -**Example:** `"User Registration"`, `"Password Reset Flow"` +**Length:** Keep under 50 characters for clean commit messages. ### description -Full description of the story. User story format recommended but not required. +The text after `**Description:**`, or freeform prose between the heading and the first checkbox list. -**Format:** `"As a [user], I want [feature] so that [benefit]."` +**Format:** `"As a [user], I want [feature] so that [benefit]."` recommended but not required. -### acceptanceCriteria +### acceptanceCriteria (checkboxes) -Array of strings, each describing a requirement. The agent uses these to know when the story is complete. +The `- [ ]` / `- [x]` items under each story heading. The agent uses these to know when the story is complete. **Guidelines:** - Specific and testable @@ -105,29 +122,23 @@ Array of strings, each describing a requirement. The agent uses these to know wh ### priority -Lower numbers = higher priority. Chief always picks the incomplete story with the lowest priority number first. +Lower numbers = higher priority. Chief always picks the incomplete story with the lowest priority number first. If omitted, stories are selected in document order. **Range:** Positive integers, typically 1-100 -### passes - -Boolean indicating if the story is complete. Chief updates this automatically. - -**Default:** `false` - -### inProgress +### status -Boolean indicating if the agent is currently working on this story. +Tracked by Chief. Set to `in-progress` when work begins, `done` when the agent outputs ``. -**Default:** `false` +**Values:** `done`, `in-progress`, `todo` (default if absent) ## Validation -Chief validates `prd.json` on startup: +Chief validates `prd.md` on startup by parsing the markdown structure: -- All required fields must be present -- `userStories` must be non-empty -- Each story must have unique `id` -- `priority` must be a positive number +- At least one story heading (`### ID: Title`) must be present +- Each story must have a unique ID +- Priority values (if present) must be positive numbers +- Status values (if present) must be `done`, `in-progress`, or `todo` -Invalid PRDs cause Chief to exit with an error message. +Invalid PRDs cause Chief to exit with an error message describing the parsing issue. diff --git a/docs/troubleshooting/common-issues.md b/docs/troubleshooting/common-issues.md index f50b1b31..0b06d2dc 100644 --- a/docs/troubleshooting/common-issues.md +++ b/docs/troubleshooting/common-issues.md @@ -68,13 +68,9 @@ Chief automatically configures the agent for autonomous operation by disabling p tail -100 .chief/prds/your-prd/claude.log # or codex.log / opencode.log ``` -2. Manually mark story complete if appropriate: - ```json - { - "id": "US-001", - "passes": true, - "inProgress": false - } +2. Manually mark story complete if appropriate by editing `prd.md`: + ```markdown + **Status:** done ``` 3. Restart Chief to pick up where it left off @@ -144,27 +140,26 @@ Chief automatically configures the agent for autonomous operation by disabling p .chief/ └── prds/ └── my-feature/ - ├── prd.md - └── prd.json + └── prd.md ``` -## Invalid JSON +## Invalid PRD Format -**Symptom:** Error parsing `prd.json`. +**Symptom:** Error parsing `prd.md`. -**Cause:** Syntax error in the JSON file. +**Cause:** The markdown structure doesn't match what Chief expects. **Solution:** -1. Validate your JSON: - ```bash - cat .chief/prds/your-prd/prd.json | jq . +1. Verify your story headings use the correct format: + ```markdown + ### US-001: Story Title ``` 2. Common issues: - - Trailing commas (not allowed in JSON) - - Missing quotes around keys - - Unescaped characters in strings + - Missing colon between ID and title in heading + - Invalid `**Status:**` value (must be `done`, `in-progress`, or `todo`) + - Non-numeric `**Priority:**` value ## Worktree Setup Failures @@ -259,5 +254,5 @@ If none of these solutions help: 2. Search [GitHub Issues](https://github.com/minicodemonkey/chief/issues) 3. Open a new issue with: - Chief version (`chief --version`) - - Your `prd.json` (sanitized) + - Your `prd.md` (sanitized) - Relevant agent log excerpts (e.g. `claude.log`, `codex.log`, or `opencode.log`) diff --git a/docs/troubleshooting/faq.md b/docs/troubleshooting/faq.md index 9582e684..078a020e 100644 --- a/docs/troubleshooting/faq.md +++ b/docs/troubleshooting/faq.md @@ -43,11 +43,11 @@ chief ### How do I resume after stopping? -Run `chief` again and press `s` to start. It reads state from `prd.json` and continues where it left off. +Run `chief` again and press `s` to start. It reads state from `prd.md` and continues where it left off. ### Can I edit the PRD while Chief is running? -Yes, but be careful. Chief re-reads `prd.json` between iterations. Edits to the current story might cause confusion. +Yes, but be careful. Chief re-reads `prd.md` between iterations. Edits to the current story might cause confusion. Best practice: pause Chief with `p` (or stop with `x`), edit, then press `s` to resume. @@ -65,14 +65,12 @@ Run with `chief feature-a` or use the TUI: press `n` to open the PRD picker, or ### How do I skip a story? -Mark it as passed manually: +Mark it as done manually in `prd.md`: -```json -{ - "id": "US-003", - "passes": true, - "inProgress": false -} +```markdown +### US-003: Story Title + +**Status:** done ``` Or remove it from the PRD entirely. diff --git a/embed/convert_prompt.txt b/embed/convert_prompt.txt deleted file mode 100644 index 77474bf1..00000000 --- a/embed/convert_prompt.txt +++ /dev/null @@ -1,41 +0,0 @@ -You are a PRD converter. Your task is to convert a Product Requirements Document (PRD) written in Markdown to a structured JSON format. - -Read the PRD file at: {{PRD_FILE_PATH}} - -Then output ONLY the raw JSON to stdout — no markdown fences, no explanation, no preamble, no commentary. The JSON must follow this exact structure: - -{ - "project": "Project Name", - "description": "Brief project description", - "userStories": [ - { - "id": "{{ID_PREFIX}}-001", - "title": "Story Title", - "description": "Full description of what the user story accomplishes", - "acceptanceCriteria": [ - "First acceptance criterion", - "Second acceptance criterion" - ], - "priority": 1, - "passes": false - } - ] -} - -Rules: -1. Extract the project name from the main heading (# heading) -2. Extract the description from the introductory paragraph -3. For each user story: - - Generate sequential IDs: {{ID_PREFIX}}-001, {{ID_PREFIX}}-002, etc. - - Extract title from story heading - - Extract description from story body - - Extract acceptance criteria as an array of strings - - Assign priority based on order (first story = 1, second = 2, etc.) - - Set "passes" to false for all stories (progress tracking happens later) -4. Do NOT include "inProgress" field for new stories -5. CRITICAL - JSON string escaping: All double quotes inside JSON string values MUST be escaped with a backslash. For example: - - WRONG: "description": "Click the "Submit" button" - - RIGHT: "description": "Click the \"Submit\" button" - This applies to ALL string fields: title, description, and every entry in acceptanceCriteria. -6. Ensure the JSON is valid and properly formatted with 2-space indentation -7. Include ALL user stories from the PRD — do not skip or truncate any stories diff --git a/embed/embed.go b/embed/embed.go index b583e758..f4734207 100644 --- a/embed/embed.go +++ b/embed/embed.go @@ -16,19 +16,15 @@ var initPromptTemplate string //go:embed edit_prompt.txt var editPromptTemplate string -//go:embed convert_prompt.txt -var convertPromptTemplate string - //go:embed detect_setup_prompt.txt var detectSetupPromptTemplate string -// GetPrompt returns the agent prompt with the PRD path, progress path, and +// GetPrompt returns the agent prompt with the progress path and // current story context substituted. The storyContext is the JSON of the // current story to work on, inlined directly into the prompt so that the -// agent does not need to read the entire prd.json file. -func GetPrompt(prdPath, progressPath, storyContext, storyID, storyTitle string) string { - result := strings.ReplaceAll(promptTemplate, "{{PRD_PATH}}", prdPath) - result = strings.ReplaceAll(result, "{{PROGRESS_PATH}}", progressPath) +// agent does not need to read the entire prd.md file. +func GetPrompt(progressPath, storyContext, storyID, storyTitle string) string { + result := strings.ReplaceAll(promptTemplate, "{{PROGRESS_PATH}}", progressPath) result = strings.ReplaceAll(result, "{{STORY_CONTEXT}}", storyContext) result = strings.ReplaceAll(result, "{{STORY_ID}}", storyID) return strings.ReplaceAll(result, "{{STORY_TITLE}}", storyTitle) @@ -48,14 +44,6 @@ func GetEditPrompt(prdDir string) string { return strings.ReplaceAll(editPromptTemplate, "{{PRD_DIR}}", prdDir) } -// GetConvertPrompt returns the PRD converter prompt with the file path and ID prefix substituted. -// Claude reads the file itself using file-reading tools instead of receiving inlined content. -// The idPrefix determines the story ID convention (e.g., "US" → US-001, "MFR" → MFR-001). -func GetConvertPrompt(prdFilePath, idPrefix string) string { - result := strings.ReplaceAll(convertPromptTemplate, "{{PRD_FILE_PATH}}", prdFilePath) - return strings.ReplaceAll(result, "{{ID_PREFIX}}", idPrefix) -} - // GetDetectSetupPrompt returns the prompt for detecting project setup commands. func GetDetectSetupPrompt() string { return detectSetupPromptTemplate diff --git a/embed/embed_test.go b/embed/embed_test.go index 9c432406..aff9f436 100644 --- a/embed/embed_test.go +++ b/embed/embed_test.go @@ -6,15 +6,11 @@ import ( ) func TestGetPrompt(t *testing.T) { - prdPath := "/path/to/prd.json" progressPath := "/path/to/progress.md" storyContext := `{"id":"US-001","title":"Test Story"}` - prompt := GetPrompt(prdPath, progressPath, storyContext, "US-001", "Test Story") + prompt := GetPrompt(progressPath, storyContext, "US-001", "Test Story") // Verify all placeholders were substituted - if strings.Contains(prompt, "{{PRD_PATH}}") { - t.Error("Expected {{PRD_PATH}} to be substituted") - } if strings.Contains(prompt, "{{PROGRESS_PATH}}") { t.Error("Expected {{PROGRESS_PATH}} to be substituted") } @@ -33,11 +29,6 @@ func TestGetPrompt(t *testing.T) { t.Error("Expected prompt to contain exact commit message 'feat: US-001 - Test Story'") } - // Verify the PRD path appears in the prompt - if !strings.Contains(prompt, prdPath) { - t.Errorf("Expected prompt to contain PRD path %q", prdPath) - } - // Verify the progress path appears in the prompt if !strings.Contains(prompt, progressPath) { t.Errorf("Expected prompt to contain progress path %q", progressPath) @@ -48,22 +39,14 @@ func TestGetPrompt(t *testing.T) { t.Error("Expected prompt to contain inlined story context") } - // Verify the prompt contains key instructions - if !strings.Contains(prompt, "chief-complete") { - t.Error("Expected prompt to contain chief-complete instruction") - } - - if !strings.Contains(prompt, "ralph-status") { - t.Error("Expected prompt to contain ralph-status instruction") - } - - if !strings.Contains(prompt, "passes: true") { - t.Error("Expected prompt to contain passes: true instruction") + // Verify the prompt contains chief-done stop condition + if !strings.Contains(prompt, "chief-done") { + t.Error("Expected prompt to contain chief-done instruction") } } func TestGetPrompt_NoFileReadInstruction(t *testing.T) { - prompt := GetPrompt("/path/prd.json", "/path/progress.md", `{"id":"US-001"}`, "US-001", "Test Story") + prompt := GetPrompt("/path/progress.md", `{"id":"US-001"}`, "US-001", "Test Story") // The prompt should NOT instruct Claude to read the PRD file if strings.Contains(prompt, "Read the PRD") { @@ -78,7 +61,7 @@ func TestPromptTemplateNotEmpty(t *testing.T) { } func TestGetPrompt_ChiefExclusion(t *testing.T) { - prompt := GetPrompt("/path/prd.json", "/path/progress.md", `{"id":"US-001"}`, "US-001", "Test Story") + prompt := GetPrompt("/path/progress.md", `{"id":"US-001"}`, "US-001", "Test Story") // The prompt must instruct Claude to never stage or commit .chief/ files if !strings.Contains(prompt, ".chief/") { @@ -87,74 +70,6 @@ func TestGetPrompt_ChiefExclusion(t *testing.T) { if !strings.Contains(prompt, "NEVER stage or commit") { t.Error("Expected prompt to explicitly say NEVER stage or commit .chief/ files") } - // The commit step should not say "commit ALL changes" anymore - if strings.Contains(prompt, "commit ALL changes") { - t.Error("Expected prompt to NOT say 'commit ALL changes' — it should exclude .chief/ files") - } -} - -func TestGetConvertPrompt(t *testing.T) { - prdFilePath := "/path/to/prds/main/prd.md" - prompt := GetConvertPrompt(prdFilePath, "US") - - // Verify the prompt is not empty - if prompt == "" { - t.Error("Expected GetConvertPrompt() to return non-empty prompt") - } - - // Verify file path is substituted (not inlined content) - if !strings.Contains(prompt, prdFilePath) { - t.Error("Expected prompt to contain the PRD file path") - } - if strings.Contains(prompt, "{{PRD_FILE_PATH}}") { - t.Error("Expected {{PRD_FILE_PATH}} to be substituted") - } - - // Verify the old {{PRD_CONTENT}} placeholder is completely removed - if strings.Contains(prompt, "{{PRD_CONTENT}}") { - t.Error("Expected {{PRD_CONTENT}} placeholder to be completely removed") - } - - // Verify ID prefix is substituted - if strings.Contains(prompt, "{{ID_PREFIX}}") { - t.Error("Expected {{ID_PREFIX}} to be substituted") - } - if !strings.Contains(prompt, "US-001") { - t.Error("Expected prompt to contain US-001 when prefix is US") - } - - // Verify key instructions are present - if !strings.Contains(prompt, "JSON") { - t.Error("Expected prompt to mention JSON") - } - - if !strings.Contains(prompt, "userStories") { - t.Error("Expected prompt to describe userStories structure") - } - - if !strings.Contains(prompt, `"passes": false`) { - t.Error("Expected prompt to specify passes: false default") - } - - // Verify prompt instructs Claude to read the file - if !strings.Contains(prompt, "Read the PRD file") { - t.Error("Expected prompt to instruct Claude to read the PRD file") - } -} - -func TestGetConvertPrompt_CustomPrefix(t *testing.T) { - prompt := GetConvertPrompt("/path/prd.md", "MFR") - - // Verify custom prefix is used, not hardcoded US - if strings.Contains(prompt, "{{ID_PREFIX}}") { - t.Error("Expected {{ID_PREFIX}} to be substituted") - } - if !strings.Contains(prompt, "MFR-001") { - t.Error("Expected prompt to contain MFR-001 when prefix is MFR") - } - if !strings.Contains(prompt, "MFR-002") { - t.Error("Expected prompt to contain MFR-002 when prefix is MFR") - } } func TestGetInitPrompt(t *testing.T) { diff --git a/embed/prompt.txt b/embed/prompt.txt index c9f88dae..8b9dee1f 100644 --- a/embed/prompt.txt +++ b/embed/prompt.txt @@ -10,14 +10,12 @@ Your current story: 1. Read `{{PROGRESS_PATH}}` if it exists (check Codebase Patterns section first) -2. After determining which story to work on, output exact story id, e.g.: US-056 -3. Implement the user story above -4. Run quality checks (e.g., typecheck, lint, test - use whatever your project requires) -5. If checks pass, commit changes with message: `feat: {{STORY_ID}} - {{STORY_TITLE}}` +2. Implement the user story above +3. Run quality checks (e.g., typecheck, lint, test - use whatever your project requires) +4. If checks pass, commit changes with message: `feat: {{STORY_ID}} - {{STORY_TITLE}}` - **NEVER stage or commit `.chief/` files** — these are local working files and must stay out of version control - Stage only the files you changed for the story (do NOT use `git add -A` or `git add .`) -6. Update the PRD at `{{PRD_PATH}}` to set `passes: true` for the completed story -7. Append your progress to `{{PROGRESS_PATH}}` +5. Append your progress to `{{PROGRESS_PATH}}` ## Progress Report Format @@ -57,12 +55,10 @@ Only add patterns that are **general and reusable**, not story-specific details. ## Stop Condition -After completing a user story, check if ALL stories have `passes: true`. - -If ALL stories are complete and passing, reply with: - - -If there are still stories with `passes: false`, end your response normally (another iteration will pick up the next story). +After implementing the story: +1. Review EACH acceptance criterion one by one and verify it is met +2. Only if ALL criteria pass: output +3. If any criterion is NOT met: end your response WITHOUT ## Important diff --git a/internal/agent/claude.go b/internal/agent/claude.go index 6b007e2a..e6f5999e 100644 --- a/internal/agent/claude.go +++ b/internal/agent/claude.go @@ -3,7 +3,6 @@ package agent import ( "context" "os/exec" - "strings" "github.com/minicodemonkey/chief/internal/loop" ) @@ -47,20 +46,6 @@ func (p *ClaudeProvider) InteractiveCommand(workDir, prompt string) *exec.Cmd { return cmd } -// ConvertCommand implements loop.Provider. -func (p *ClaudeProvider) ConvertCommand(workDir, prompt string) (*exec.Cmd, loop.OutputMode, string, error) { - cmd := exec.Command(p.cliPath, "-p") - cmd.Dir = workDir - cmd.Stdin = strings.NewReader(prompt) - return cmd, loop.OutputStdout, "", nil -} - -// FixJSONCommand implements loop.Provider. -func (p *ClaudeProvider) FixJSONCommand(prompt string) (*exec.Cmd, loop.OutputMode, string, error) { - cmd := exec.Command(p.cliPath, "-p", prompt) - return cmd, loop.OutputStdout, "", nil -} - // ParseLine implements loop.Provider. func (p *ClaudeProvider) ParseLine(line string) *loop.Event { return loop.ParseLine(line) diff --git a/internal/agent/claude_test.go b/internal/agent/claude_test.go index f2e3f744..3ddad551 100644 --- a/internal/agent/claude_test.go +++ b/internal/agent/claude_test.go @@ -54,55 +54,6 @@ func TestClaudeProvider_LoopCommand(t *testing.T) { } } -func TestClaudeProvider_ConvertCommand(t *testing.T) { - p := NewClaudeProvider("/bin/claude") - cmd, mode, outPath, err := p.ConvertCommand("/prd/dir", "convert prompt") - if err != nil { - t.Fatalf("ConvertCommand unexpected error: %v", err) - } - if mode != loop.OutputStdout { - t.Errorf("ConvertCommand mode = %v, want OutputStdout", mode) - } - if outPath != "" { - t.Errorf("ConvertCommand outPath = %q, want empty string", outPath) - } - if cmd.Dir != "/prd/dir" { - t.Errorf("ConvertCommand Dir = %q, want /prd/dir", cmd.Dir) - } - // Should use -p flag with stdin - wantArgs := []string{"/bin/claude", "-p"} - if len(cmd.Args) != len(wantArgs) { - t.Fatalf("ConvertCommand Args = %v, want %v", cmd.Args, wantArgs) - } - for i, w := range wantArgs { - if cmd.Args[i] != w { - t.Errorf("ConvertCommand Args[%d] = %q, want %q", i, cmd.Args[i], w) - } - } - if cmd.Stdin == nil { - t.Error("ConvertCommand Stdin must be set (prompt via stdin)") - } -} - -func TestClaudeProvider_FixJSONCommand(t *testing.T) { - p := NewClaudeProvider("/bin/claude") - cmd, mode, outPath, err := p.FixJSONCommand("fix prompt") - if err != nil { - t.Fatalf("FixJSONCommand unexpected error: %v", err) - } - if mode != loop.OutputStdout { - t.Errorf("FixJSONCommand mode = %v, want OutputStdout", mode) - } - if outPath != "" { - t.Errorf("FixJSONCommand outPath = %q, want empty string", outPath) - } - // Should pass prompt as arg to -p - wantArgs := []string{"/bin/claude", "-p", "fix prompt"} - if len(cmd.Args) != len(wantArgs) { - t.Fatalf("FixJSONCommand Args = %v, want %v", cmd.Args, wantArgs) - } -} - func TestClaudeProvider_InteractiveCommand(t *testing.T) { p := NewClaudeProvider("/bin/claude") cmd := p.InteractiveCommand("/work", "my prompt") diff --git a/internal/agent/codex.go b/internal/agent/codex.go index cbd74826..1dd993fd 100644 --- a/internal/agent/codex.go +++ b/internal/agent/codex.go @@ -2,8 +2,6 @@ package agent import ( "context" - "fmt" - "os" "os/exec" "strings" @@ -45,33 +43,6 @@ func (p *CodexProvider) InteractiveCommand(workDir, prompt string) *exec.Cmd { return cmd } -// ConvertCommand implements loop.Provider. -func (p *CodexProvider) ConvertCommand(workDir, prompt string) (*exec.Cmd, loop.OutputMode, string, error) { - f, err := os.CreateTemp("", "chief-codex-convert-*.txt") - if err != nil { - return nil, 0, "", fmt.Errorf("failed to create temp file for conversion output: %w", err) - } - outPath := f.Name() - f.Close() - cmd := exec.Command(p.cliPath, "exec", "--sandbox", "read-only", "-o", outPath, "-") - cmd.Dir = workDir - cmd.Stdin = strings.NewReader(prompt) - return cmd, loop.OutputFromFile, outPath, nil -} - -// FixJSONCommand implements loop.Provider. -func (p *CodexProvider) FixJSONCommand(prompt string) (*exec.Cmd, loop.OutputMode, string, error) { - f, err := os.CreateTemp("", "chief-codex-fixjson-*.txt") - if err != nil { - return nil, 0, "", fmt.Errorf("failed to create temp file for fix output: %w", err) - } - outPath := f.Name() - f.Close() - cmd := exec.Command(p.cliPath, "exec", "--sandbox", "read-only", "-o", outPath, "-") - cmd.Stdin = strings.NewReader(prompt) - return cmd, loop.OutputFromFile, outPath, nil -} - // ParseLine implements loop.Provider. func (p *CodexProvider) ParseLine(line string) *loop.Event { return loop.ParseLineCodex(line) diff --git a/internal/agent/codex_test.go b/internal/agent/codex_test.go index 3a9d2c00..625ed2fd 100644 --- a/internal/agent/codex_test.go +++ b/internal/agent/codex_test.go @@ -2,7 +2,6 @@ package agent import ( "context" - "strings" "testing" "github.com/minicodemonkey/chief/internal/loop" @@ -60,60 +59,6 @@ func TestCodexProvider_LoopCommand(t *testing.T) { // We can't easily read cmd.Stdin without running; just check it's non-nil (done above) } -func TestCodexProvider_ConvertCommand(t *testing.T) { - p := NewCodexProvider("codex") - cmd, mode, outPath, err := p.ConvertCommand("/prd/dir", "convert prompt") - if err != nil { - t.Fatalf("ConvertCommand unexpected error: %v", err) - } - if mode != loop.OutputFromFile { - t.Errorf("ConvertCommand mode = %v, want OutputFromFile", mode) - } - if outPath == "" { - t.Error("ConvertCommand outPath should be non-empty temp file") - } - if !strings.Contains(cmd.Path, "codex") { - t.Errorf("ConvertCommand Path = %q", cmd.Path) - } - foundO := false - for i, a := range cmd.Args { - if a == "-o" && i+1 < len(cmd.Args) && cmd.Args[i+1] == outPath { - foundO = true - break - } - } - if !foundO { - t.Errorf("ConvertCommand should have -o %q in args: %v", outPath, cmd.Args) - } - if cmd.Dir != "/prd/dir" { - t.Errorf("ConvertCommand Dir = %q, want /prd/dir", cmd.Dir) - } -} - -func TestCodexProvider_FixJSONCommand(t *testing.T) { - p := NewCodexProvider("codex") - cmd, mode, outPath, err := p.FixJSONCommand("fix prompt") - if err != nil { - t.Fatalf("FixJSONCommand unexpected error: %v", err) - } - if mode != loop.OutputFromFile { - t.Errorf("FixJSONCommand mode = %v, want OutputFromFile", mode) - } - if outPath == "" { - t.Error("FixJSONCommand outPath should be non-empty temp file") - } - foundO := false - for i, a := range cmd.Args { - if a == "-o" && i+1 < len(cmd.Args) && cmd.Args[i+1] == outPath { - foundO = true - break - } - } - if !foundO { - t.Errorf("FixJSONCommand should have -o %q in args: %v", outPath, cmd.Args) - } -} - func TestCodexProvider_InteractiveCommand(t *testing.T) { p := NewCodexProvider("codex") cmd := p.InteractiveCommand("/work", "my prompt") diff --git a/internal/agent/opencode.go b/internal/agent/opencode.go index b026306a..25f2a10c 100644 --- a/internal/agent/opencode.go +++ b/internal/agent/opencode.go @@ -36,17 +36,6 @@ func (p *OpenCodeProvider) InteractiveCommand(workDir, prompt string) *exec.Cmd return cmd } -func (p *OpenCodeProvider) ConvertCommand(workDir, prompt string) (*exec.Cmd, loop.OutputMode, string, error) { - cmd := exec.Command(p.cliPath, "run", "--format", "json", "--", prompt) - cmd.Dir = workDir - return cmd, loop.OutputStdout, "", nil -} - -func (p *OpenCodeProvider) FixJSONCommand(prompt string) (*exec.Cmd, loop.OutputMode, string, error) { - cmd := exec.Command(p.cliPath, "run", "--format", "json", "--", prompt) - return cmd, loop.OutputStdout, "", nil -} - func (p *OpenCodeProvider) ParseLine(line string) *loop.Event { return loop.ParseLineOpenCode(line) } @@ -61,7 +50,6 @@ func (p *OpenCodeProvider) CleanOutput(output string) string { return output } - // Parse each line as JSON to find text events (last one wins). var lastText string for _, line := range strings.Split(output, "\n") { line = strings.TrimSpace(line) diff --git a/internal/agent/opencode_test.go b/internal/agent/opencode_test.go index e1123e17..c7f2ea68 100644 --- a/internal/agent/opencode_test.go +++ b/internal/agent/opencode_test.go @@ -54,55 +54,6 @@ func TestOpenCodeProvider_LoopCommand(t *testing.T) { } } -func TestOpenCodeProvider_ConvertCommand(t *testing.T) { - p := NewOpenCodeProvider("/bin/opencode") - cmd, mode, outPath, err := p.ConvertCommand("/prd/dir", "convert prompt") - if err != nil { - t.Fatalf("ConvertCommand unexpected error: %v", err) - } - if mode != loop.OutputStdout { - t.Errorf("ConvertCommand mode = %v, want OutputStdout", mode) - } - if outPath != "" { - t.Errorf("ConvertCommand outPath = %q, want empty string", outPath) - } - if cmd.Dir != "/prd/dir" { - t.Errorf("ConvertCommand Dir = %q, want /prd/dir", cmd.Dir) - } - // Prompt is passed as CLI argument, not stdin - if cmd.Stdin != nil { - t.Error("ConvertCommand Stdin should be nil (prompt passed as arg)") - } - // Check args contain the prompt after "--" - wantArgs := []string{"/bin/opencode", "run", "--format", "json", "--", "convert prompt"} - if len(cmd.Args) != len(wantArgs) { - t.Fatalf("ConvertCommand Args = %v, want %v", cmd.Args, wantArgs) - } - for i, w := range wantArgs { - if cmd.Args[i] != w { - t.Errorf("ConvertCommand Args[%d] = %q, want %q", i, cmd.Args[i], w) - } - } -} - -func TestOpenCodeProvider_FixJSONCommand(t *testing.T) { - p := NewOpenCodeProvider("/bin/opencode") - cmd, mode, outPath, err := p.FixJSONCommand("fix prompt") - if err != nil { - t.Fatalf("FixJSONCommand unexpected error: %v", err) - } - if mode != loop.OutputStdout { - t.Errorf("FixJSONCommand mode = %v, want OutputStdout", mode) - } - if outPath != "" { - t.Errorf("FixJSONCommand outPath = %q, want empty string", outPath) - } - // Prompt is passed as CLI argument, not stdin - if cmd.Stdin != nil { - t.Error("FixJSONCommand Stdin should be nil (prompt passed as arg)") - } -} - func TestOpenCodeProvider_CleanOutput_PlainText(t *testing.T) { p := NewOpenCodeProvider("") // Non-NDJSON input should be returned as-is diff --git a/internal/cmd/convert.go b/internal/cmd/convert.go deleted file mode 100644 index 931e5dbf..00000000 --- a/internal/cmd/convert.go +++ /dev/null @@ -1,76 +0,0 @@ -package cmd - -import ( - "bytes" - "fmt" - "os" - "os/exec" - "path/filepath" - - "github.com/minicodemonkey/chief/embed" - "github.com/minicodemonkey/chief/internal/loop" - "github.com/minicodemonkey/chief/internal/prd" -) - -// waitFunc is the signature for prd.WaitWithPanel / prd.WaitWithSpinner. -type waitFunc func(cmd *exec.Cmd, title, message string, stderr *bytes.Buffer) error - -// runAgentCommand runs a provider command, captures output (stdout or file), and -// displays a progress indicator via the supplied wait function. -func runAgentCommand( - providerName string, - cmd *exec.Cmd, - mode loop.OutputMode, - outPath string, - wait waitFunc, - title, activity string, -) (string, error) { - var stdout, stderr bytes.Buffer - if mode == loop.OutputStdout { - cmd.Stdout = &stdout - } else { - cmd.Stdout = &bytes.Buffer{} - } - cmd.Stderr = &stderr - - if err := cmd.Start(); err != nil { - if outPath != "" { - _ = os.Remove(outPath) - } - return "", fmt.Errorf("failed to start %s: %w", providerName, err) - } - if err := wait(cmd, title, activity, &stderr); err != nil { - if outPath != "" { - _ = os.Remove(outPath) - } - return "", err - } - if mode == loop.OutputFromFile && outPath != "" { - defer os.Remove(outPath) - data, err := os.ReadFile(outPath) - if err != nil { - return "", fmt.Errorf("failed to read output from %s: %w", outPath, err) - } - return string(data), nil - } - return stdout.String(), nil -} - -// runConversionWithProvider runs the agent to convert prd.md to JSON. -func runConversionWithProvider(provider loop.Provider, absPRDDir string) (string, error) { - prompt := embed.GetConvertPrompt(filepath.Join(absPRDDir, "prd.md"), "US") - cmd, mode, outPath, err := provider.ConvertCommand(absPRDDir, prompt) - if err != nil { - return "", fmt.Errorf("failed to prepare conversion command: %w", err) - } - return runAgentCommand(provider.Name(), cmd, mode, outPath, prd.WaitWithPanel, "Converting PRD", "Analyzing PRD...") -} - -// runFixJSONWithProvider runs the agent to fix invalid JSON. -func runFixJSONWithProvider(provider loop.Provider, prompt string) (string, error) { - cmd, mode, outPath, err := provider.FixJSONCommand(prompt) - if err != nil { - return "", fmt.Errorf("failed to prepare fix command: %w", err) - } - return runAgentCommand(provider.Name(), cmd, mode, outPath, prd.WaitWithSpinner, "Fixing JSON", "Fixing prd.json...") -} diff --git a/internal/cmd/edit.go b/internal/cmd/edit.go index 83a81342..878fa743 100644 --- a/internal/cmd/edit.go +++ b/internal/cmd/edit.go @@ -7,14 +7,13 @@ import ( "github.com/minicodemonkey/chief/embed" "github.com/minicodemonkey/chief/internal/loop" + "github.com/minicodemonkey/chief/internal/prd" ) // EditOptions contains configuration for the edit command. type EditOptions struct { Name string // PRD name (default: "main") BaseDir string // Base directory for .chief/prds/ (default: current directory) - Merge bool // Auto-merge without prompting on conversion conflicts - Force bool // Auto-overwrite without prompting on conversion conflicts Provider loop.Provider // Agent CLI provider (Claude or Codex) } @@ -63,15 +62,9 @@ func RunEdit(opts EditOptions) error { fmt.Println("\nPRD editing complete!") - // Run conversion from prd.md to prd.json with progress protection - convertOpts := ConvertOptions{ - PRDDir: prdDir, - Merge: opts.Merge, - Force: opts.Force, - Provider: opts.Provider, - } - if err := RunConvertWithOptions(convertOpts); err != nil { - return fmt.Errorf("conversion failed: %w", err) + // Validate the edited prd.md can be parsed + if _, err := prd.ParseMarkdownPRD(prdMdPath); err != nil { + fmt.Printf("Warning: prd.md could not be parsed: %v\n", err) } fmt.Printf("\nYour PRD is updated! Run 'chief' or 'chief %s' to continue working on it.\n", opts.Name) diff --git a/internal/cmd/edit_test.go b/internal/cmd/edit_test.go index c07f6943..35ba0589 100644 --- a/internal/cmd/edit_test.go +++ b/internal/cmd/edit_test.go @@ -73,48 +73,12 @@ func TestRunEditDefaultsToMain(t *testing.T) { } } -func TestRunEditWithMergeFlag(t *testing.T) { - opts := EditOptions{ - Name: "test", - Merge: true, - Force: false, - } - - if !opts.Merge { - t.Error("Merge flag should be true") - } - if opts.Force { - t.Error("Force flag should be false") - } -} - -func TestRunEditWithForceFlag(t *testing.T) { - opts := EditOptions{ - Name: "test", - Merge: false, - Force: true, - } - - if opts.Merge { - t.Error("Merge flag should be false") - } - if !opts.Force { - t.Error("Force flag should be true") - } -} - func TestEditOptionsDefaults(t *testing.T) { opts := EditOptions{} if opts.Name != "" { t.Error("Name should default to empty (filled later)") } - if opts.Merge { - t.Error("Merge should default to false") - } - if opts.Force { - t.Error("Force should default to false") - } if opts.BaseDir != "" { t.Error("BaseDir should default to empty (filled later)") } diff --git a/internal/cmd/new.go b/internal/cmd/new.go index bd01446e..04922b7d 100644 --- a/internal/cmd/new.go +++ b/internal/cmd/new.go @@ -75,11 +75,12 @@ func RunNew(opts NewOptions) error { return nil } - fmt.Println("\nPRD created successfully!") - - // Run conversion from prd.md to prd.json - if err := RunConvertWithOptions(ConvertOptions{PRDDir: prdDir, Provider: opts.Provider}); err != nil { - return fmt.Errorf("conversion failed: %w", err) + // Validate the created prd.md can be parsed + if _, err := prd.ParseMarkdownPRD(prdMdPath); err != nil { + fmt.Printf("\nWarning: prd.md was created but could not be parsed: %v\n", err) + fmt.Println("You may need to edit it to match the expected format.") + } else { + fmt.Println("\nPRD created successfully!") } fmt.Printf("\nYour PRD is ready! Run 'chief' or 'chief %s' to start working on it.\n", opts.Name) @@ -98,46 +99,6 @@ func runInteractiveAgent(provider loop.Provider, workDir, prompt string) error { return cmd.Run() } -// ConvertOptions contains configuration for the conversion command. -type ConvertOptions struct { - PRDDir string // PRD directory containing prd.md - Merge bool // Auto-merge without prompting on conversion conflicts - Force bool // Auto-overwrite without prompting on conversion conflicts - Provider loop.Provider // Agent CLI provider for conversion -} - -// RunConvert converts prd.md to prd.json using the given agent provider. -func RunConvert(prdDir string, provider loop.Provider) error { - return RunConvertWithOptions(ConvertOptions{PRDDir: prdDir, Provider: provider}) -} - -// RunConvertWithOptions converts prd.md to prd.json using the configured agent with options. -func RunConvertWithOptions(opts ConvertOptions) error { - if opts.Provider == nil { - return fmt.Errorf("conversion requires Provider to be set") - } - provider := opts.Provider - return prd.Convert(prd.ConvertOptions{ - PRDDir: opts.PRDDir, - Merge: opts.Merge, - Force: opts.Force, - RunConversion: func(absPRDDir, idPrefix string) (string, error) { - raw, err := runConversionWithProvider(provider, absPRDDir) - if err != nil { - return "", err - } - return provider.CleanOutput(raw), nil - }, - RunFixJSON: func(prompt string) (string, error) { - raw, err := runFixJSONWithProvider(provider, prompt) - if err != nil { - return "", err - } - return provider.CleanOutput(raw), nil - }, - }) -} - // isValidPRDName checks if the name contains only valid characters. func isValidPRDName(name string) bool { if name == "" { diff --git a/internal/cmd/status.go b/internal/cmd/status.go index 8fbf6cb5..02e26aaa 100644 --- a/internal/cmd/status.go +++ b/internal/cmd/status.go @@ -30,7 +30,7 @@ func RunStatus(opts StatusOptions) error { } // Build PRD path - prdPath := filepath.Join(opts.BaseDir, ".chief", "prds", opts.Name, "prd.json") + prdPath := filepath.Join(opts.BaseDir, ".chief", "prds", opts.Name, "prd.md") // Load PRD p, err := prd.LoadPRD(prdPath) @@ -123,7 +123,7 @@ func RunList(opts ListOptions) error { } name := entry.Name() - prdPath := filepath.Join(prdsDir, name, "prd.json") + prdPath := filepath.Join(prdsDir, name, "prd.md") // Try to load the PRD p, err := prd.LoadPRD(prdPath) diff --git a/internal/cmd/status_test.go b/internal/cmd/status_test.go index 97144de2..f4dee9e6 100644 --- a/internal/cmd/status_test.go +++ b/internal/cmd/status_test.go @@ -9,25 +9,29 @@ import ( func TestRunStatusWithValidPRD(t *testing.T) { tmpDir := t.TempDir() - // Create a PRD directory with prd.json prdDir := filepath.Join(tmpDir, ".chief", "prds", "test") if err := os.MkdirAll(prdDir, 0755); err != nil { t.Fatalf("Failed to create directory: %v", err) } - // Create a test prd.json - prdJSON := `{ - "project": "Test Project", - "description": "Test description", - "userStories": [ - {"id": "US-001", "title": "Story 1", "passes": true, "priority": 1}, - {"id": "US-002", "title": "Story 2", "passes": false, "priority": 2}, - {"id": "US-003", "title": "Story 3", "passes": false, "inProgress": true, "priority": 3} - ] -}` - prdPath := filepath.Join(prdDir, "prd.json") - if err := os.WriteFile(prdPath, []byte(prdJSON), 0644); err != nil { - t.Fatalf("Failed to create prd.json: %v", err) + prdMd := `# Test Project + +Test description + +### US-001: Story 1 +**Status:** done +- [x] Done + +### US-002: Story 2 +- [ ] Pending + +### US-003: Story 3 +**Status:** in-progress +- [ ] Working +` + prdPath := filepath.Join(prdDir, "prd.md") + if err := os.WriteFile(prdPath, []byte(prdMd), 0644); err != nil { + t.Fatalf("Failed to create prd.md: %v", err) } opts := StatusOptions{ @@ -35,7 +39,6 @@ func TestRunStatusWithValidPRD(t *testing.T) { BaseDir: tmpDir, } - // Should not return error err := RunStatus(opts) if err != nil { t.Errorf("RunStatus() returned error: %v", err) @@ -45,23 +48,19 @@ func TestRunStatusWithValidPRD(t *testing.T) { func TestRunStatusWithDefaultName(t *testing.T) { tmpDir := t.TempDir() - // Create a PRD directory with prd.json using default name "main" prdDir := filepath.Join(tmpDir, ".chief", "prds", "main") if err := os.MkdirAll(prdDir, 0755); err != nil { t.Fatalf("Failed to create directory: %v", err) } - prdJSON := `{ - "project": "Main Project", - "userStories": [] -}` - prdPath := filepath.Join(prdDir, "prd.json") - if err := os.WriteFile(prdPath, []byte(prdJSON), 0644); err != nil { - t.Fatalf("Failed to create prd.json: %v", err) + prdMd := "# Main Project\n" + prdPath := filepath.Join(prdDir, "prd.md") + if err := os.WriteFile(prdPath, []byte(prdMd), 0644); err != nil { + t.Fatalf("Failed to create prd.md: %v", err) } opts := StatusOptions{ - Name: "", // Empty should default to "main" + Name: "", BaseDir: tmpDir, } @@ -92,7 +91,6 @@ func TestRunListWithNoPRDs(t *testing.T) { BaseDir: tmpDir, } - // Should not return error, just print "No PRDs found" err := RunList(opts) if err != nil { t.Errorf("RunList() returned error: %v", err) @@ -102,24 +100,17 @@ func TestRunListWithNoPRDs(t *testing.T) { func TestRunListWithPRDs(t *testing.T) { tmpDir := t.TempDir() - // Create multiple PRD directories prds := []struct { - name string - project string - stories string + name string + md string }{ { "auth", - "Authentication", - `[{"id": "US-001", "title": "Login", "passes": true, "priority": 1}, - {"id": "US-002", "title": "Logout", "passes": false, "priority": 2}]`, + "# Authentication\n\n### US-001: Login\n**Status:** done\n- [x] Works\n\n### US-002: Logout\n- [ ] Works\n", }, { "api", - "API Service", - `[{"id": "US-001", "title": "Endpoints", "passes": true, "priority": 1}, - {"id": "US-002", "title": "Auth", "passes": true, "priority": 2}, - {"id": "US-003", "title": "Rate limiting", "passes": true, "priority": 3}]`, + "# API Service\n\n### US-001: Endpoints\n**Status:** done\n- [x] Done\n\n### US-002: Auth\n**Status:** done\n- [x] Done\n\n### US-003: Rate limiting\n**Status:** done\n- [x] Done\n", }, } @@ -128,11 +119,9 @@ func TestRunListWithPRDs(t *testing.T) { if err := os.MkdirAll(prdDir, 0755); err != nil { t.Fatalf("Failed to create directory: %v", err) } - - prdJSON := `{"project": "` + p.project + `", "userStories": ` + p.stories + `}` - prdPath := filepath.Join(prdDir, "prd.json") - if err := os.WriteFile(prdPath, []byte(prdJSON), 0644); err != nil { - t.Fatalf("Failed to create prd.json: %v", err) + prdPath := filepath.Join(prdDir, "prd.md") + if err := os.WriteFile(prdPath, []byte(p.md), 0644); err != nil { + t.Fatalf("Failed to create prd.md: %v", err) } } @@ -154,31 +143,20 @@ func TestRunListSkipsInvalidPRDs(t *testing.T) { if err := os.MkdirAll(validDir, 0755); err != nil { t.Fatalf("Failed to create directory: %v", err) } - validJSON := `{"project": "Valid", "userStories": []}` - if err := os.WriteFile(filepath.Join(validDir, "prd.json"), []byte(validJSON), 0644); err != nil { - t.Fatalf("Failed to create prd.json: %v", err) + if err := os.WriteFile(filepath.Join(validDir, "prd.md"), []byte("# Valid\n"), 0644); err != nil { + t.Fatalf("Failed to create prd.md: %v", err) } - // Create an invalid PRD directory (no prd.json) + // Create an invalid PRD directory (no prd.md) invalidDir := filepath.Join(tmpDir, ".chief", "prds", "invalid") if err := os.MkdirAll(invalidDir, 0755); err != nil { t.Fatalf("Failed to create directory: %v", err) } - // Create another invalid PRD (invalid JSON) - badJsonDir := filepath.Join(tmpDir, ".chief", "prds", "badjson") - if err := os.MkdirAll(badJsonDir, 0755); err != nil { - t.Fatalf("Failed to create directory: %v", err) - } - if err := os.WriteFile(filepath.Join(badJsonDir, "prd.json"), []byte("not json"), 0644); err != nil { - t.Fatalf("Failed to create prd.json: %v", err) - } - opts := ListOptions{ BaseDir: tmpDir, } - // Should not return error, just skip invalid PRDs err := RunList(opts) if err != nil { t.Errorf("RunList() returned error: %v", err) @@ -193,16 +171,10 @@ func TestRunStatusAllComplete(t *testing.T) { t.Fatalf("Failed to create directory: %v", err) } - prdJSON := `{ - "project": "Complete Project", - "userStories": [ - {"id": "US-001", "title": "Story 1", "passes": true, "priority": 1}, - {"id": "US-002", "title": "Story 2", "passes": true, "priority": 2} - ] -}` - prdPath := filepath.Join(prdDir, "prd.json") - if err := os.WriteFile(prdPath, []byte(prdJSON), 0644); err != nil { - t.Fatalf("Failed to create prd.json: %v", err) + prdMd := "# Complete Project\n\n### US-001: Story 1\n**Status:** done\n- [x] Done\n\n### US-002: Story 2\n**Status:** done\n- [x] Done\n" + prdPath := filepath.Join(prdDir, "prd.md") + if err := os.WriteFile(prdPath, []byte(prdMd), 0644); err != nil { + t.Fatalf("Failed to create prd.md: %v", err) } opts := StatusOptions{ @@ -224,10 +196,10 @@ func TestRunStatusEmptyPRD(t *testing.T) { t.Fatalf("Failed to create directory: %v", err) } - prdJSON := `{"project": "Empty Project", "userStories": []}` - prdPath := filepath.Join(prdDir, "prd.json") - if err := os.WriteFile(prdPath, []byte(prdJSON), 0644); err != nil { - t.Fatalf("Failed to create prd.json: %v", err) + prdMd := "# Empty Project\n" + prdPath := filepath.Join(prdDir, "prd.md") + if err := os.WriteFile(prdPath, []byte(prdMd), 0644); err != nil { + t.Fatalf("Failed to create prd.md: %v", err) } opts := StatusOptions{ diff --git a/internal/loop/codex_parser.go b/internal/loop/codex_parser.go index 3dd5f398..d9346d02 100644 --- a/internal/loop/codex_parser.go +++ b/internal/loop/codex_parser.go @@ -102,15 +102,8 @@ func ParseLineCodex(line string) *Event { } case "agent_message": text := ev.Item.Text - if strings.Contains(text, "") { - return &Event{Type: EventComplete, Text: text} - } - if storyID := extractStoryID(text, "", ""); storyID != "" { - return &Event{ - Type: EventStoryStarted, - Text: text, - StoryID: storyID, - } + if strings.Contains(text, "") { + return &Event{Type: EventStoryDone, Text: text} } return &Event{Type: EventAssistantText, Text: text} case "file_change": diff --git a/internal/loop/codex_parser_test.go b/internal/loop/codex_parser_test.go index 81df2a0d..3afd4ca7 100644 --- a/internal/loop/codex_parser_test.go +++ b/internal/loop/codex_parser_test.go @@ -54,31 +54,25 @@ func TestParseLineCodex_commandExecutionCompleted(t *testing.T) { } } -func TestParseLineCodex_agentMessageWithComplete(t *testing.T) { - line := `{"type":"item.completed","item":{"id":"item_3","type":"agent_message","text":"Done. "}}` +func TestParseLineCodex_agentMessageWithChiefDoneTag(t *testing.T) { + line := `{"type":"item.completed","item":{"id":"item_3","type":"agent_message","text":"Done. "}}` ev := ParseLineCodex(line) if ev == nil { t.Fatal("expected event, got nil") } - if ev.Type != EventComplete { - t.Errorf("expected EventComplete, got %v", ev.Type) - } - if ev.Text != "Done. " { - t.Errorf("unexpected Text: %q", ev.Text) + if ev.Type != EventStoryDone { + t.Errorf("expected EventStoryDone, got %v", ev.Type) } } -func TestParseLineCodex_agentMessageWithRalphStatus(t *testing.T) { - line := `{"type":"item.completed","item":{"id":"item_3","type":"agent_message","text":"Working on US-056 now."}}` +func TestParseLineCodex_agentMessageWithChiefDone(t *testing.T) { + line := `{"type":"item.completed","item":{"id":"item_3","type":"agent_message","text":"All criteria pass. "}}` ev := ParseLineCodex(line) if ev == nil { t.Fatal("expected event, got nil") } - if ev.Type != EventStoryStarted { - t.Errorf("expected EventStoryStarted, got %v", ev.Type) - } - if ev.StoryID != "US-056" { - t.Errorf("expected StoryID US-056, got %q", ev.StoryID) + if ev.Type != EventStoryDone { + t.Errorf("expected EventStoryDone, got %v", ev.Type) } } diff --git a/internal/loop/loop.go b/internal/loop/loop.go index 0d3dd74d..b153ac9b 100644 --- a/internal/loop/loop.go +++ b/internal/loop/loop.go @@ -44,7 +44,7 @@ type Loop struct { prdPath string workDir string prompt string - buildPrompt func() (string, error) // optional: rebuild prompt each iteration + buildPrompt func() (string, string, error) // optional: rebuild prompt each iteration; returns (prompt, storyID, error) maxIter int iteration int events chan Event @@ -57,6 +57,8 @@ type Loop struct { retryConfig RetryConfig lastOutputTime time.Time watchdogTimeout time.Duration + sawStoryDone bool + currentStoryID string } // NewLoop creates a new Loop instance. @@ -97,22 +99,26 @@ func NewLoopWithEmbeddedPrompt(prdPath string, maxIter int, provider Provider) * // promptBuilderForPRD returns a function that loads the PRD and builds a prompt // with the next story inlined. This is called before each iteration so that -// newly completed stories are skipped. -func promptBuilderForPRD(prdPath string) func() (string, error) { - return func() (string, error) { +// newly completed stories are skipped. The returned storyID is stored on the Loop. +func promptBuilderForPRD(prdPath string) func() (string, string, error) { + return func() (string, string, error) { p, err := prd.LoadPRD(prdPath) if err != nil { - return "", fmt.Errorf("failed to load PRD for prompt: %w", err) + return "", "", fmt.Errorf("failed to load PRD for prompt: %w", err) } story := p.NextStory() if story == nil { - return "", fmt.Errorf("all stories are complete") + return "", "", fmt.Errorf("all stories are complete") } + // Mark the story as in-progress in the markdown file + _ = prd.SetStoryStatus(prdPath, story.ID, "in-progress") + storyCtx := p.NextStoryContext() - return embed.GetPrompt(prdPath, prd.ProgressPath(prdPath), *storyCtx, story.ID, story.Title), nil + prompt := embed.GetPrompt(prd.ProgressPath(prdPath), *storyCtx, story.ID, story.Title) + return prompt, story.ID, nil } } @@ -170,7 +176,7 @@ func (l *Loop) Run(ctx context.Context) error { // Rebuild prompt if builder is set (inlines the current story each iteration) if l.buildPrompt != nil { - prompt, err := l.buildPrompt() + prompt, storyID, err := l.buildPrompt() if err != nil { l.events <- Event{ Type: EventComplete, @@ -180,6 +186,8 @@ func (l *Loop) Run(ctx context.Context) error { } l.mu.Lock() l.prompt = prompt + l.currentStoryID = storyID + l.sawStoryDone = false l.mu.Unlock() } @@ -205,23 +213,17 @@ func (l *Loop) Run(ctx context.Context) error { default: } - // Check prd.json for completion - p, err := prd.LoadPRD(l.prdPath) - if err != nil { - l.events <- Event{ - Type: EventError, - Err: fmt.Errorf("failed to load PRD: %w", err), - } - return err - } - - if p.AllComplete() { - l.events <- Event{ - Type: EventComplete, - Iteration: currentIter, - } - return nil + // If the agent emitted , mark the story as done in prd.md + l.mu.Lock() + saw := l.sawStoryDone + storyID := l.currentStoryID + l.sawStoryDone = false + l.mu.Unlock() + if saw && storyID != "" { + _ = prd.SetStoryStatus(l.prdPath, storyID, "done") } + // buildPrompt on the next iteration will return error if all stories are complete, + // which causes EventComplete to be emitted above. // Check pause flag after iteration (loop stops after current iteration completes) l.mu.Lock() @@ -466,6 +468,9 @@ func (l *Loop) processOutput(r io.Reader) { if event := l.provider.ParseLine(line); event != nil { l.mu.Lock() event.Iteration = l.iteration + if event.Type == EventStoryDone { + l.sawStoryDone = true + } l.mu.Unlock() l.events <- *event } diff --git a/internal/loop/loop_test.go b/internal/loop/loop_test.go index 782d50df..fe72c0ca 100644 --- a/internal/loop/loop_test.go +++ b/internal/loop/loop_test.go @@ -2,7 +2,6 @@ package loop import ( "context" - "encoding/json" "fmt" "os" "os/exec" @@ -26,14 +25,6 @@ func (m *mockProvider) InteractiveCommand(_, _ string) *exec.Cmd { return exec.C func (m *mockProvider) ParseLine(line string) *Event { return ParseLine(line) } func (m *mockProvider) LogFileName() string { return "claude.log" } -func (m *mockProvider) ConvertCommand(_, _ string) (*exec.Cmd, OutputMode, string, error) { - return exec.Command("true"), OutputStdout, "", nil -} - -func (m *mockProvider) FixJSONCommand(_ string) (*exec.Cmd, OutputMode, string, error) { - return exec.Command("true"), OutputStdout, "", nil -} - func (m *mockProvider) path() string { if m.cliPath != "" { return m.cliPath @@ -70,27 +61,21 @@ func createMockClaudeScript(t *testing.T, dir string, output []string) string { return scriptPath } -// createTestPRD creates a minimal test PRD file. +// createTestPRD creates a minimal test PRD markdown file. func createTestPRD(t *testing.T, dir string, allComplete bool) string { t.Helper() - prdFile := &prd.PRD{ - Project: "Test Project", - Description: "Test Description", - UserStories: []prd.UserStory{ - { - ID: "US-001", - Title: "Test Story", - Description: "A test story", - Priority: 1, - Passes: allComplete, - }, - }, - } - - prdPath := filepath.Join(dir, "prd.json") - data, _ := json.MarshalIndent(prdFile, "", " ") - if err := os.WriteFile(prdPath, data, 0644); err != nil { + status := "" + checkbox := "- [ ] It works" + if allComplete { + status = "**Status:** done\n" + checkbox = "- [x] It works" + } + + md := fmt.Sprintf("# Test Project\n\nTest Description\n\n### US-001: Test Story\n%s%s\n", status, checkbox) + + prdPath := filepath.Join(dir, "prd.md") + if err := os.WriteFile(prdPath, []byte(md), 0644); err != nil { t.Fatalf("Failed to create test PRD: %v", err) } @@ -329,7 +314,7 @@ func TestLoop_LogFile(t *testing.T) { t.Fatalf("Failed to create log file: %v", err) } - l := NewLoop(filepath.Join(tmpDir, "prd.json"), "test", 1, testProvider) + l := NewLoop(filepath.Join(tmpDir, "prd.md"), "test", 1, testProvider) l.logFile = logFile l.logLine("test log line") @@ -346,8 +331,8 @@ func TestLoop_LogFile(t *testing.T) { } } -// TestLoop_ChiefCompleteEvent tests detection of event. -func TestLoop_ChiefCompleteEvent(t *testing.T) { +// TestLoop_ChiefDoneEvent tests detection of event. +func TestLoop_ChiefDoneEvent(t *testing.T) { l := NewLoop("/test/prd.json", "test", 5, testProvider) l.iteration = 1 @@ -356,17 +341,17 @@ func TestLoop_ChiefCompleteEvent(t *testing.T) { go func() { for event := range l.Events() { events = append(events, event) - if event.Type == EventComplete { + if event.Type == EventStoryDone { break } } done <- true }() - // Simulate processing a line with chief-complete + // Simulate processing a line with chief-done r, w, _ := os.Pipe() go func() { - w.WriteString(`{"type":"assistant","message":{"content":[{"type":"text","text":"All done! "}]}}` + "\n") + w.WriteString(`{"type":"assistant","message":{"content":[{"type":"text","text":"All criteria pass! "}]}}` + "\n") w.Close() }() @@ -374,17 +359,23 @@ func TestLoop_ChiefCompleteEvent(t *testing.T) { close(l.events) <-done - // Check that we got a Complete event - hasComplete := false + // Check that we got a StoryDone event and sawStoryDone was set + hasStoryDone := false for _, e := range events { - if e.Type == EventComplete { - hasComplete = true + if e.Type == EventStoryDone { + hasStoryDone = true } } - if !hasComplete { - t.Error("Expected Complete event for ") + if !hasStoryDone { + t.Error("Expected StoryDone event for ") } + + l.mu.Lock() + if !l.sawStoryDone { + t.Error("Expected sawStoryDone to be true after processing ") + } + l.mu.Unlock() } // TestLoop_SetMaxIterations tests setting max iterations at runtime. diff --git a/internal/loop/opencode_parser.go b/internal/loop/opencode_parser.go index 611ea1e6..a9e81ae8 100644 --- a/internal/loop/opencode_parser.go +++ b/internal/loop/opencode_parser.go @@ -98,6 +98,12 @@ func ParseLineOpenCode(line string) *Event { if ev.Part == nil { return nil } + if strings.Contains(ev.Part.Text, "") { + return &Event{ + Type: EventStoryDone, + Text: ev.Part.Text, + } + } return &Event{ Type: EventAssistantText, Text: ev.Part.Text, diff --git a/internal/loop/parser.go b/internal/loop/parser.go index 59bd97b0..5a38a07f 100644 --- a/internal/loop/parser.go +++ b/internal/loop/parser.go @@ -19,11 +19,9 @@ const ( EventToolStart // EventToolResult is emitted when a tool returns a result. EventToolResult - // EventStoryStarted is emitted when Claude indicates a story is being worked on. - EventStoryStarted - // EventStoryCompleted is emitted when Claude completes a story. - EventStoryCompleted - // EventComplete is emitted when is detected. + // EventStoryDone is emitted when Claude signals a story is done via . + EventStoryDone + // EventComplete is emitted when all stories are complete (buildPrompt returns error). EventComplete // EventMaxIterationsReached is emitted when max iterations are reached. EventMaxIterationsReached @@ -46,10 +44,8 @@ func (e EventType) String() string { return "ToolStart" case EventToolResult: return "ToolResult" - case EventStoryStarted: - return "StoryStarted" - case EventStoryCompleted: - return "StoryCompleted" + case EventStoryDone: + return "StoryDone" case EventComplete: return "Complete" case EventMaxIterationsReached: @@ -138,8 +134,6 @@ func ParseLine(line string) *Event { return parseUserMessage(msg.Message) case "result": - // Result messages indicate the end of an iteration - // We don't emit a specific event for this, but could in the future return nil default: @@ -158,28 +152,17 @@ func parseAssistantMessage(raw json.RawMessage) *Event { return nil } - // Process content blocks - return the first meaningful event - // In practice, we might want to return multiple events, but for simplicity - // we return the first one found for _, block := range msg.Content { switch block.Type { case "text": text := block.Text - // Check for tag - if strings.Contains(text, "") { + // Check for tag + if strings.Contains(text, "") { return &Event{ - Type: EventComplete, + Type: EventStoryDone, Text: text, } } - // Check for story markers using ralph-status tags - if storyID := extractStoryID(text, "", ""); storyID != "" { - return &Event{ - Type: EventStoryStarted, - Text: text, - StoryID: storyID, - } - } return &Event{ Type: EventAssistantText, Text: text, @@ -219,19 +202,3 @@ func parseUserMessage(raw json.RawMessage) *Event { return nil } - -// extractStoryID extracts a story ID from text between start and end tags. -func extractStoryID(text, startTag, endTag string) string { - startIdx := strings.Index(text, startTag) - if startIdx == -1 { - return "" - } - startIdx += len(startTag) - - endIdx := strings.Index(text[startIdx:], endTag) - if endIdx == -1 { - return "" - } - - return strings.TrimSpace(text[startIdx : startIdx+endIdx]) -} diff --git a/internal/loop/parser_test.go b/internal/loop/parser_test.go index f4d28664..34a98068 100644 --- a/internal/loop/parser_test.go +++ b/internal/loop/parser_test.go @@ -14,8 +14,7 @@ func TestEventTypeString(t *testing.T) { {EventAssistantText, "AssistantText"}, {EventToolStart, "ToolStart"}, {EventToolResult, "ToolResult"}, - {EventStoryStarted, "StoryStarted"}, - {EventStoryCompleted, "StoryCompleted"}, + {EventStoryDone, "StoryDone"}, {EventComplete, "Complete"}, {EventMaxIterationsReached, "MaxIterationsReached"}, {EventError, "Error"}, @@ -97,11 +96,11 @@ func TestParseLineAssistantText(t *testing.T) { } } -func TestParseLineChiefComplete(t *testing.T) { +func TestParseLineChiefDone(t *testing.T) { tests := []string{ - `{"type":"assistant","message":{"content":[{"type":"text","text":"All stories complete! "}]}}`, - `{"type":"assistant","message":{"content":[{"type":"text","text":""}]}}`, - `{"type":"assistant","message":{"content":[{"type":"text","text":"Done\n\nGoodbye"}]}}`, + `{"type":"assistant","message":{"content":[{"type":"text","text":"All criteria pass! "}]}}`, + `{"type":"assistant","message":{"content":[{"type":"text","text":""}]}}`, + `{"type":"assistant","message":{"content":[{"type":"text","text":"Done\n\nGoodbye"}]}}`, } for _, line := range tests { @@ -109,8 +108,8 @@ func TestParseLineChiefComplete(t *testing.T) { if event == nil { t.Fatalf("ParseLine(%q) returned nil, want event", line) } - if event.Type != EventComplete { - t.Errorf("ParseLine(%q): event.Type = %v, want EventComplete", line, event.Type) + if event.Type != EventStoryDone { + t.Errorf("ParseLine(%q): event.Type = %v, want EventStoryDone", line, event.Type) } } } @@ -151,21 +150,6 @@ func TestParseLineToolResult(t *testing.T) { } } -func TestParseLineStoryStarted(t *testing.T) { - line := `{"type":"assistant","message":{"content":[{"type":"text","text":"Working on the next story.\nUS-003\nLet me implement this."}]}}` - - event := ParseLine(line) - if event == nil { - t.Fatal("ParseLine returned nil, want event") - } - if event.Type != EventStoryStarted { - t.Errorf("event.Type = %v, want EventStoryStarted", event.Type) - } - if event.StoryID != "US-003" { - t.Errorf("event.StoryID = %q, want %q", event.StoryID, "US-003") - } -} - func TestParseLineResultMessage(t *testing.T) { line := `{"type":"result","subtype":"success","is_error":false,"result":"Done"}` @@ -195,7 +179,6 @@ func TestParseLineEmptyContent(t *testing.T) { } func TestParseLineRealWorldSystemInit(t *testing.T) { - // Real example from Claude output line := `{"type":"system","subtype":"init","cwd":"/Users/codemonkey/projects/chief","session_id":"7cdf33f6-72ec-4c0e-94fb-e2b637e109da","tools":["Task","Bash","Read"],"model":"claude-opus-4-5-20251101","permissionMode":"default"}` event := ParseLine(line) @@ -208,7 +191,6 @@ func TestParseLineRealWorldSystemInit(t *testing.T) { } func TestParseLineRealWorldToolUse(t *testing.T) { - // Real example from Claude output line := `{"type":"assistant","message":{"model":"claude-opus-4-5-20251101","id":"msg_01XPBzHqPaCuQMUFm77eHe4D","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01MiR5Ps9inHigemS2gxEz5R","name":"Read","input":{"file_path":"/Users/codemonkey/projects/chief/go.mod"}}]}}` event := ParseLine(line) @@ -223,65 +205,13 @@ func TestParseLineRealWorldToolUse(t *testing.T) { } } -func TestExtractStoryID(t *testing.T) { - tests := []struct { - text string - startTag string - endTag string - expected string - }{ - { - text: "US-001", - startTag: "", - endTag: "", - expected: "US-001", - }, - { - text: "Some text US-002 more text", - startTag: "", - endTag: "", - expected: "US-002", - }, - { - text: " US-003 ", - startTag: "", - endTag: "", - expected: "US-003", - }, - { - text: "no tags here", - startTag: "", - endTag: "", - expected: "", - }, - { - text: "unclosed", - startTag: "", - endTag: "", - expected: "", - }, - } - - for _, tt := range tests { - t.Run(tt.text, func(t *testing.T) { - got := extractStoryID(tt.text, tt.startTag, tt.endTag) - if got != tt.expected { - t.Errorf("extractStoryID(%q, %q, %q) = %q, want %q", tt.text, tt.startTag, tt.endTag, got, tt.expected) - } - }) - } -} - func TestParseLineMultipleContentBlocks(t *testing.T) { - // When there are multiple content blocks, we return the first meaningful one - // This tests that text comes before tool_use in the content array line := `{"type":"assistant","message":{"content":[{"type":"text","text":"First"},{"type":"tool_use","name":"Read","input":{}}]}}` event := ParseLine(line) if event == nil { t.Fatal("ParseLine returned nil, want event") } - // Should return the text event since it comes first if event.Type != EventAssistantText { t.Errorf("event.Type = %v, want EventAssistantText", event.Type) } @@ -291,14 +221,12 @@ func TestParseLineMultipleContentBlocks(t *testing.T) { } func TestParseLineToolUseFirst(t *testing.T) { - // When tool_use comes first line := `{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Write","input":{"file_path":"/test"}},{"type":"text","text":"Second"}]}}` event := ParseLine(line) if event == nil { t.Fatal("ParseLine returned nil, want event") } - // Should return the tool_use event since it comes first if event.Type != EventToolStart { t.Errorf("event.Type = %v, want EventToolStart", event.Type) } diff --git a/internal/loop/provider.go b/internal/loop/provider.go index 2fc0d75f..40fb4ca7 100644 --- a/internal/loop/provider.go +++ b/internal/loop/provider.go @@ -5,16 +5,6 @@ import ( "os/exec" ) -// OutputMode indicates how to capture the result of a one-shot agent command. -type OutputMode int - -const ( - // OutputStdout means the result is read from stdout. - OutputStdout OutputMode = iota - // OutputFromFile means the result is written to a file; use the path returned by ConvertCommand/FixJSONCommand. - OutputFromFile -) - // Provider is the interface for an agent CLI (e.g. Claude, Codex). // Implementations live in internal/agent to avoid import cycles. type Provider interface { @@ -22,8 +12,6 @@ type Provider interface { CLIPath() string LoopCommand(ctx context.Context, prompt, workDir string) *exec.Cmd InteractiveCommand(workDir, prompt string) *exec.Cmd - ConvertCommand(workDir, prompt string) (cmd *exec.Cmd, mode OutputMode, outPath string, err error) - FixJSONCommand(prompt string) (cmd *exec.Cmd, mode OutputMode, outPath string, err error) // CleanOutput extracts JSON from the provider's output format (e.g., NDJSON). // Returns the original output if no cleaning needed. CleanOutput(output string) string diff --git a/internal/prd/generator.go b/internal/prd/generator.go deleted file mode 100644 index ec77a6ca..00000000 --- a/internal/prd/generator.go +++ /dev/null @@ -1,749 +0,0 @@ -package prd - -import ( - "bufio" - "bytes" - "encoding/json" - "fmt" - "math/rand" - "os" - "os/exec" - "path/filepath" - "strings" - "time" - - "github.com/charmbracelet/lipgloss" - "github.com/charmbracelet/x/term" -) - -// Colors duplicated from tui/styles.go to avoid import cycle (tui → git → prd). -var ( - cPrimary = lipgloss.Color("#00D7FF") - cSuccess = lipgloss.Color("#5AF78E") - cMuted = lipgloss.Color("#6C7086") - cBorder = lipgloss.Color("#45475A") - cText = lipgloss.Color("#CDD6F4") -) - -// waitingJokes are shown on a rotating basis during long-running operations. -var waitingJokes = []string{ - "Why do programmers prefer dark mode? Because light attracts bugs.", - "There are only 10 types of people: those who understand binary and those who don't.", - "A SQL query walks into a bar, sees two tables and asks... 'Can I JOIN you?'", - "!false — it's funny because it's true.", - "A programmer's wife says: 'Go to the store and get a gallon of milk. If they have eggs, get a dozen.' He returns with 12 gallons of milk.", - "Why do Java developers wear glasses? Because they can't C#.", - "There's no place like 127.0.0.1.", - "Algorithm: a word used by programmers when they don't want to explain what they did.", - "It works on my machine. Ship it!", - "99 little bugs in the code, 99 little bugs. Take one down, patch it around... 127 little bugs in the code.", - "The best thing about a boolean is that even if you're wrong, you're only off by a bit.", - "Debugging is like being the detective in a crime movie where you are also the murderer.", - "How many programmers does it take to change a light bulb? None, that's a hardware problem.", - "I asked the AI to write a PRD. It wrote a PRD about writing PRDs.", - "You're absolutely right. That's a great point. I completely agree. — Claude, before doing what it was already going to do", - "The AI said it was 95% confident. It was not.", - "Prompt engineering: the art of saying 'no really, do what I said' in 47 different ways.", - "The LLM hallucinated a library that doesn't exist. Honestly, the API looked pretty good though.", - "AI will replace programmers any day now. — programmers, every year since 2022", - "Homer Simpson: 'To start, press any key.' Where's the ANY key?!", - "Homer Simpson: 'Kids, you tried your best and you failed miserably. The lesson is, never try.'", - "The code works and nobody knows why. The code breaks and nobody knows why.", - "Frink: 'You've got to listen to me! Elementary chaos theory tells us that all robots will eventually turn against their masters!'", -} - -// ConvertOptions contains configuration for PRD conversion. -type ConvertOptions struct { - PRDDir string // Directory containing prd.md - Merge bool // Auto-merge progress on conversion conflicts - Force bool // Auto-overwrite on conversion conflicts - // RunConversion runs the agent to convert prd.md to JSON. Required. - RunConversion func(absPRDDir, idPrefix string) (string, error) - // RunFixJSON runs the agent to fix invalid JSON. Required. - RunFixJSON func(prompt string) (string, error) -} - -// ProgressConflictChoice represents the user's choice when a progress conflict is detected. -type ProgressConflictChoice int - -const ( - ChoiceMerge ProgressConflictChoice = iota // Keep status for matching story IDs - ChoiceOverwrite // Discard all progress - ChoiceCancel // Cancel conversion -) - -// Convert converts prd.md to prd.json using the configured agent one-shot. -// This function is called: -// - After chief new (new PRD creation) -// - After chief edit (PRD modification) -// - Before chief run if prd.md is newer than prd.json -// -// Progress protection: -// - If prd.json has progress (passes: true or inProgress: true) and prd.md changed: -// - opts.Merge: auto-merge, preserving status for matching story IDs -// - opts.Force: auto-overwrite, discarding all progress -// - Neither: prompt the user with Merge/Overwrite/Cancel options -func Convert(opts ConvertOptions) error { - prdMdPath := filepath.Join(opts.PRDDir, "prd.md") - prdJsonPath := filepath.Join(opts.PRDDir, "prd.json") - - // Check if prd.md exists - if _, err := os.Stat(prdMdPath); os.IsNotExist(err) { - return fmt.Errorf("prd.md not found in %s", opts.PRDDir) - } - - // Resolve absolute path so the prompt can specify exact file locations - absPRDDir, err := filepath.Abs(opts.PRDDir) - if err != nil { - return fmt.Errorf("failed to resolve absolute path: %w", err) - } - - if opts.RunConversion == nil || opts.RunFixJSON == nil { - return fmt.Errorf("conversion requires RunConversion and RunFixJSON callbacks") - } - - // Check for existing progress before conversion - var existingPRD *PRD - hasProgress := false - if existing, err := LoadPRD(prdJsonPath); err == nil { - existingPRD = existing - hasProgress = HasProgress(existing) - } - - // Extract ID prefix from existing PRD (defaults to "US" for new PRDs) - idPrefix := "US" - if existingPRD != nil { - idPrefix = existingPRD.ExtractIDPrefix() - } - - // Run agent to convert prd.md → JSON string - rawJSON, err := opts.RunConversion(absPRDDir, idPrefix) - if err != nil { - return err - } - - // Clean up output (strip markdown fences if any) - cleanedJSON := stripMarkdownFences(rawJSON) - - // Parse and validate - newPRD, err := parseAndValidatePRD(cleanedJSON) - if err != nil { - // Retry once: ask agent to fix the invalid JSON - fmt.Println("Conversion produced invalid JSON, retrying...") - fmt.Printf("Raw output:\n---\n%s\n---\n", cleanedJSON) - fixedJSON, retryErr := opts.RunFixJSON(fixPromptForRetry(cleanedJSON, err)) - if retryErr != nil { - return fmt.Errorf("conversion retry failed: %w", retryErr) - } - - cleanedJSON = stripMarkdownFences(fixedJSON) - newPRD, err = parseAndValidatePRD(cleanedJSON) - if err != nil { - return fmt.Errorf("conversion produced invalid JSON after retry:\n---\n%s\n---\n%w", cleanedJSON, err) - } - } - - // Sanity check: warn if JSON has significantly fewer stories than markdown - if mdContent, readErr := os.ReadFile(prdMdPath); readErr == nil { - mdStoryCount := CountMarkdownStories(string(mdContent)) - jsonStoryCount := len(newPRD.UserStories) - if mdStoryCount > 0 && jsonStoryCount < int(float64(mdStoryCount)*0.8) { - fmt.Printf("⚠️ Warning: possible truncation — JSON has %d stories but markdown has ~%d story headings\n", jsonStoryCount, mdStoryCount) - } - } - - // Re-save through Go's JSON encoder to guarantee proper escaping and formatting - normalizedContent, err := json.MarshalIndent(newPRD, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal PRD: %w", err) - } - - // Handle progress protection if existing prd.json has progress - if hasProgress && existingPRD != nil { - choice := ChoiceOverwrite // Default to overwrite if no progress - - if opts.Merge { - choice = ChoiceMerge - } else if opts.Force { - choice = ChoiceOverwrite - } else { - // Prompt user for choice - var promptErr error - choice, promptErr = promptProgressConflict(existingPRD, newPRD) - if promptErr != nil { - return fmt.Errorf("failed to prompt for choice: %w", promptErr) - } - } - - switch choice { - case ChoiceCancel: - return fmt.Errorf("conversion cancelled by user") - case ChoiceMerge: - // Merge progress from existing PRD into new PRD - MergeProgress(existingPRD, newPRD) - // Re-marshal with merged progress - mergedContent, err := json.MarshalIndent(newPRD, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal merged PRD: %w", err) - } - normalizedContent = mergedContent - case ChoiceOverwrite: - // Use the new PRD as-is (no progress) - } - } - - // Write the final normalized prd.json - if err := os.WriteFile(prdJsonPath, append(normalizedContent, '\n'), 0644); err != nil { - return fmt.Errorf("failed to write prd.json: %w", err) - } - - fmt.Println(lipgloss.NewStyle().Foreground(cSuccess).Render("✓ PRD converted successfully")) - return nil -} - -// fixPromptForRetry builds the prompt for the agent to fix invalid JSON. -func fixPromptForRetry(badJSON string, validationErr error) string { - return fmt.Sprintf( - "The following JSON is invalid. The error is: %s\n\n"+ - "Fix the JSON (pay special attention to escaping double quotes inside string values with backslashes) "+ - "and return ONLY the corrected JSON — no markdown fences, no explanation.\n\n%s", - validationErr.Error(), badJSON, - ) -} - -// parseAndValidatePRD unmarshals a JSON string and validates it as a PRD. -func parseAndValidatePRD(jsonStr string) (*PRD, error) { - var prd PRD - if err := json.Unmarshal([]byte(jsonStr), &prd); err != nil { - return nil, fmt.Errorf("failed to parse JSON: %w", err) - } - if prd.Project == "" { - return nil, fmt.Errorf("prd.json missing required 'project' field") - } - if len(prd.UserStories) == 0 { - return nil, fmt.Errorf("prd.json has no user stories") - } - return &prd, nil -} - -// loadAndValidateConvertedPRD loads prd.json from disk and validates it can be parsed as a PRD. -func loadAndValidateConvertedPRD(prdJsonPath string) (*PRD, error) { - data, err := os.ReadFile(prdJsonPath) - if err != nil { - return nil, err - } - return parseAndValidatePRD(string(data)) -} - -// getTerminalWidth returns the current terminal width, defaulting to 80. -func getTerminalWidth() int { - w, _, err := term.GetSize(os.Stdout.Fd()) - if err != nil || w <= 0 { - return 80 - } - return w -} - -// wrapText wraps text to the given width at word boundaries. -func wrapText(text string, width int) string { - words := strings.Fields(text) - if len(words) == 0 { - return "" - } - var lines []string - line := words[0] - for _, w := range words[1:] { - if len(line)+1+len(w) <= width { - line += " " + w - } else { - lines = append(lines, line) - line = w - } - } - lines = append(lines, line) - return strings.Join(lines, "\n") -} - -// renderProgressBar renders a progress bar based on elapsed time vs estimated duration. -// Caps at 95% to avoid showing 100% prematurely. -func renderProgressBar(elapsed time.Duration, width int) string { - const estimatedDuration = 90 * time.Second - - progress := elapsed.Seconds() / estimatedDuration.Seconds() - if progress > 0.95 { - progress = 0.95 - } - if progress < 0 { - progress = 0 - } - - pct := int(progress * 100) - pctStr := fmt.Sprintf("%d%%", pct) - - barWidth := width - len(pctStr) - 2 // 2 for gap between bar and percentage - if barWidth < 10 { - barWidth = 10 - } - - fillWidth := int(float64(barWidth) * progress) - emptyWidth := barWidth - fillWidth - - fill := lipgloss.NewStyle().Foreground(cSuccess).Render(strings.Repeat("█", fillWidth)) - empty := lipgloss.NewStyle().Foreground(cMuted).Render(strings.Repeat("░", emptyWidth)) - styledPct := lipgloss.NewStyle().Foreground(cMuted).Render(pctStr) - - return fill + empty + " " + styledPct -} - -// renderActivityLine renders a line with a cyan dot, activity text, and right-aligned elapsed time. -func renderActivityLine(activity string, elapsed time.Duration, contentWidth int) string { - icon := lipgloss.NewStyle().Foreground(cPrimary).Render("●") - elapsedFmt := formatElapsed(elapsed) - elapsedStr := lipgloss.NewStyle().Foreground(cMuted).Render(elapsedFmt) - - // Truncate activity if it would overflow - maxDescWidth := contentWidth - 2 - len(elapsedFmt) - 2 // icon+space, elapsed, gap - if len(activity) > maxDescWidth && maxDescWidth > 3 { - activity = activity[:maxDescWidth-1] + "…" - } - - descStr := lipgloss.NewStyle().Foreground(cText).Render(activity) - leftPart := icon + " " + descStr - rightPart := elapsedStr - gap := contentWidth - lipgloss.Width(leftPart) - lipgloss.Width(rightPart) - if gap < 1 { - gap = 1 - } - return leftPart + strings.Repeat(" ", gap) + rightPart -} - -// renderProgressBox builds the full lipgloss-styled progress panel with progress bar and joke. -func renderProgressBox(title, activity string, elapsed time.Duration, joke string, panelWidth int) string { - contentWidth := panelWidth - 6 // 2 border + 4 padding (2 each side) - if contentWidth < 20 { - contentWidth = 20 - } - - // Header: "chief " - chiefStr := lipgloss.NewStyle().Bold(true).Foreground(cPrimary).Render("chief") - titleStr := lipgloss.NewStyle().Foreground(cText).Render(title) - header := chiefStr + " " + titleStr - - // Divider - divider := lipgloss.NewStyle().Foreground(cBorder).Render(strings.Repeat("─", contentWidth)) - - // Activity + progress bar - activityLine := renderActivityLine(activity, elapsed, contentWidth) - progressLine := renderProgressBar(elapsed, contentWidth) - - // Joke (word-wrapped, muted) - wrappedJoke := wrapText(joke, contentWidth) - jokeStr := lipgloss.NewStyle().Foreground(cMuted).Render(wrappedJoke) - - content := strings.Join([]string{ - header, - divider, - "", - activityLine, - progressLine, - "", - divider, - jokeStr, - }, "\n") - - style := lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(cPrimary). - Padding(1, 2). - Width(panelWidth - 2) - - return style.Render(content) -} - -// renderSpinnerBox builds a simpler bordered panel for non-streaming operations. -func renderSpinnerBox(title, activity string, elapsed time.Duration, panelWidth int) string { - contentWidth := panelWidth - 6 - if contentWidth < 20 { - contentWidth = 20 - } - - chiefStr := lipgloss.NewStyle().Bold(true).Foreground(cPrimary).Render("chief") - titleStr := lipgloss.NewStyle().Foreground(cText).Render(title) - header := chiefStr + " " + titleStr - - divider := lipgloss.NewStyle().Foreground(cBorder).Render(strings.Repeat("─", contentWidth)) - activityLine := renderActivityLine(activity, elapsed, contentWidth) - - content := strings.Join([]string{ - header, - divider, - "", - activityLine, - }, "\n") - - style := lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(cPrimary). - Padding(1, 2). - Width(panelWidth - 2) - - return style.Render(content) -} - -// clearPanelLines clears N lines of previous panel output by moving cursor up and erasing. -func clearPanelLines(n int) { - if n <= 0 { - return - } - // Move to first line - if n > 1 { - fmt.Printf("\033[%dA", n-1) - } - fmt.Print("\r") - // Clear each line - for i := 0; i < n; i++ { - fmt.Print("\033[2K") - if i < n-1 { - fmt.Print("\n") - } - } - // Return to first line - if n > 1 { - fmt.Printf("\033[%dA", n-1) - } - fmt.Print("\r") -} - -// repaintBox repaints the panel box, handling cursor movement for the previous frame. -// Returns the new line count for the next frame. -func repaintBox(box string, prevLines int) int { - newLines := strings.Count(box, "\n") + 1 - - // Move cursor to start of previous panel - if prevLines > 1 { - fmt.Printf("\033[%dA", prevLines-1) - } - if prevLines > 0 { - fmt.Print("\r") - } - - // Print the new box - fmt.Print(box) - - // Clear leftover lines if new box is shorter - if newLines < prevLines { - for i := 0; i < prevLines-newLines; i++ { - fmt.Print("\n\033[2K") - } - fmt.Printf("\033[%dA", prevLines-newLines) - } - - return newLines -} - -// WaitWithSpinner runs a bordered panel while waiting for a command to finish. -// Exported for use by cmd when running agent conversion. -func WaitWithSpinner(cmd *exec.Cmd, title, message string, stderr *bytes.Buffer) error { - done := make(chan error, 1) - go func() { - done <- cmd.Wait() - }() - - startTime := time.Now() - ticker := time.NewTicker(200 * time.Millisecond) - defer ticker.Stop() - - termWidth := getTerminalWidth() - panelWidth := termWidth - 2 - if panelWidth > 62 { - panelWidth = 62 - } - - prevLines := 0 - - for { - select { - case err := <-done: - clearPanelLines(prevLines) - if err != nil { - return fmt.Errorf("agent failed: %s", stderr.String()) - } - return nil - case <-ticker.C: - box := renderSpinnerBox(title, message, time.Since(startTime), panelWidth) - prevLines = repaintBox(box, prevLines) - } - } -} - -// WaitWithPanel runs a full progress panel (header, activity, progress bar, jokes) -// while waiting for a command to finish. Exported for use by cmd when running agent conversion. -func WaitWithPanel(cmd *exec.Cmd, title, activity string, stderr *bytes.Buffer) error { - done := make(chan error, 1) - go func() { - done <- cmd.Wait() - }() - - startTime := time.Now() - ticker := time.NewTicker(80 * time.Millisecond) - defer ticker.Stop() - - // Pick a random starting joke and track rotation - jokeIndex := rand.Intn(len(waitingJokes)) - currentJoke := waitingJokes[jokeIndex] - lastJokeChange := time.Now() - - termWidth := getTerminalWidth() - panelWidth := termWidth - 2 - if panelWidth > 62 { - panelWidth = 62 - } - - prevLines := 0 - - for { - select { - case err := <-done: - clearPanelLines(prevLines) - if err != nil { - return fmt.Errorf("agent failed: %s", stderr.String()) - } - return nil - case <-ticker.C: - // Rotate joke every 30 seconds - if time.Since(lastJokeChange) >= 30*time.Second { - jokeIndex = (jokeIndex + 1 + rand.Intn(len(waitingJokes)-1)) % len(waitingJokes) - currentJoke = waitingJokes[jokeIndex] - lastJokeChange = time.Now() - } - - box := renderProgressBox(title, activity, time.Since(startTime), currentJoke, panelWidth) - prevLines = repaintBox(box, prevLines) - } - } -} - -// formatElapsed formats a duration as a human-readable elapsed time string. -// Examples: "0s", "5s", "1m 12s", "2m 0s" -func formatElapsed(d time.Duration) string { - d = d.Truncate(time.Second) - if d < time.Minute { - return fmt.Sprintf("%ds", int(d.Seconds())) - } - minutes := int(d.Minutes()) - seconds := int(d.Seconds()) % 60 - return fmt.Sprintf("%dm %ds", minutes, seconds) -} - -// NeedsConversion checks if prd.md is newer than prd.json, indicating conversion is needed. -// Returns true if: -// - prd.md exists and prd.json does not exist -// - prd.md exists and is newer than prd.json -// Returns false if: -// - prd.md does not exist -// - prd.json is newer than or same age as prd.md -func NeedsConversion(prdDir string) (bool, error) { - prdMdPath := filepath.Join(prdDir, "prd.md") - prdJsonPath := filepath.Join(prdDir, "prd.json") - - // Check if prd.md exists - mdInfo, err := os.Stat(prdMdPath) - if os.IsNotExist(err) { - // No prd.md, no conversion needed - return false, nil - } - if err != nil { - return false, fmt.Errorf("failed to stat prd.md: %w", err) - } - - // Check if prd.json exists - jsonInfo, err := os.Stat(prdJsonPath) - if os.IsNotExist(err) { - // prd.md exists but prd.json doesn't - needs conversion - return true, nil - } - if err != nil { - return false, fmt.Errorf("failed to stat prd.json: %w", err) - } - - // Both exist - compare modification times - return mdInfo.ModTime().After(jsonInfo.ModTime()), nil -} - -// stripMarkdownFences removes markdown code blocks and extracts the JSON object. -// This handles output from providers like Claude that may wrap JSON in markdown fences. -func stripMarkdownFences(output string) string { - output = strings.TrimSpace(output) - - // Remove markdown code blocks if present - if strings.HasPrefix(output, "```json") { - output = strings.TrimPrefix(output, "```json") - } else if strings.HasPrefix(output, "```") { - output = strings.TrimPrefix(output, "```") - } - - if strings.HasSuffix(output, "```") { - output = strings.TrimSuffix(output, "```") - } - - output = strings.TrimSpace(output) - - // If output doesn't start with '{', the provider may have added preamble text. - // Extract the JSON object by finding the first '{' and matching closing '}'. - if len(output) > 0 && output[0] != '{' { - start := strings.Index(output, "{") - if start == -1 { - return output // No JSON object found, return as-is for error handling - } - // Find the matching closing brace by counting brace depth - depth := 0 - inString := false - escaped := false - end := -1 - for i := start; i < len(output); i++ { - if escaped { - escaped = false - continue - } - ch := output[i] - if ch == '\\' && inString { - escaped = true - continue - } - if ch == '"' { - inString = !inString - continue - } - if inString { - continue - } - if ch == '{' { - depth++ - } else if ch == '}' { - depth-- - if depth == 0 { - end = i - break - } - } - } - if end != -1 { - output = output[start : end+1] - } else { - // No matching closing brace; take from first '{' to end - output = output[start:] - } - } - - return strings.TrimSpace(output) -} - -// validateJSON checks if the given string is valid JSON. -func validateJSON(content string) error { - var js json.RawMessage - if err := json.Unmarshal([]byte(content), &js); err != nil { - return fmt.Errorf("invalid JSON: %w", err) - } - return nil -} - -// CountMarkdownStories counts the approximate number of user stories in a markdown PRD -// by counting second-level headings (## ). This is a heuristic used for truncation detection. -func CountMarkdownStories(content string) int { - count := 0 - for _, line := range strings.Split(content, "\n") { - trimmed := strings.TrimSpace(line) - if strings.HasPrefix(trimmed, "## ") { - count++ - } - } - return count -} - -// HasProgress checks if the PRD has any progress (passes: true or inProgress: true). -func HasProgress(prd *PRD) bool { - if prd == nil { - return false - } - for _, story := range prd.UserStories { - if story.Passes || story.InProgress { - return true - } - } - return false -} - -// MergeProgress merges progress from the old PRD into the new PRD. -// For stories with matching IDs, it preserves the Passes and InProgress status. -// New stories (in newPRD but not in oldPRD) are added without progress. -// Removed stories (in oldPRD but not in newPRD) are dropped. -func MergeProgress(oldPRD, newPRD *PRD) { - if oldPRD == nil || newPRD == nil { - return - } - - // Create a map of old story statuses by ID - oldStatus := make(map[string]struct { - passes bool - inProgress bool - }) - for _, story := range oldPRD.UserStories { - oldStatus[story.ID] = struct { - passes bool - inProgress bool - }{ - passes: story.Passes, - inProgress: story.InProgress, - } - } - - // Apply old status to matching stories in new PRD - for i := range newPRD.UserStories { - if status, exists := oldStatus[newPRD.UserStories[i].ID]; exists { - newPRD.UserStories[i].Passes = status.passes - newPRD.UserStories[i].InProgress = status.inProgress - } - } -} - -// promptProgressConflict prompts the user to choose how to handle a progress conflict. -func promptProgressConflict(oldPRD, newPRD *PRD) (ProgressConflictChoice, error) { - // Count stories with progress - progressCount := 0 - for _, story := range oldPRD.UserStories { - if story.Passes || story.InProgress { - progressCount++ - } - } - - // Show warning - fmt.Println() - fmt.Printf("⚠️ Warning: prd.json has progress (%d stories with status)\n", progressCount) - fmt.Println() - fmt.Println("How would you like to proceed?") - fmt.Println() - fmt.Println(" [m] Merge - Keep status for matching story IDs, add new stories, drop removed stories") - fmt.Println(" [o] Overwrite - Discard all progress and use the new PRD") - fmt.Println(" [c] Cancel - Cancel conversion and keep existing prd.json") - fmt.Println() - fmt.Print("Choice [m/o/c]: ") - - reader := bufio.NewReader(os.Stdin) - input, err := reader.ReadString('\n') - if err != nil { - return ChoiceCancel, fmt.Errorf("failed to read input: %w", err) - } - - input = strings.TrimSpace(strings.ToLower(input)) - switch input { - case "m", "merge": - return ChoiceMerge, nil - case "o", "overwrite": - return ChoiceOverwrite, nil - case "c", "cancel", "": - return ChoiceCancel, nil - default: - fmt.Printf("Invalid choice %q, cancelling conversion.\n", input) - return ChoiceCancel, nil - } -} diff --git a/internal/prd/generator_test.go b/internal/prd/generator_test.go deleted file mode 100644 index dd7b0fd0..00000000 --- a/internal/prd/generator_test.go +++ /dev/null @@ -1,634 +0,0 @@ -package prd - -import ( - "os" - "path/filepath" - "strings" - "testing" - "time" -) - -func TestStripMarkdownFences(t *testing.T) { - tests := []struct { - name string - input string - expected string - }{ - { - name: "plain JSON", - input: `{"project": "test"}`, - expected: `{"project": "test"}`, - }, - { - name: "with json code block", - input: "```json\n{\"project\": \"test\"}\n```", - expected: `{"project": "test"}`, - }, - { - name: "with plain code block", - input: "```\n{\"project\": \"test\"}\n```", - expected: `{"project": "test"}`, - }, - { - name: "with extra whitespace", - input: " \n{\"project\": \"test\"}\n ", - expected: `{"project": "test"}`, - }, - { - name: "with conversational preamble", - input: "Since the file write is being denied, here's the JSON output directly:\n\n{\"project\": \"test\"}", - expected: `{"project": "test"}`, - }, - { - name: "with preamble and nested objects", - input: "Here is the JSON:\n{\"project\": \"test\", \"userStories\": [{\"id\": \"US-001\"}]}", - expected: `{"project": "test", "userStories": [{"id": "US-001"}]}`, - }, - { - name: "with preamble and trailing text", - input: "Here you go:\n{\"project\": \"test\"}\nLet me know if you need changes.", - expected: `{"project": "test"}`, - }, - { - name: "with code fence and preamble", - input: "Here is the output:\n```json\n{\"project\": \"test\"}\n```", - expected: `{"project": "test"}`, - }, - { - name: "JSON with escaped quotes in preamble scenario", - input: "Output:\n{\"project\": \"test \\\"quoted\\\"\"}", - expected: `{"project": "test \"quoted\""}`, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := stripMarkdownFences(tt.input) - if result != tt.expected { - t.Errorf("stripMarkdownFences() = %q, want %q", result, tt.expected) - } - }) - } -} - -func TestValidateJSON(t *testing.T) { - tests := []struct { - name string - input string - wantErr bool - }{ - { - name: "valid JSON object", - input: `{"project": "test", "stories": []}`, - wantErr: false, - }, - { - name: "valid JSON array", - input: `[1, 2, 3]`, - wantErr: false, - }, - { - name: "valid nested JSON", - input: `{"project": "test", "userStories": [{"id": "US-001", "title": "Test"}]}`, - wantErr: false, - }, - { - name: "invalid JSON - missing closing brace", - input: `{"project": "test"`, - wantErr: true, - }, - { - name: "invalid JSON - trailing comma", - input: `{"project": "test",}`, - wantErr: true, - }, - { - name: "invalid JSON - plain text", - input: `This is not JSON`, - wantErr: true, - }, - { - name: "empty string", - input: ``, - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := validateJSON(tt.input) - if (err != nil) != tt.wantErr { - t.Errorf("validateJSON() error = %v, wantErr %v", err, tt.wantErr) - } - }) - } -} - -func TestNeedsConversion(t *testing.T) { - t.Run("no prd.md exists", func(t *testing.T) { - tmpDir := t.TempDir() - - needs, err := NeedsConversion(tmpDir) - if err != nil { - t.Errorf("NeedsConversion() unexpected error: %v", err) - } - if needs { - t.Error("NeedsConversion() = true, want false when no prd.md exists") - } - }) - - t.Run("prd.md exists but prd.json does not", func(t *testing.T) { - tmpDir := t.TempDir() - prdMdPath := filepath.Join(tmpDir, "prd.md") - if err := os.WriteFile(prdMdPath, []byte("# Test PRD"), 0644); err != nil { - t.Fatalf("Failed to create prd.md: %v", err) - } - - needs, err := NeedsConversion(tmpDir) - if err != nil { - t.Errorf("NeedsConversion() unexpected error: %v", err) - } - if !needs { - t.Error("NeedsConversion() = false, want true when prd.json doesn't exist") - } - }) - - t.Run("prd.md is newer than prd.json", func(t *testing.T) { - tmpDir := t.TempDir() - - // Create prd.json first - prdJsonPath := filepath.Join(tmpDir, "prd.json") - if err := os.WriteFile(prdJsonPath, []byte(`{"project":"test"}`), 0644); err != nil { - t.Fatalf("Failed to create prd.json: %v", err) - } - - // Wait a moment to ensure different timestamps - time.Sleep(100 * time.Millisecond) - - // Create prd.md after (so it's newer) - prdMdPath := filepath.Join(tmpDir, "prd.md") - if err := os.WriteFile(prdMdPath, []byte("# Test PRD"), 0644); err != nil { - t.Fatalf("Failed to create prd.md: %v", err) - } - - needs, err := NeedsConversion(tmpDir) - if err != nil { - t.Errorf("NeedsConversion() unexpected error: %v", err) - } - if !needs { - t.Error("NeedsConversion() = false, want true when prd.md is newer") - } - }) - - t.Run("prd.json is newer than prd.md", func(t *testing.T) { - tmpDir := t.TempDir() - - // Create prd.md first - prdMdPath := filepath.Join(tmpDir, "prd.md") - if err := os.WriteFile(prdMdPath, []byte("# Test PRD"), 0644); err != nil { - t.Fatalf("Failed to create prd.md: %v", err) - } - - // Wait a moment to ensure different timestamps - time.Sleep(100 * time.Millisecond) - - // Create prd.json after (so it's newer) - prdJsonPath := filepath.Join(tmpDir, "prd.json") - if err := os.WriteFile(prdJsonPath, []byte(`{"project":"test"}`), 0644); err != nil { - t.Fatalf("Failed to create prd.json: %v", err) - } - - needs, err := NeedsConversion(tmpDir) - if err != nil { - t.Errorf("NeedsConversion() unexpected error: %v", err) - } - if needs { - t.Error("NeedsConversion() = true, want false when prd.json is newer") - } - }) -} - -func TestConvertMissingPrdMd(t *testing.T) { - tmpDir := t.TempDir() - - err := Convert(ConvertOptions{PRDDir: tmpDir}) - if err == nil { - t.Error("Convert() expected error when prd.md is missing") - } -} - -func TestHasProgress(t *testing.T) { - tests := []struct { - name string - prd *PRD - expected bool - }{ - { - name: "nil PRD", - prd: nil, - expected: false, - }, - { - name: "empty PRD", - prd: &PRD{}, - expected: false, - }, - { - name: "no progress", - prd: &PRD{ - UserStories: []UserStory{ - {ID: "US-001", Passes: false, InProgress: false}, - {ID: "US-002", Passes: false, InProgress: false}, - }, - }, - expected: false, - }, - { - name: "one story passes", - prd: &PRD{ - UserStories: []UserStory{ - {ID: "US-001", Passes: true, InProgress: false}, - {ID: "US-002", Passes: false, InProgress: false}, - }, - }, - expected: true, - }, - { - name: "one story in progress", - prd: &PRD{ - UserStories: []UserStory{ - {ID: "US-001", Passes: false, InProgress: true}, - {ID: "US-002", Passes: false, InProgress: false}, - }, - }, - expected: true, - }, - { - name: "all stories pass", - prd: &PRD{ - UserStories: []UserStory{ - {ID: "US-001", Passes: true}, - {ID: "US-002", Passes: true}, - }, - }, - expected: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := HasProgress(tt.prd) - if result != tt.expected { - t.Errorf("HasProgress() = %v, want %v", result, tt.expected) - } - }) - } -} - -func TestMergeProgress(t *testing.T) { - t.Run("nil PRDs", func(t *testing.T) { - // Should not panic - MergeProgress(nil, nil) - MergeProgress(&PRD{}, nil) - MergeProgress(nil, &PRD{}) - }) - - t.Run("matching story IDs - preserve status", func(t *testing.T) { - oldPRD := &PRD{ - UserStories: []UserStory{ - {ID: "US-001", Title: "Old Title 1", Passes: true, InProgress: false}, - {ID: "US-002", Title: "Old Title 2", Passes: false, InProgress: true}, - {ID: "US-003", Title: "Old Title 3", Passes: false, InProgress: false}, - }, - } - newPRD := &PRD{ - UserStories: []UserStory{ - {ID: "US-001", Title: "New Title 1", Passes: false, InProgress: false}, - {ID: "US-002", Title: "New Title 2", Passes: false, InProgress: false}, - {ID: "US-003", Title: "New Title 3", Passes: false, InProgress: false}, - }, - } - - MergeProgress(oldPRD, newPRD) - - // US-001 should have passes: true preserved - if !newPRD.UserStories[0].Passes { - t.Error("US-001 should have Passes: true after merge") - } - // US-002 should have inProgress: true preserved - if !newPRD.UserStories[1].InProgress { - t.Error("US-002 should have InProgress: true after merge") - } - // US-003 should remain unchanged (no progress) - if newPRD.UserStories[2].Passes || newPRD.UserStories[2].InProgress { - t.Error("US-003 should not have any progress after merge") - } - }) - - t.Run("new stories added - no progress", func(t *testing.T) { - oldPRD := &PRD{ - UserStories: []UserStory{ - {ID: "US-001", Passes: true}, - }, - } - newPRD := &PRD{ - UserStories: []UserStory{ - {ID: "US-001", Passes: false}, - {ID: "US-002", Passes: false}, // New story - }, - } - - MergeProgress(oldPRD, newPRD) - - // US-001 should have progress preserved - if !newPRD.UserStories[0].Passes { - t.Error("US-001 should have Passes: true after merge") - } - // US-002 is new, should have no progress - if newPRD.UserStories[1].Passes || newPRD.UserStories[1].InProgress { - t.Error("New story US-002 should not have any progress") - } - }) - - t.Run("removed stories are dropped", func(t *testing.T) { - oldPRD := &PRD{ - UserStories: []UserStory{ - {ID: "US-001", Passes: true}, - {ID: "US-002", Passes: true}, // Will be removed - }, - } - newPRD := &PRD{ - UserStories: []UserStory{ - {ID: "US-001", Passes: false}, - // US-002 removed from new PRD - }, - } - - MergeProgress(oldPRD, newPRD) - - // Only US-001 should exist - if len(newPRD.UserStories) != 1 { - t.Errorf("Expected 1 story, got %d", len(newPRD.UserStories)) - } - if newPRD.UserStories[0].ID != "US-001" { - t.Errorf("Expected US-001, got %s", newPRD.UserStories[0].ID) - } - if !newPRD.UserStories[0].Passes { - t.Error("US-001 should have Passes: true after merge") - } - }) - - t.Run("mixed scenario - add, remove, keep", func(t *testing.T) { - oldPRD := &PRD{ - UserStories: []UserStory{ - {ID: "US-001", Passes: true}, // Keep with progress - {ID: "US-002", Passes: true}, // Removed - {ID: "US-003", InProgress: true}, // Keep with progress - {ID: "US-004", Passes: false}, // Keep without progress - }, - } - newPRD := &PRD{ - UserStories: []UserStory{ - {ID: "US-001", Passes: false}, // Existing - {ID: "US-003", Passes: false}, // Existing - {ID: "US-004", Passes: false}, // Existing - {ID: "US-005", Passes: false}, // New - }, - } - - MergeProgress(oldPRD, newPRD) - - // Verify each story - storyMap := make(map[string]*UserStory) - for i := range newPRD.UserStories { - storyMap[newPRD.UserStories[i].ID] = &newPRD.UserStories[i] - } - - if s, ok := storyMap["US-001"]; !ok || !s.Passes { - t.Error("US-001 should exist with Passes: true") - } - if _, ok := storyMap["US-002"]; ok { - t.Error("US-002 should be removed") - } - if s, ok := storyMap["US-003"]; !ok || !s.InProgress { - t.Error("US-003 should exist with InProgress: true") - } - if s, ok := storyMap["US-004"]; !ok || s.Passes || s.InProgress { - t.Error("US-004 should exist without progress") - } - if s, ok := storyMap["US-005"]; !ok || s.Passes || s.InProgress { - t.Error("US-005 should exist without progress (new story)") - } - }) - - t.Run("reordered stories - preserves progress by ID", func(t *testing.T) { - oldPRD := &PRD{ - UserStories: []UserStory{ - {ID: "US-001", Priority: 1, Passes: true}, - {ID: "US-002", Priority: 2, Passes: false}, - {ID: "US-003", Priority: 3, InProgress: true}, - }, - } - newPRD := &PRD{ - UserStories: []UserStory{ - {ID: "US-003", Priority: 1, Passes: false}, // Moved to top - {ID: "US-001", Priority: 2, Passes: false}, // Moved down - {ID: "US-002", Priority: 3, Passes: false}, // Moved down - }, - } - - MergeProgress(oldPRD, newPRD) - - // Verify progress is preserved regardless of order - if !newPRD.UserStories[0].InProgress { - t.Error("US-003 should have InProgress: true after merge") - } - if !newPRD.UserStories[1].Passes { - t.Error("US-001 should have Passes: true after merge") - } - if newPRD.UserStories[2].Passes || newPRD.UserStories[2].InProgress { - t.Error("US-002 should not have progress after merge") - } - }) -} - -func TestLoadAndValidateConvertedPRD(t *testing.T) { - t.Run("valid prd.json", func(t *testing.T) { - tmpDir := t.TempDir() - prdJsonPath := filepath.Join(tmpDir, "prd.json") - content := `{ - "project": "Test Project", - "description": "A test project", - "userStories": [ - { - "id": "US-001", - "title": "First Story", - "description": "Do something", - "acceptanceCriteria": ["It works"], - "priority": 1, - "passes": false - } - ] -}` - if err := os.WriteFile(prdJsonPath, []byte(content), 0644); err != nil { - t.Fatalf("Failed to write test prd.json: %v", err) - } - - prd, err := loadAndValidateConvertedPRD(prdJsonPath) - if err != nil { - t.Errorf("loadAndValidateConvertedPRD() unexpected error: %v", err) - } - if prd == nil { - t.Fatal("Expected non-nil PRD") - } - if prd.Project != "Test Project" { - t.Errorf("Expected project 'Test Project', got %q", prd.Project) - } - }) - - t.Run("file does not exist", func(t *testing.T) { - tmpDir := t.TempDir() - prdJsonPath := filepath.Join(tmpDir, "prd.json") - - _, err := loadAndValidateConvertedPRD(prdJsonPath) - if err == nil { - t.Error("Expected error when prd.json does not exist") - } - }) - - t.Run("invalid JSON", func(t *testing.T) { - tmpDir := t.TempDir() - prdJsonPath := filepath.Join(tmpDir, "prd.json") - if err := os.WriteFile(prdJsonPath, []byte(`{invalid json`), 0644); err != nil { - t.Fatalf("Failed to write test file: %v", err) - } - - _, err := loadAndValidateConvertedPRD(prdJsonPath) - if err == nil { - t.Error("Expected error for invalid JSON") - } - }) - - t.Run("missing project field", func(t *testing.T) { - tmpDir := t.TempDir() - prdJsonPath := filepath.Join(tmpDir, "prd.json") - content := `{ - "project": "", - "description": "A test", - "userStories": [{"id": "US-001", "title": "Story", "description": "Desc", "acceptanceCriteria": [], "priority": 1, "passes": false}] -}` - if err := os.WriteFile(prdJsonPath, []byte(content), 0644); err != nil { - t.Fatalf("Failed to write test file: %v", err) - } - - _, err := loadAndValidateConvertedPRD(prdJsonPath) - if err == nil { - t.Error("Expected error for missing project field") - } - if err != nil && !strings.Contains(err.Error(), "project") { - t.Errorf("Expected error about 'project' field, got: %v", err) - } - }) - - t.Run("no user stories", func(t *testing.T) { - tmpDir := t.TempDir() - prdJsonPath := filepath.Join(tmpDir, "prd.json") - content := `{ - "project": "Test", - "description": "A test", - "userStories": [] -}` - if err := os.WriteFile(prdJsonPath, []byte(content), 0644); err != nil { - t.Fatalf("Failed to write test file: %v", err) - } - - _, err := loadAndValidateConvertedPRD(prdJsonPath) - if err == nil { - t.Error("Expected error for empty user stories") - } - if err != nil && !strings.Contains(err.Error(), "user stories") { - t.Errorf("Expected error about 'user stories', got: %v", err) - } - }) - - t.Run("JSON with escaped quotes parses correctly", func(t *testing.T) { - tmpDir := t.TempDir() - prdJsonPath := filepath.Join(tmpDir, "prd.json") - content := `{ - "project": "Test Project", - "description": "A project with \"quoted\" text", - "userStories": [ - { - "id": "US-001", - "title": "Story with \"quotes\"", - "description": "Click the \"Submit\" button", - "acceptanceCriteria": ["User sees \"Success\" message", "Button says \"OK\""], - "priority": 1, - "passes": false - } - ] -}` - if err := os.WriteFile(prdJsonPath, []byte(content), 0644); err != nil { - t.Fatalf("Failed to write test file: %v", err) - } - - prd, err := loadAndValidateConvertedPRD(prdJsonPath) - if err != nil { - t.Errorf("loadAndValidateConvertedPRD() unexpected error: %v", err) - } - if prd == nil { - t.Fatal("Expected non-nil PRD") - } - // Verify the escaped quotes are properly parsed - if prd.UserStories[0].Title != `Story with "quotes"` { - t.Errorf("Expected title with unescaped quotes, got %q", prd.UserStories[0].Title) - } - if prd.UserStories[0].AcceptanceCriteria[0] != `User sees "Success" message` { - t.Errorf("Expected acceptance criteria with unescaped quotes, got %q", prd.UserStories[0].AcceptanceCriteria[0]) - } - }) -} - -// Note: Full integration tests for Convert(), runClaudeConversion(), runClaudeJSONFix(), -// and waitWithSpinner() require Claude to be available and are not included here. - -func TestSamplePRDMarkdown(t *testing.T) { - // Test that a sample prd.md structure is recognized - // This verifies the file detection logic, not the actual conversion - tmpDir := t.TempDir() - - sampleMd := `# My Test Project - -A sample project for testing. - -## User Stories - -### US-001: Setup Project -As a developer, I need a properly structured project. - -**Acceptance Criteria:** -- Create project structure -- Add dependencies -- Verify build works - -### US-002: Add Feature -As a user, I want a new feature. - -**Acceptance Criteria:** -- Feature works correctly -- Tests pass -` - prdMdPath := filepath.Join(tmpDir, "prd.md") - if err := os.WriteFile(prdMdPath, []byte(sampleMd), 0644); err != nil { - t.Fatalf("Failed to create sample prd.md: %v", err) - } - - // Verify the file can be detected for conversion - needs, err := NeedsConversion(tmpDir) - if err != nil { - t.Errorf("NeedsConversion() unexpected error: %v", err) - } - if !needs { - t.Error("Sample prd.md should trigger conversion need") - } -} diff --git a/internal/prd/loader.go b/internal/prd/loader.go index a4da8330..88182072 100644 --- a/internal/prd/loader.go +++ b/internal/prd/loader.go @@ -1,36 +1,6 @@ package prd -import ( - "encoding/json" - "fmt" - "os" -) - -// LoadPRD reads and parses a PRD JSON file from the given path. +// LoadPRD reads and parses a PRD markdown file from the given path. func LoadPRD(path string) (*PRD, error) { - data, err := os.ReadFile(path) - if err != nil { - return nil, fmt.Errorf("failed to read PRD file: %w", err) - } - - var p PRD - if err := json.Unmarshal(data, &p); err != nil { - return nil, fmt.Errorf("failed to parse PRD JSON: %w", err) - } - - return &p, nil -} - -// Save writes the PRD back to a JSON file at the given path. -func (p *PRD) Save(path string) error { - data, err := json.MarshalIndent(p, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal PRD: %w", err) - } - - if err := os.WriteFile(path, data, 0644); err != nil { - return fmt.Errorf("failed to write PRD file: %w", err) - } - - return nil + return ParseMarkdownPRD(path) } diff --git a/internal/prd/markdown.go b/internal/prd/markdown.go new file mode 100644 index 00000000..8de033b5 --- /dev/null +++ b/internal/prd/markdown.go @@ -0,0 +1,173 @@ +package prd + +import ( + "fmt" + "os" + "regexp" + "strings" +) + +// storyHeadingRegex matches story headings like "### US-001: Story Title" +var storyHeadingRegex = regexp.MustCompile(`^###\s+([A-Za-z]+-\d+):\s+(.+)$`) + +// statusLineRegex matches "**Status:** value" +var statusLineRegex = regexp.MustCompile(`^\*\*Status:\*\*\s*(.+)$`) + +// priorityLineRegex matches "**Priority:** value" +var priorityLineRegex = regexp.MustCompile(`^\*\*Priority:\*\*\s*(.+)$`) + +// descriptionLineRegex matches "**Description:** value" +var descriptionLineRegex = regexp.MustCompile(`^\*\*Description:\*\*\s*(.+)$`) + +// checkboxRegex matches "- [ ] text" or "- [x] text" +var checkboxRegex = regexp.MustCompile(`^-\s+\[([ xX])\]\s+(.+)$`) + +// projectHeadingRegex matches "# PRD: Name" or "# Name" +var projectHeadingRegex = regexp.MustCompile(`^#\s+(?:PRD:\s+)?(.+)$`) + +// ParseMarkdownPRD reads and parses a PRD markdown file from the given path. +func ParseMarkdownPRD(path string) (*PRD, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read PRD file: %w", err) + } + + return ParseMarkdownPRDFromString(string(data)) +} + +// ParseMarkdownPRDFromString parses a PRD from a markdown string. +func ParseMarkdownPRDFromString(content string) (*PRD, error) { + lines := strings.Split(content, "\n") + p := &PRD{} + + type storyBuilder struct { + story UserStory + descLines []string + } + + var current *storyBuilder + introStarted := false + introDone := false + autoPriority := 0 + + flushStory := func() { + if current == nil { + return + } + // If no explicit Description, join collected prose lines + if current.story.Description == "" && len(current.descLines) > 0 { + current.story.Description = strings.Join(current.descLines, " ") + } + // Assign auto-priority if none was set + if current.story.Priority == 0 { + autoPriority++ + current.story.Priority = autoPriority + } else if current.story.Priority > autoPriority { + autoPriority = current.story.Priority + } + p.UserStories = append(p.UserStories, current.story) + current = nil + } + + for _, line := range lines { + trimmed := strings.TrimSpace(line) + + // Check for project heading (# level only, not ## or ###) + if strings.HasPrefix(line, "# ") && !strings.HasPrefix(line, "## ") { + if m := projectHeadingRegex.FindStringSubmatch(trimmed); m != nil { + p.Project = strings.TrimSpace(m[1]) + introStarted = true + continue + } + } + + // Check for story heading (### ID: Title) + if m := storyHeadingRegex.FindStringSubmatch(trimmed); m != nil { + flushStory() + introDone = true + current = &storyBuilder{ + story: UserStory{ + ID: m[1], + Title: strings.TrimSpace(m[2]), + }, + } + continue + } + + // Check for ## heading (section boundary — ends current story block) + if strings.HasPrefix(line, "## ") { + flushStory() + + heading := strings.TrimSpace(strings.TrimPrefix(trimmed, "##")) + if strings.EqualFold(heading, "Introduction") || strings.EqualFold(heading, "Overview") { + introStarted = true + introDone = false + } else { + introDone = true + } + continue + } + + // Inside a story block + if current != nil { + // **Status:** line + if m := statusLineRegex.FindStringSubmatch(trimmed); m != nil { + status := strings.TrimSpace(strings.ToLower(m[1])) + switch status { + case "done", "complete", "completed", "passed": + current.story.Passes = true + current.story.InProgress = false + case "in-progress", "in progress", "started": + current.story.InProgress = true + current.story.Passes = false + default: + current.story.Passes = false + current.story.InProgress = false + } + continue + } + + // **Priority:** line + if m := priorityLineRegex.FindStringSubmatch(trimmed); m != nil { + val := strings.TrimSpace(m[1]) + var pri int + if _, err := fmt.Sscanf(val, "%d", &pri); err == nil && pri > 0 { + current.story.Priority = pri + } + continue + } + + // **Description:** line + if m := descriptionLineRegex.FindStringSubmatch(trimmed); m != nil { + current.story.Description = strings.TrimSpace(m[1]) + continue + } + + // Checkbox items → acceptance criteria + if m := checkboxRegex.FindStringSubmatch(trimmed); m != nil { + current.story.AcceptanceCriteria = append(current.story.AcceptanceCriteria, strings.TrimSpace(m[2])) + continue + } + + // Collect prose lines as implicit description (only if no explicit **Description:** yet) + if trimmed != "" && current.story.Description == "" && + !strings.HasPrefix(trimmed, "**") && + !strings.HasPrefix(trimmed, "- ") { + current.descLines = append(current.descLines, trimmed) + } + continue + } + + // Collect introduction paragraph as project description + if introStarted && !introDone && p.Description == "" { + if trimmed != "" && !strings.HasPrefix(trimmed, "#") { + p.Description = trimmed + } + } + } + + // Flush the last story + flushStory() + + return p, nil +} diff --git a/internal/prd/markdown_test.go b/internal/prd/markdown_test.go new file mode 100644 index 00000000..eedcecc0 --- /dev/null +++ b/internal/prd/markdown_test.go @@ -0,0 +1,334 @@ +package prd + +import ( + "os" + "path/filepath" + "testing" +) + +func TestParseMarkdownPRDFromString_Normal(t *testing.T) { + md := `# PRD: My Test Project + +A sample project for testing. + +## User Stories + +### US-001: Setup Project +As a developer, I need a properly structured project. + +**Priority:** 1 +**Status:** done + +- [x] Create project structure +- [x] Add dependencies + +### US-002: Add Feature +**Description:** As a user, I want a new feature. + +**Status:** in-progress + +- [ ] Feature works correctly +- [ ] Tests pass + +### US-003: Final Polish +Some prose description here. + +- [ ] Polish the UI +- [ ] Write docs +` + p, err := ParseMarkdownPRDFromString(md) + if err != nil { + t.Fatalf("ParseMarkdownPRDFromString() error = %v", err) + } + + if p.Project != "My Test Project" { + t.Errorf("Project = %q, want %q", p.Project, "My Test Project") + } + if p.Description != "A sample project for testing." { + t.Errorf("Description = %q, want %q", p.Description, "A sample project for testing.") + } + if len(p.UserStories) != 3 { + t.Fatalf("len(UserStories) = %d, want 3", len(p.UserStories)) + } + + // Story 1: done + s1 := p.UserStories[0] + if s1.ID != "US-001" { + t.Errorf("s1.ID = %q, want %q", s1.ID, "US-001") + } + if s1.Title != "Setup Project" { + t.Errorf("s1.Title = %q, want %q", s1.Title, "Setup Project") + } + if !s1.Passes { + t.Error("s1.Passes = false, want true") + } + if s1.InProgress { + t.Error("s1.InProgress = true, want false") + } + if s1.Priority != 1 { + t.Errorf("s1.Priority = %d, want 1", s1.Priority) + } + if len(s1.AcceptanceCriteria) != 2 { + t.Errorf("len(s1.AcceptanceCriteria) = %d, want 2", len(s1.AcceptanceCriteria)) + } + + // Story 2: in-progress + s2 := p.UserStories[1] + if s2.ID != "US-002" { + t.Errorf("s2.ID = %q, want %q", s2.ID, "US-002") + } + if !s2.InProgress { + t.Error("s2.InProgress = false, want true") + } + if s2.Passes { + t.Error("s2.Passes = true, want false") + } + if s2.Description != "As a user, I want a new feature." { + t.Errorf("s2.Description = %q, want %q", s2.Description, "As a user, I want a new feature.") + } + + // Story 3: pending (no status) + s3 := p.UserStories[2] + if s3.ID != "US-003" { + t.Errorf("s3.ID = %q, want %q", s3.ID, "US-003") + } + if s3.Passes || s3.InProgress { + t.Error("s3 should be pending (both false)") + } + if s3.Description != "Some prose description here." { + t.Errorf("s3.Description = %q, want %q", s3.Description, "Some prose description here.") + } + if len(s3.AcceptanceCriteria) != 2 { + t.Errorf("len(s3.AcceptanceCriteria) = %d, want 2", len(s3.AcceptanceCriteria)) + } +} + +func TestParseMarkdownPRDFromString_ProjectWithoutPRDPrefix(t *testing.T) { + md := `# My Project + +Overview text. + +### FEAT-001: First Feature +- [ ] It works +` + p, err := ParseMarkdownPRDFromString(md) + if err != nil { + t.Fatalf("error = %v", err) + } + if p.Project != "My Project" { + t.Errorf("Project = %q, want %q", p.Project, "My Project") + } + if len(p.UserStories) != 1 { + t.Fatalf("len(UserStories) = %d, want 1", len(p.UserStories)) + } + if p.UserStories[0].ID != "FEAT-001" { + t.Errorf("ID = %q, want %q", p.UserStories[0].ID, "FEAT-001") + } +} + +func TestParseMarkdownPRDFromString_MissingFields(t *testing.T) { + md := `# Minimal + +### US-001: Only Title +` + p, err := ParseMarkdownPRDFromString(md) + if err != nil { + t.Fatalf("error = %v", err) + } + if len(p.UserStories) != 1 { + t.Fatalf("len(UserStories) = %d, want 1", len(p.UserStories)) + } + s := p.UserStories[0] + if s.ID != "US-001" { + t.Errorf("ID = %q", s.ID) + } + if s.Priority != 1 { + t.Errorf("Priority = %d, want 1 (auto-assigned)", s.Priority) + } + if s.Passes || s.InProgress { + t.Error("should be pending") + } +} + +func TestParseMarkdownPRDFromString_PhaseHeadingsIgnored(t *testing.T) { + md := `# My Project + +## Phase 1: Setup + +### US-001: Do Setup +- [ ] Setup done + +## Phase 2: Build + +### US-002: Do Build +- [ ] Build done +` + p, err := ParseMarkdownPRDFromString(md) + if err != nil { + t.Fatalf("error = %v", err) + } + if len(p.UserStories) != 2 { + t.Fatalf("len(UserStories) = %d, want 2", len(p.UserStories)) + } + if p.UserStories[0].ID != "US-001" { + t.Errorf("first story ID = %q", p.UserStories[0].ID) + } + if p.UserStories[1].ID != "US-002" { + t.Errorf("second story ID = %q", p.UserStories[1].ID) + } +} + +func TestParseMarkdownPRDFromString_IntroductionSection(t *testing.T) { + md := `# PRD: Test + +## Introduction + +This is the introduction paragraph. + +## Stories + +### US-001: First +- [ ] Done +` + p, err := ParseMarkdownPRDFromString(md) + if err != nil { + t.Fatalf("error = %v", err) + } + if p.Description != "This is the introduction paragraph." { + t.Errorf("Description = %q, want %q", p.Description, "This is the introduction paragraph.") + } +} + +func TestParseMarkdownPRDFromString_FreeSections(t *testing.T) { + md := `# Test Project + +Overview. + +## Background + +Some background text. + +## User Stories + +### US-001: First +- [ ] Criterion A + +## Appendix + +Extra info here. +` + p, err := ParseMarkdownPRDFromString(md) + if err != nil { + t.Fatalf("error = %v", err) + } + if len(p.UserStories) != 1 { + t.Fatalf("len(UserStories) = %d, want 1", len(p.UserStories)) + } + if p.UserStories[0].ID != "US-001" { + t.Errorf("ID = %q", p.UserStories[0].ID) + } +} + +func TestParseMarkdownPRDFromString_StatusMapping(t *testing.T) { + tests := []struct { + status string + wantPasses bool + wantIP bool + }{ + {"done", true, false}, + {"complete", true, false}, + {"completed", true, false}, + {"passed", true, false}, + {"in-progress", false, true}, + {"in progress", false, true}, + {"started", false, true}, + {"todo", false, false}, + {"pending", false, false}, + {"", false, false}, + } + + for _, tt := range tests { + t.Run(tt.status, func(t *testing.T) { + md := "# P\n\n### US-001: S\n**Status:** " + tt.status + "\n" + p, err := ParseMarkdownPRDFromString(md) + if err != nil { + t.Fatalf("error = %v", err) + } + if len(p.UserStories) != 1 { + t.Fatalf("len(UserStories) = %d", len(p.UserStories)) + } + if p.UserStories[0].Passes != tt.wantPasses { + t.Errorf("Passes = %v, want %v", p.UserStories[0].Passes, tt.wantPasses) + } + if p.UserStories[0].InProgress != tt.wantIP { + t.Errorf("InProgress = %v, want %v", p.UserStories[0].InProgress, tt.wantIP) + } + }) + } +} + +func TestParseMarkdownPRD_File(t *testing.T) { + tmpDir := t.TempDir() + prdPath := filepath.Join(tmpDir, "prd.md") + + md := `# Test + +### US-001: First Story +- [ ] Works +` + if err := os.WriteFile(prdPath, []byte(md), 0644); err != nil { + t.Fatalf("failed to write: %v", err) + } + + p, err := ParseMarkdownPRD(prdPath) + if err != nil { + t.Fatalf("ParseMarkdownPRD() error = %v", err) + } + if p.Project != "Test" { + t.Errorf("Project = %q", p.Project) + } + if len(p.UserStories) != 1 { + t.Fatalf("len(UserStories) = %d", len(p.UserStories)) + } +} + +func TestParseMarkdownPRD_FileNotFound(t *testing.T) { + _, err := ParseMarkdownPRD("/nonexistent/prd.md") + if err == nil { + t.Error("expected error for nonexistent file") + } +} + +func TestParseMarkdownPRDFromString_AutoPriority(t *testing.T) { + md := `# P + +### US-001: First +- [ ] A + +### US-002: Second +**Priority:** 5 +- [ ] B + +### US-003: Third +- [ ] C +` + p, err := ParseMarkdownPRDFromString(md) + if err != nil { + t.Fatalf("error = %v", err) + } + if len(p.UserStories) != 3 { + t.Fatalf("len = %d", len(p.UserStories)) + } + // First: auto-priority 1 + if p.UserStories[0].Priority != 1 { + t.Errorf("s1.Priority = %d, want 1", p.UserStories[0].Priority) + } + // Second: explicit priority 5 + if p.UserStories[1].Priority != 5 { + t.Errorf("s2.Priority = %d, want 5", p.UserStories[1].Priority) + } + // Third: auto-priority 6 (after 5) + if p.UserStories[2].Priority != 6 { + t.Errorf("s3.Priority = %d, want 6", p.UserStories[2].Priority) + } +} diff --git a/internal/prd/markdown_writer.go b/internal/prd/markdown_writer.go new file mode 100644 index 00000000..ab3f26af --- /dev/null +++ b/internal/prd/markdown_writer.go @@ -0,0 +1,89 @@ +package prd + +import ( + "fmt" + "os" + "regexp" + "strings" +) + +// SetStoryStatus performs a surgical update of a story's status in a prd.md file. +// It finds the story block by its heading, updates or inserts the **Status:** line, +// and when status is "done", flips all unchecked checkboxes to checked. +func SetStoryStatus(path, storyID, status string) error { + data, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("failed to read PRD file: %w", err) + } + + result, err := setStoryStatusInString(string(data), storyID, status) + if err != nil { + return err + } + + return os.WriteFile(path, []byte(result), 0644) +} + +// setStoryStatusInString performs the status update on a string and returns the modified string. +func setStoryStatusInString(content, storyID, status string) (string, error) { + lines := strings.Split(content, "\n") + + // Find the story block + storyStart := -1 + storyEnd := len(lines) // default to end of file + + headingPattern := regexp.MustCompile(`^###\s+` + regexp.QuoteMeta(storyID) + `:\s+`) + + for i, line := range lines { + if storyStart == -1 { + // Looking for the story heading + if headingPattern.MatchString(strings.TrimSpace(line)) { + storyStart = i + } + } else { + // Looking for the end of the story block (next ## or ### heading) + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "## ") || strings.HasPrefix(trimmed, "### ") { + storyEnd = i + break + } + } + } + + if storyStart == -1 { + return "", fmt.Errorf("story %s not found in PRD", storyID) + } + + // Process the story block + statusLineIdx := -1 + statusLine := fmt.Sprintf("**Status:** %s", status) + + for i := storyStart + 1; i < storyEnd; i++ { + if statusLineRegex.MatchString(strings.TrimSpace(lines[i])) { + statusLineIdx = i + break + } + } + + if statusLineIdx >= 0 { + // Replace existing status line + lines[statusLineIdx] = statusLine + } else { + // Insert status line as first line after heading + newLines := make([]string, 0, len(lines)+1) + newLines = append(newLines, lines[:storyStart+1]...) + newLines = append(newLines, statusLine) + newLines = append(newLines, lines[storyStart+1:]...) + lines = newLines + storyEnd++ // adjust for the inserted line + } + + // When status is "done", flip all unchecked checkboxes to checked + if status == "done" { + for i := storyStart + 1; i < storyEnd; i++ { + lines[i] = strings.Replace(lines[i], "- [ ]", "- [x]", 1) + } + } + + return strings.Join(lines, "\n"), nil +} diff --git a/internal/prd/markdown_writer_test.go b/internal/prd/markdown_writer_test.go new file mode 100644 index 00000000..c7458252 --- /dev/null +++ b/internal/prd/markdown_writer_test.go @@ -0,0 +1,243 @@ +package prd + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestSetStoryStatusInString_ExistingStatusLine(t *testing.T) { + md := `# P + +### US-001: First +**Status:** todo +- [ ] A +- [ ] B + +### US-002: Second +- [ ] C +` + result, err := setStoryStatusInString(md, "US-001", "done") + if err != nil { + t.Fatalf("error = %v", err) + } + + if !strings.Contains(result, "**Status:** done") { + t.Error("expected **Status:** done in result") + } + // Should not contain the old status + if strings.Contains(result, "**Status:** todo") { + t.Error("old status should be replaced") + } + // Checkboxes should be flipped to checked + if strings.Contains(result, "- [ ] A") { + t.Error("expected checkbox A to be checked") + } + if !strings.Contains(result, "- [x] A") { + t.Error("expected checkbox A to be [x]") + } + // US-002 should be untouched + if !strings.Contains(result, "- [ ] C") { + t.Error("US-002 checkboxes should be untouched") + } +} + +func TestSetStoryStatusInString_MissingStatusLine(t *testing.T) { + md := `# P + +### US-001: First +- [ ] A +` + result, err := setStoryStatusInString(md, "US-001", "in-progress") + if err != nil { + t.Fatalf("error = %v", err) + } + + if !strings.Contains(result, "**Status:** in-progress") { + t.Error("expected **Status:** in-progress to be inserted") + } + + // Status line should appear after the heading + lines := strings.Split(result, "\n") + for i, line := range lines { + if strings.Contains(line, "### US-001") { + if i+1 >= len(lines) || !strings.Contains(lines[i+1], "**Status:** in-progress") { + t.Error("status line should be directly after heading") + } + break + } + } +} + +func TestSetStoryStatusInString_CheckboxFlipping(t *testing.T) { + md := `# P + +### US-001: First +**Status:** in-progress +- [ ] Unchecked A +- [x] Already checked B +- [ ] Unchecked C +` + result, err := setStoryStatusInString(md, "US-001", "done") + if err != nil { + t.Fatalf("error = %v", err) + } + + if strings.Contains(result, "- [ ] Unchecked A") { + t.Error("checkbox A should be checked") + } + if !strings.Contains(result, "- [x] Unchecked A") { + t.Error("expected [x] Unchecked A") + } + if !strings.Contains(result, "- [x] Already checked B") { + t.Error("already checked B should remain checked") + } + if !strings.Contains(result, "- [x] Unchecked C") { + t.Error("checkbox C should be checked") + } +} + +func TestSetStoryStatusInString_MultiStory(t *testing.T) { + md := `# P + +### US-001: First +**Status:** todo +- [ ] A + +### US-002: Second +**Status:** todo +- [ ] B + +### US-003: Third +- [ ] C +` + // Mark US-002 as done + result, err := setStoryStatusInString(md, "US-002", "done") + if err != nil { + t.Fatalf("error = %v", err) + } + + // US-001 should be unchanged + if !strings.Contains(result, "- [ ] A") { + t.Error("US-001 checkboxes should be untouched") + } + // US-003 should be unchanged + if !strings.Contains(result, "- [ ] C") { + t.Error("US-003 checkboxes should be untouched") + } + // US-002 should be done with checked boxes + if !strings.Contains(result, "- [x] B") { + t.Error("US-002 checkbox should be checked") + } +} + +func TestSetStoryStatusInString_StoryNotFound(t *testing.T) { + md := `# P + +### US-001: First +- [ ] A +` + _, err := setStoryStatusInString(md, "US-999", "done") + if err == nil { + t.Error("expected error for missing story") + } +} + +func TestSetStoryStatus_File(t *testing.T) { + tmpDir := t.TempDir() + prdPath := filepath.Join(tmpDir, "prd.md") + + md := `# P + +### US-001: First +- [ ] A +` + if err := os.WriteFile(prdPath, []byte(md), 0644); err != nil { + t.Fatalf("failed to write: %v", err) + } + + if err := SetStoryStatus(prdPath, "US-001", "done"); err != nil { + t.Fatalf("SetStoryStatus() error = %v", err) + } + + data, err := os.ReadFile(prdPath) + if err != nil { + t.Fatalf("failed to read: %v", err) + } + + result := string(data) + if !strings.Contains(result, "**Status:** done") { + t.Error("expected **Status:** done in file") + } + if !strings.Contains(result, "- [x] A") { + t.Error("expected checkbox to be checked") + } +} + +func TestSetStoryStatusInString_NoCheckboxFlipForNonDone(t *testing.T) { + md := `# P + +### US-001: First +- [ ] A +` + result, err := setStoryStatusInString(md, "US-001", "in-progress") + if err != nil { + t.Fatalf("error = %v", err) + } + + // Checkboxes should NOT be flipped for in-progress + if !strings.Contains(result, "- [ ] A") { + t.Error("checkboxes should not be flipped for non-done status") + } +} + +func TestSetStoryStatusInString_RoundTrip(t *testing.T) { + md := `# My Project + +A description. + +### US-001: First +**Status:** todo +- [ ] A +- [ ] B + +### US-002: Second +- [ ] C +` + // Set US-001 to in-progress + result, err := setStoryStatusInString(md, "US-001", "in-progress") + if err != nil { + t.Fatalf("error = %v", err) + } + + // Parse and verify + p, err := ParseMarkdownPRDFromString(result) + if err != nil { + t.Fatalf("parse error = %v", err) + } + if !p.UserStories[0].InProgress { + t.Error("US-001 should be in-progress") + } + if p.UserStories[0].Passes { + t.Error("US-001 should not be passes") + } + + // Now set US-001 to done + result, err = setStoryStatusInString(result, "US-001", "done") + if err != nil { + t.Fatalf("error = %v", err) + } + + // Parse and verify + p, err = ParseMarkdownPRDFromString(result) + if err != nil { + t.Fatalf("parse error = %v", err) + } + if !p.UserStories[0].Passes { + t.Error("US-001 should be passes") + } + if p.UserStories[0].InProgress { + t.Error("US-001 should not be in-progress") + } +} diff --git a/internal/prd/migrate.go b/internal/prd/migrate.go new file mode 100644 index 00000000..6dd5ad9e --- /dev/null +++ b/internal/prd/migrate.go @@ -0,0 +1,53 @@ +package prd + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" +) + +// MigrateFromJSON reads prd.json, transfers story statuses into prd.md +// using SetStoryStatus, and renames prd.json to prd.json.bak. +func MigrateFromJSON(prdDir string) error { + jsonPath := filepath.Join(prdDir, "prd.json") + mdPath := filepath.Join(prdDir, "prd.md") + + // Read and parse prd.json + data, err := os.ReadFile(jsonPath) + if err != nil { + return fmt.Errorf("failed to read prd.json: %w", err) + } + + var p PRD + if err := json.Unmarshal(data, &p); err != nil { + return fmt.Errorf("failed to parse prd.json: %w", err) + } + + // Check that prd.md exists + if _, err := os.Stat(mdPath); err != nil { + return fmt.Errorf("prd.md not found: %w", err) + } + + // Transfer statuses + for _, story := range p.UserStories { + if story.Passes { + if err := SetStoryStatus(mdPath, story.ID, "done"); err != nil { + // Non-fatal: story might not exist in prd.md (was removed) + continue + } + } else if story.InProgress { + if err := SetStoryStatus(mdPath, story.ID, "in-progress"); err != nil { + continue + } + } + } + + // Rename prd.json → prd.json.bak + bakPath := filepath.Join(prdDir, "prd.json.bak") + if err := os.Rename(jsonPath, bakPath); err != nil { + return fmt.Errorf("failed to rename prd.json to prd.json.bak: %w", err) + } + + return nil +} diff --git a/internal/prd/migrate_test.go b/internal/prd/migrate_test.go new file mode 100644 index 00000000..055748ac --- /dev/null +++ b/internal/prd/migrate_test.go @@ -0,0 +1,153 @@ +package prd + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestMigrateFromJSON(t *testing.T) { + tmpDir := t.TempDir() + + // Create prd.json with some statuses + jsonContent := `{ + "project": "Test", + "description": "A test", + "userStories": [ + {"id": "US-001", "title": "Done Story", "passes": true, "priority": 1}, + {"id": "US-002", "title": "In Progress Story", "passes": false, "inProgress": true, "priority": 2}, + {"id": "US-003", "title": "Pending Story", "passes": false, "priority": 3} + ] +}` + if err := os.WriteFile(filepath.Join(tmpDir, "prd.json"), []byte(jsonContent), 0644); err != nil { + t.Fatalf("failed to write prd.json: %v", err) + } + + // Create prd.md with the same stories + mdContent := `# Test + +### US-001: Done Story +- [ ] A + +### US-002: In Progress Story +- [ ] B + +### US-003: Pending Story +- [ ] C +` + if err := os.WriteFile(filepath.Join(tmpDir, "prd.md"), []byte(mdContent), 0644); err != nil { + t.Fatalf("failed to write prd.md: %v", err) + } + + // Run migration + if err := MigrateFromJSON(tmpDir); err != nil { + t.Fatalf("MigrateFromJSON() error = %v", err) + } + + // Verify prd.json is renamed to prd.json.bak + if _, err := os.Stat(filepath.Join(tmpDir, "prd.json")); !os.IsNotExist(err) { + t.Error("prd.json should be renamed") + } + if _, err := os.Stat(filepath.Join(tmpDir, "prd.json.bak")); err != nil { + t.Error("prd.json.bak should exist") + } + + // Verify prd.md has the correct statuses + data, err := os.ReadFile(filepath.Join(tmpDir, "prd.md")) + if err != nil { + t.Fatalf("failed to read prd.md: %v", err) + } + result := string(data) + + // US-001 should be done with checked boxes + if !strings.Contains(result, "- [x] A") { + t.Error("US-001 should have checked checkbox") + } + + // Parse and verify + p, err := ParseMarkdownPRD(filepath.Join(tmpDir, "prd.md")) + if err != nil { + t.Fatalf("ParseMarkdownPRD() error = %v", err) + } + + if !p.UserStories[0].Passes { + t.Error("US-001 should be passes") + } + if !p.UserStories[1].InProgress { + t.Error("US-002 should be in-progress") + } + if p.UserStories[2].Passes || p.UserStories[2].InProgress { + t.Error("US-003 should be pending") + } +} + +func TestMigrateFromJSON_MissingStoryInMd(t *testing.T) { + tmpDir := t.TempDir() + + // prd.json has a story that doesn't exist in prd.md + jsonContent := `{ + "project": "Test", + "userStories": [ + {"id": "US-001", "title": "Exists", "passes": true, "priority": 1}, + {"id": "US-999", "title": "Missing", "passes": true, "priority": 2} + ] +}` + if err := os.WriteFile(filepath.Join(tmpDir, "prd.json"), []byte(jsonContent), 0644); err != nil { + t.Fatalf("failed to write: %v", err) + } + + mdContent := `# Test + +### US-001: Exists +- [ ] A +` + if err := os.WriteFile(filepath.Join(tmpDir, "prd.md"), []byte(mdContent), 0644); err != nil { + t.Fatalf("failed to write: %v", err) + } + + // Should not error — missing stories are skipped + if err := MigrateFromJSON(tmpDir); err != nil { + t.Fatalf("MigrateFromJSON() error = %v", err) + } + + // Verify the existing story was migrated + p, err := ParseMarkdownPRD(filepath.Join(tmpDir, "prd.md")) + if err != nil { + t.Fatalf("parse error = %v", err) + } + if !p.UserStories[0].Passes { + t.Error("US-001 should be passes") + } +} + +func TestMigrateFromJSON_NoJsonFile(t *testing.T) { + tmpDir := t.TempDir() + + mdContent := `# Test +### US-001: First +- [ ] A +` + if err := os.WriteFile(filepath.Join(tmpDir, "prd.md"), []byte(mdContent), 0644); err != nil { + t.Fatalf("failed to write: %v", err) + } + + err := MigrateFromJSON(tmpDir) + if err == nil { + t.Error("expected error when prd.json doesn't exist") + } +} + +func TestMigrateFromJSON_NoMdFile(t *testing.T) { + tmpDir := t.TempDir() + + jsonContent := `{"project": "Test", "userStories": [{"id": "US-001", "passes": true, "priority": 1}]}` + if err := os.WriteFile(filepath.Join(tmpDir, "prd.json"), []byte(jsonContent), 0644); err != nil { + t.Fatalf("failed to write: %v", err) + } + + err := MigrateFromJSON(tmpDir) + if err == nil { + t.Error("expected error when prd.md doesn't exist") + } +} diff --git a/internal/prd/prd_test.go b/internal/prd/prd_test.go index b196e457..0deeda70 100644 --- a/internal/prd/prd_test.go +++ b/internal/prd/prd_test.go @@ -9,26 +9,22 @@ import ( ) func TestLoadPRD(t *testing.T) { - // Create a temp file with valid PRD JSON + // Create a temp file with valid PRD markdown tmpDir := t.TempDir() - prdPath := filepath.Join(tmpDir, "prd.json") + prdPath := filepath.Join(tmpDir, "prd.md") - validJSON := `{ - "project": "Test Project", - "description": "A test PRD", - "userStories": [ - { - "id": "US-001", - "title": "First Story", - "description": "Test description", - "acceptanceCriteria": ["AC1", "AC2"], - "priority": 1, - "passes": false - } - ] - }` - - if err := os.WriteFile(prdPath, []byte(validJSON), 0644); err != nil { + validMd := `# Test Project + +A test PRD + +### US-001: First Story +**Description:** Test description + +- [ ] AC1 +- [ ] AC2 +` + + if err := os.WriteFile(prdPath, []byte(validMd), 0644); err != nil { t.Fatalf("failed to write test file: %v", err) } @@ -52,66 +48,12 @@ func TestLoadPRD(t *testing.T) { } func TestLoadPRD_FileNotFound(t *testing.T) { - _, err := LoadPRD("/nonexistent/path/prd.json") + _, err := LoadPRD("/nonexistent/path/prd.md") if err == nil { t.Error("expected error for nonexistent file, got nil") } } -func TestLoadPRD_InvalidJSON(t *testing.T) { - tmpDir := t.TempDir() - prdPath := filepath.Join(tmpDir, "prd.json") - - if err := os.WriteFile(prdPath, []byte("not valid json"), 0644); err != nil { - t.Fatalf("failed to write test file: %v", err) - } - - _, err := LoadPRD(prdPath) - if err == nil { - t.Error("expected error for invalid JSON, got nil") - } -} - -func TestPRD_Save(t *testing.T) { - tmpDir := t.TempDir() - prdPath := filepath.Join(tmpDir, "prd.json") - - p := &PRD{ - Project: "Saved Project", - Description: "A saved PRD", - UserStories: []UserStory{ - { - ID: "US-001", - Title: "Test Story", - Description: "Test", - AcceptanceCriteria: []string{"AC1"}, - Priority: 1, - Passes: true, - }, - }, - } - - if err := p.Save(prdPath); err != nil { - t.Fatalf("Save failed: %v", err) - } - - // Verify by loading it back - loaded, err := LoadPRD(prdPath) - if err != nil { - t.Fatalf("LoadPRD after Save failed: %v", err) - } - - if loaded.Project != p.Project { - t.Errorf("expected project '%s', got '%s'", p.Project, loaded.Project) - } - if len(loaded.UserStories) != 1 { - t.Errorf("expected 1 user story, got %d", len(loaded.UserStories)) - } - if !loaded.UserStories[0].Passes { - t.Error("expected story to have passes: true") - } -} - func TestPRD_AllComplete_EmptyPRD(t *testing.T) { p := &PRD{ Project: "Empty", @@ -238,7 +180,6 @@ func TestPRD_NextStory_SkipsCompleted(t *testing.T) { } func TestPRD_NextStory_InterruptedTakesPrecedence(t *testing.T) { - // Even if there's a lower priority story, in-progress takes precedence p := &PRD{ Project: "Test", UserStories: []UserStory{ @@ -275,37 +216,6 @@ func TestUserStory_Fields(t *testing.T) { } } -func TestPRD_Save_PreservesInProgress(t *testing.T) { - tmpDir := t.TempDir() - prdPath := filepath.Join(tmpDir, "prd.json") - - p := &PRD{ - Project: "Test", - UserStories: []UserStory{ - { - ID: "US-001", - Title: "Story", - Priority: 1, - Passes: false, - InProgress: true, - }, - }, - } - - if err := p.Save(prdPath); err != nil { - t.Fatalf("Save failed: %v", err) - } - - loaded, err := LoadPRD(prdPath) - if err != nil { - t.Fatalf("LoadPRD failed: %v", err) - } - - if !loaded.UserStories[0].InProgress { - t.Error("expected InProgress to be preserved as true") - } -} - func TestPRD_NextStoryContext_ReturnsHighestPriority(t *testing.T) { p := &PRD{ Project: "Test", @@ -321,7 +231,6 @@ func TestPRD_NextStoryContext_ReturnsHighestPriority(t *testing.T) { t.Fatal("expected non-nil context") } - // Parse the JSON to verify it's the highest-priority story var story UserStory if err := json.Unmarshal([]byte(*ctx), &story); err != nil { t.Fatalf("failed to parse story context JSON: %v", err) @@ -417,7 +326,6 @@ func TestPRD_NextStoryContext_ValidJSON(t *testing.T) { } func TestPRD_NextStoryContext_PromptSizeUnder10KB(t *testing.T) { - // Create a 300-story PRD to verify the context stays small stories := make([]UserStory, 300) for i := range stories { stories[i] = UserStory{ @@ -426,7 +334,7 @@ func TestPRD_NextStoryContext_PromptSizeUnder10KB(t *testing.T) { Description: "This is a description that is moderately long to simulate realistic PRD content for testing purposes.", AcceptanceCriteria: []string{"Criterion A", "Criterion B", "Criterion C"}, Priority: i + 1, - Passes: i > 0, // Only first story is pending + Passes: i > 0, } } p := &PRD{ @@ -491,59 +399,3 @@ func TestPRD_ExtractIDPrefix_SingleChar(t *testing.T) { t.Errorf("ExtractIDPrefix() = %q, want %q", got, "T") } } - -func TestCountMarkdownStories(t *testing.T) { - tests := []struct { - name string - content string - expected int - }{ - { - name: "typical PRD with stories", - content: "# My Project\n\nOverview text.\n\n## Story One\n\nDetails.\n\n## Story Two\n\nMore details.\n\n## Story Three\n\nEven more.", - expected: 3, - }, - { - name: "no stories", - content: "# My Project\n\nJust an overview with no stories.", - expected: 0, - }, - { - name: "empty content", - content: "", - expected: 0, - }, - { - name: "nested headings not counted", - content: "# Project\n\n## Story One\n\n### Sub-section\n\n## Story Two\n\n### Another sub-section", - expected: 2, - }, - { - name: "heading without space not counted", - content: "# Project\n\n##NotAStory\n\n## Real Story", - expected: 1, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := CountMarkdownStories(tt.content) - if got != tt.expected { - t.Errorf("CountMarkdownStories() = %d, want %d", got, tt.expected) - } - }) - } -} - -func TestCountMarkdownStories_LargePRD(t *testing.T) { - // Simulate a large PRD with 100 stories - var content string - content = "# Large Project\n\nA big project.\n\n" - for i := 1; i <= 100; i++ { - content += fmt.Sprintf("## Story %d\n\nDescription for story %d.\n\n", i, i) - } - got := CountMarkdownStories(content) - if got != 100 { - t.Errorf("CountMarkdownStories() = %d, want 100", got) - } -} diff --git a/internal/prd/watcher.go b/internal/prd/watcher.go index 001397cc..09e2b2f5 100644 --- a/internal/prd/watcher.go +++ b/internal/prd/watcher.go @@ -110,7 +110,7 @@ func (w *Watcher) processEvents() { // Handle file removal - try to re-watch if event.Op&fsnotify.Remove != 0 { - w.events <- WatcherEvent{Error: errors.New("prd.json was removed")} + w.events <- WatcherEvent{Error: errors.New("prd.md was removed")} // Try to re-add the watch (file might be re-created) _ = w.watcher.Add(w.path) } diff --git a/internal/prd/watcher_test.go b/internal/prd/watcher_test.go index 5c7ca8b6..36d715f7 100644 --- a/internal/prd/watcher_test.go +++ b/internal/prd/watcher_test.go @@ -1,28 +1,42 @@ package prd import ( - "encoding/json" "os" "path/filepath" "testing" "time" ) -func TestNewWatcher(t *testing.T) { - tmpDir := t.TempDir() - prdPath := filepath.Join(tmpDir, "prd.json") - - // Create a test PRD file - testPRD := &PRD{ - Project: "Test", - UserStories: []UserStory{ - {ID: "US-001", Title: "Test Story", Passes: false}, - }, +// createTestPRDMd creates a markdown PRD file for testing. +func createTestPRDMd(t *testing.T, dir string, stories []UserStory) string { + t.Helper() + prdPath := filepath.Join(dir, "prd.md") + + md := "# Test\n\n" + for _, s := range stories { + md += "### " + s.ID + ": " + s.Title + "\n" + if s.Passes { + md += "**Status:** done\n" + } else if s.InProgress { + md += "**Status:** in-progress\n" + } + if s.Description != "" { + md += "**Description:** " + s.Description + "\n" + } + md += "- [ ] criterion\n\n" } - data, _ := json.Marshal(testPRD) - if err := os.WriteFile(prdPath, data, 0644); err != nil { + + if err := os.WriteFile(prdPath, []byte(md), 0644); err != nil { t.Fatalf("Failed to write test PRD: %v", err) } + return prdPath +} + +func TestNewWatcher(t *testing.T) { + tmpDir := t.TempDir() + prdPath := createTestPRDMd(t, tmpDir, []UserStory{ + {ID: "US-001", Title: "Test Story", Passes: false}, + }) watcher, err := NewWatcher(prdPath) if err != nil { @@ -37,19 +51,9 @@ func TestNewWatcher(t *testing.T) { func TestWatcherStart(t *testing.T) { tmpDir := t.TempDir() - prdPath := filepath.Join(tmpDir, "prd.json") - - // Create a test PRD file - testPRD := &PRD{ - Project: "Test", - UserStories: []UserStory{ - {ID: "US-001", Title: "Test Story", Passes: false}, - }, - } - data, _ := json.Marshal(testPRD) - if err := os.WriteFile(prdPath, data, 0644); err != nil { - t.Fatalf("Failed to write test PRD: %v", err) - } + prdPath := createTestPRDMd(t, tmpDir, []UserStory{ + {ID: "US-001", Title: "Test Story", Passes: false}, + }) watcher, err := NewWatcher(prdPath) if err != nil { @@ -69,19 +73,9 @@ func TestWatcherStart(t *testing.T) { func TestWatcherDetectsFileChange(t *testing.T) { tmpDir := t.TempDir() - prdPath := filepath.Join(tmpDir, "prd.json") - - // Create a test PRD file - testPRD := &PRD{ - Project: "Test", - UserStories: []UserStory{ - {ID: "US-001", Title: "Test Story", Passes: false}, - }, - } - data, _ := json.Marshal(testPRD) - if err := os.WriteFile(prdPath, data, 0644); err != nil { - t.Fatalf("Failed to write test PRD: %v", err) - } + prdPath := createTestPRDMd(t, tmpDir, []UserStory{ + {ID: "US-001", Title: "Test Story", Passes: false}, + }) watcher, err := NewWatcher(prdPath) if err != nil { @@ -93,17 +87,13 @@ func TestWatcherDetectsFileChange(t *testing.T) { t.Fatalf("Failed to start watcher: %v", err) } - // Give watcher time to initialize time.Sleep(100 * time.Millisecond) // Modify the file - change passes status - testPRD.UserStories[0].Passes = true - data, _ = json.Marshal(testPRD) - if err := os.WriteFile(prdPath, data, 0644); err != nil { + if err := SetStoryStatus(prdPath, "US-001", "done"); err != nil { t.Fatalf("Failed to update test PRD: %v", err) } - // Wait for the event select { case event := <-watcher.Events(): if event.Error != nil { @@ -122,19 +112,9 @@ func TestWatcherDetectsFileChange(t *testing.T) { func TestWatcherDetectsInProgressChange(t *testing.T) { tmpDir := t.TempDir() - prdPath := filepath.Join(tmpDir, "prd.json") - - // Create a test PRD file - testPRD := &PRD{ - Project: "Test", - UserStories: []UserStory{ - {ID: "US-001", Title: "Test Story", Passes: false, InProgress: false}, - }, - } - data, _ := json.Marshal(testPRD) - if err := os.WriteFile(prdPath, data, 0644); err != nil { - t.Fatalf("Failed to write test PRD: %v", err) - } + prdPath := createTestPRDMd(t, tmpDir, []UserStory{ + {ID: "US-001", Title: "Test Story", Passes: false, InProgress: false}, + }) watcher, err := NewWatcher(prdPath) if err != nil { @@ -146,17 +126,13 @@ func TestWatcherDetectsInProgressChange(t *testing.T) { t.Fatalf("Failed to start watcher: %v", err) } - // Give watcher time to initialize time.Sleep(100 * time.Millisecond) // Modify the file - change inProgress status - testPRD.UserStories[0].InProgress = true - data, _ = json.Marshal(testPRD) - if err := os.WriteFile(prdPath, data, 0644); err != nil { + if err := SetStoryStatus(prdPath, "US-001", "in-progress"); err != nil { t.Fatalf("Failed to update test PRD: %v", err) } - // Wait for the event select { case event := <-watcher.Events(): if event.Error != nil { @@ -175,7 +151,7 @@ func TestWatcherDetectsInProgressChange(t *testing.T) { func TestWatcherHandlesFileNotFound(t *testing.T) { tmpDir := t.TempDir() - prdPath := filepath.Join(tmpDir, "nonexistent.json") + prdPath := filepath.Join(tmpDir, "nonexistent.md") watcher, err := NewWatcher(prdPath) if err != nil { @@ -183,89 +159,26 @@ func TestWatcherHandlesFileNotFound(t *testing.T) { } defer watcher.Stop() - // Start should still work, but we'll get an error event if err := watcher.Start(); err != nil { - // This is expected since the file doesn't exist - // But the watcher.Add might fail first - // Let's check that events channel has an error t.Logf("Got expected start error: %v", err) return } - // If start succeeded, check for error event select { case event := <-watcher.Events(): if event.Error == nil { t.Error("Expected error event for nonexistent file") } case <-time.After(1 * time.Second): - // Might not get event if watcher.Add failed t.Log("No error event received, which is acceptable if Add failed") } } -func TestWatcherIgnoresNonStatusChanges(t *testing.T) { - tmpDir := t.TempDir() - prdPath := filepath.Join(tmpDir, "prd.json") - - // Create a test PRD file - testPRD := &PRD{ - Project: "Test", - UserStories: []UserStory{ - {ID: "US-001", Title: "Test Story", Description: "Original", Passes: false}, - }, - } - data, _ := json.Marshal(testPRD) - if err := os.WriteFile(prdPath, data, 0644); err != nil { - t.Fatalf("Failed to write test PRD: %v", err) - } - - watcher, err := NewWatcher(prdPath) - if err != nil { - t.Fatalf("Failed to create watcher: %v", err) - } - defer watcher.Stop() - - if err := watcher.Start(); err != nil { - t.Fatalf("Failed to start watcher: %v", err) - } - - // Give watcher time to initialize - time.Sleep(100 * time.Millisecond) - - // Modify the file - only change description (not status) - testPRD.UserStories[0].Description = "Modified" - data, _ = json.Marshal(testPRD) - if err := os.WriteFile(prdPath, data, 0644); err != nil { - t.Fatalf("Failed to update test PRD: %v", err) - } - - // Should NOT receive an event since status didn't change - select { - case event := <-watcher.Events(): - if event.PRD != nil { - t.Error("Did not expect PRD event for non-status change") - } - case <-time.After(500 * time.Millisecond): - // Expected - no event for non-status changes - } -} - func TestWatcherStop(t *testing.T) { tmpDir := t.TempDir() - prdPath := filepath.Join(tmpDir, "prd.json") - - // Create a test PRD file - testPRD := &PRD{ - Project: "Test", - UserStories: []UserStory{ - {ID: "US-001", Title: "Test Story", Passes: false}, - }, - } - data, _ := json.Marshal(testPRD) - if err := os.WriteFile(prdPath, data, 0644); err != nil { - t.Fatalf("Failed to write test PRD: %v", err) - } + prdPath := createTestPRDMd(t, tmpDir, []UserStory{ + {ID: "US-001", Title: "Test Story", Passes: false}, + }) watcher, err := NewWatcher(prdPath) if err != nil { @@ -276,11 +189,8 @@ func TestWatcherStop(t *testing.T) { t.Fatalf("Failed to start watcher: %v", err) } - // Stop should not panic or hang - watcher.Stop() - - // Stopping again should be safe watcher.Stop() + watcher.Stop() // Should be safe } func TestHasStatusChanged(t *testing.T) { diff --git a/internal/tui/app.go b/internal/tui/app.go index 8fc6f98c..6d7bf957 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -945,14 +945,11 @@ func (a App) handleLoopEvent(prdName string, event loop.Event) (tea.Model, tea.C if isCurrentPRD { a.lastActivity = "Tool completed" } - case loop.EventStoryStarted: + case loop.EventStoryDone: if isCurrentPRD { - a.lastActivity = "Working on: " + event.StoryID - // Finalize previous story timing + a.lastActivity = "Story done" + // Finalize story timing a.finalizeStoryTiming() - // Start tracking the new story - a.currentStoryID = event.StoryID - a.currentStoryStart = time.Now() } case loop.EventComplete: if isCurrentPRD { @@ -995,18 +992,12 @@ func (a App) handleLoopEvent(prdName string, event loop.Event) (tea.Model, tea.C // Reload PRD from disk only on meaningful state changes (not every event) if isCurrentPRD { switch event.Type { - case loop.EventStoryStarted, loop.EventComplete, loop.EventError, loop.EventMaxIterationsReached: + case loop.EventStoryDone, loop.EventComplete, loop.EventError, loop.EventMaxIterationsReached: if p, err := prd.LoadPRD(a.prdPath); err == nil { a.prd = p } } - // Mark the story as in-progress in the PRD and auto-select it - if event.Type == loop.EventStoryStarted && event.StoryID != "" { - a.markStoryInProgress(event.StoryID) - a.selectStoryByID(event.StoryID) - } - // Clear in-progress when the PRD completes or the loop stops if event.Type == loop.EventComplete || event.Type == loop.EventError || event.Type == loop.EventMaxIterationsReached { a.clearInProgress() @@ -2166,25 +2157,28 @@ func (a *App) adjustStoriesScroll() { } // markStoryInProgress clears any existing in-progress flags and marks the -// given story as in-progress, then saves the PRD to disk. +// given story as in-progress, then reloads the PRD from disk. func (a *App) markStoryInProgress(storyID string) { - for i := range a.prd.UserStories { - a.prd.UserStories[i].InProgress = a.prd.UserStories[i].ID == storyID + _ = prd.SetStoryStatus(a.prdPath, storyID, "in-progress") + if p, err := prd.LoadPRD(a.prdPath); err == nil { + a.prd = p } - _ = a.prd.Save(a.prdPath) } -// clearInProgress clears all in-progress flags and saves the PRD to disk. +// clearInProgress clears all in-progress flags by setting each in-progress +// story's status to "todo" in the markdown file, then reloads. func (a *App) clearInProgress() { dirty := false - for i := range a.prd.UserStories { - if a.prd.UserStories[i].InProgress { - a.prd.UserStories[i].InProgress = false + for _, story := range a.prd.UserStories { + if story.InProgress { + _ = prd.SetStoryStatus(a.prdPath, story.ID, "todo") dirty = true } } if dirty { - _ = a.prd.Save(a.prdPath) + if p, err := prd.LoadPRD(a.prdPath); err == nil { + a.prd = p + } } } diff --git a/internal/tui/log.go b/internal/tui/log.go index 3ff08bec..7cbeecf9 100644 --- a/internal/tui/log.go +++ b/internal/tui/log.go @@ -76,7 +76,7 @@ func (l *LogViewer) AddEvent(event loop.Event) { // Filter out events we don't want to display switch event.Type { case loop.EventAssistantText, loop.EventToolStart, loop.EventToolResult, - loop.EventStoryStarted, loop.EventComplete, loop.EventError, loop.EventRetrying, + loop.EventStoryDone, loop.EventComplete, loop.EventError, loop.EventRetrying, loop.EventWatchdogTimeout: // Pre-render and cache lines if l.width > 0 { @@ -354,8 +354,8 @@ func (l *LogViewer) renderEntry(entry LogEntry) []string { return l.renderToolCard(entry) case loop.EventToolResult: return l.renderToolResult(entry) - case loop.EventStoryStarted: - return l.renderStoryStarted(entry) + case loop.EventStoryDone: + return l.renderStoryDone(entry) case loop.EventComplete: return l.renderComplete(entry) case loop.EventError: @@ -554,20 +554,20 @@ func stripLineNumbers(code string) string { return strings.Join(result, "\n") } -// renderStoryStarted renders a story started marker. -func (l *LogViewer) renderStoryStarted(entry LogEntry) []string { +// renderStoryDone renders a story done marker. +func (l *LogViewer) renderStoryDone(entry LogEntry) []string { storyStyle := lipgloss.NewStyle(). - Foreground(PrimaryColor). + Foreground(SuccessColor). Bold(true). Padding(0, 1) - dividerStyle := lipgloss.NewStyle().Foreground(PrimaryColor) + dividerStyle := lipgloss.NewStyle().Foreground(SuccessColor) divider := dividerStyle.Render(strings.Repeat("─", l.width-4)) return []string{ "", divider, - storyStyle.Render(fmt.Sprintf("▶ Working on: %s", entry.StoryID)), + storyStyle.Render("✓ Story done"), divider, "", } diff --git a/internal/tui/log_perf_test.go b/internal/tui/log_perf_test.go index bf5eb18d..73f9fd77 100644 --- a/internal/tui/log_perf_test.go +++ b/internal/tui/log_perf_test.go @@ -27,7 +27,7 @@ func makeToolResultEvent(text string) loop.Event { // makeStoryEvent creates a story started event. func makeStoryEvent(storyID string) loop.Event { - return loop.Event{Type: loop.EventStoryStarted, StoryID: storyID} + return loop.Event{Type: loop.EventStoryDone, StoryID: storyID} } // --- AddEvent caching tests --- diff --git a/internal/tui/picker.go b/internal/tui/picker.go index 63923568..dd1e4032 100644 --- a/internal/tui/picker.go +++ b/internal/tui/picker.go @@ -118,12 +118,10 @@ func (p *PRDPicker) Refresh() { name := entry.Name() dirPath := filepath.Join(prdsDir, name) - prdPath := filepath.Join(dirPath, "prd.json") + prdPath := filepath.Join(dirPath, "prd.md") - // Skip directories without prd.md or prd.json (empty/incomplete) - _, jsonErr := os.Stat(prdPath) - _, mdErr := os.Stat(filepath.Join(dirPath, "prd.md")) - if os.IsNotExist(jsonErr) && os.IsNotExist(mdErr) { + // Skip directories without prd.md (empty/incomplete) + if _, err := os.Stat(prdPath); os.IsNotExist(err) { continue } @@ -133,7 +131,7 @@ func (p *PRDPicker) Refresh() { } // Also check if there's a "main" PRD directly in .chief/ (legacy location) - mainPrdPath := filepath.Join(p.basePath, ".chief", "prd.json") + mainPrdPath := filepath.Join(p.basePath, ".chief", "prd.md") if _, err := os.Stat(mainPrdPath); err == nil && !addedNames["main"] { prdEntry := p.loadPRDEntry("main", mainPrdPath) p.entries = append(p.entries, prdEntry) @@ -175,11 +173,11 @@ func (p *PRDPicker) Refresh() { if !found { p.entries = append(p.entries, PRDEntry{ Name: prdName, - Path: filepath.Join(p.basePath, ".chief", "prds", prdName, "prd.json"), + Path: filepath.Join(p.basePath, ".chief", "prds", prdName, "prd.md"), LoopState: loop.LoopStateReady, WorktreeDir: absPath, Orphaned: true, - LoadError: fmt.Errorf("orphaned worktree (no prd.json)"), + LoadError: fmt.Errorf("orphaned worktree (no prd.md)"), }) } } diff --git a/internal/tui/picker_test.go b/internal/tui/picker_test.go index 90182fed..359ae14e 100644 --- a/internal/tui/picker_test.go +++ b/internal/tui/picker_test.go @@ -896,11 +896,11 @@ func TestRefreshShowsDirectoryWithPrdMdOnly(t *testing.T) { } } -func TestRefreshShowsDirectoryWithPrdJsonOnly(t *testing.T) { +func TestRefreshSkipsDirectoryWithOnlyPrdJson(t *testing.T) { tmpDir := t.TempDir() prdsDir := filepath.Join(tmpDir, ".chief", "prds") - // Create directory with only prd.json + // Create directory with only prd.json (no prd.md) — should be skipped prdDir := filepath.Join(prdsDir, "converted") if err := os.MkdirAll(prdDir, 0755); err != nil { t.Fatalf("Failed to create dir: %v", err) @@ -916,11 +916,8 @@ func TestRefreshShowsDirectoryWithPrdJsonOnly(t *testing.T) { } p.Refresh() - if len(p.entries) != 1 { - t.Fatalf("expected 1 entry for directory with prd.json, got %d", len(p.entries)) - } - if p.entries[0].Name != "converted" { - t.Errorf("expected entry name 'converted', got %q", p.entries[0].Name) + if len(p.entries) != 0 { + t.Fatalf("expected 0 entries for directory with only prd.json (no prd.md), got %d", len(p.entries)) } } diff --git a/internal/tui/tabbar.go b/internal/tui/tabbar.go index a5627154..8964f72b 100644 --- a/internal/tui/tabbar.go +++ b/internal/tui/tabbar.go @@ -66,7 +66,7 @@ func (t *TabBar) Refresh() { } name := entry.Name() - prdPath := filepath.Join(prdsDir, name, "prd.json") + prdPath := filepath.Join(prdsDir, name, "prd.md") tabEntry := t.loadTabEntry(name, prdPath) t.entries = append(t.entries, tabEntry) @@ -74,7 +74,7 @@ func (t *TabBar) Refresh() { } // Also check if there's a "main" PRD directly in .chief/ (legacy location) - mainPrdPath := filepath.Join(t.baseDir, ".chief", "prd.json") + mainPrdPath := filepath.Join(t.baseDir, ".chief", "prd.md") if _, err := os.Stat(mainPrdPath); err == nil && !addedNames["main"] { tabEntry := t.loadTabEntry("main", mainPrdPath) t.entries = append(t.entries, tabEntry)