Skip to content

Commit d5d0994

Browse files
feat: [US-001] - Fix large PRD exceeding Claude's token limit
Add NextStoryContext() helper that inlines only the current story into the agent prompt instead of having Claude read the entire prd.json file. The prompt is rebuilt on each loop iteration so newly completed stories are automatically skipped. - NextStoryContext() returns JSON of highest-priority uncompleted story - NextStoryContext() returns nil when all stories are complete - prompt.txt uses {{STORY_CONTEXT}} placeholder instead of file-read - Loop rebuilds prompt before each iteration via buildPrompt callback - Single story context stays well under 10KB even for 300-story PRDs
1 parent 68650d5 commit d5d0994

7 files changed

Lines changed: 264 additions & 33 deletions

File tree

embed/embed.go

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,14 @@ var convertPromptTemplate string
2222
//go:embed detect_setup_prompt.txt
2323
var detectSetupPromptTemplate string
2424

25-
// GetPrompt returns the agent prompt with the PRD and progress paths substituted.
26-
func GetPrompt(prdPath, progressPath string) string {
25+
// GetPrompt returns the agent prompt with the PRD path, progress path, and
26+
// current story context substituted. The storyContext is the JSON of the
27+
// current story to work on, inlined directly into the prompt so that the
28+
// agent does not need to read the entire prd.json file.
29+
func GetPrompt(prdPath, progressPath, storyContext string) string {
2730
result := strings.ReplaceAll(promptTemplate, "{{PRD_PATH}}", prdPath)
28-
return strings.ReplaceAll(result, "{{PROGRESS_PATH}}", progressPath)
31+
result = strings.ReplaceAll(result, "{{PROGRESS_PATH}}", progressPath)
32+
return strings.ReplaceAll(result, "{{STORY_CONTEXT}}", storyContext)
2933
}
3034

3135
// GetInitPrompt returns the PRD generator prompt with the PRD directory and optional context substituted.

embed/embed_test.go

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,19 @@ import (
88
func TestGetPrompt(t *testing.T) {
99
prdPath := "/path/to/prd.json"
1010
progressPath := "/path/to/progress.md"
11-
prompt := GetPrompt(prdPath, progressPath)
11+
storyContext := `{"id":"US-001","title":"Test Story"}`
12+
prompt := GetPrompt(prdPath, progressPath, storyContext)
1213

13-
// Verify the PRD path placeholder was substituted
14+
// Verify all placeholders were substituted
1415
if strings.Contains(prompt, "{{PRD_PATH}}") {
1516
t.Error("Expected {{PRD_PATH}} to be substituted")
1617
}
17-
18-
// Verify the progress path placeholder was substituted
1918
if strings.Contains(prompt, "{{PROGRESS_PATH}}") {
2019
t.Error("Expected {{PROGRESS_PATH}} to be substituted")
2120
}
21+
if strings.Contains(prompt, "{{STORY_CONTEXT}}") {
22+
t.Error("Expected {{STORY_CONTEXT}} to be substituted")
23+
}
2224

2325
// Verify the PRD path appears in the prompt
2426
if !strings.Contains(prompt, prdPath) {
@@ -30,6 +32,11 @@ func TestGetPrompt(t *testing.T) {
3032
t.Errorf("Expected prompt to contain progress path %q", progressPath)
3133
}
3234

35+
// Verify the story context is inlined in the prompt
36+
if !strings.Contains(prompt, storyContext) {
37+
t.Error("Expected prompt to contain inlined story context")
38+
}
39+
3340
// Verify the prompt contains key instructions
3441
if !strings.Contains(prompt, "chief-complete") {
3542
t.Error("Expected prompt to contain chief-complete instruction")
@@ -44,6 +51,15 @@ func TestGetPrompt(t *testing.T) {
4451
}
4552
}
4653

54+
func TestGetPrompt_NoFileReadInstruction(t *testing.T) {
55+
prompt := GetPrompt("/path/prd.json", "/path/progress.md", `{"id":"US-001"}`)
56+
57+
// The prompt should NOT instruct Claude to read the PRD file
58+
if strings.Contains(prompt, "Read the PRD") {
59+
t.Error("Expected prompt to NOT contain 'Read the PRD' file-read instruction")
60+
}
61+
}
62+
4763
func TestPromptTemplateNotEmpty(t *testing.T) {
4864
if promptTemplate == "" {
4965
t.Error("Expected promptTemplate to be embedded and non-empty")

embed/prompt.txt

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,18 @@ You are an autonomous coding agent working on a software project.
44

55
## Your Task
66

7-
1. Read the PRD at `{{PRD_PATH}}`
8-
2. Read `{{PROGRESS_PATH}}` if it exists (check Codebase Patterns section first)
9-
3. Pick the **highest priority** user story where `passes: false` -- After determining which story to work on, output exact story id, e.g.: <ralph-status>US-056</ralph-status>
10-
4. Implement that single user story
11-
5. Run quality checks (e.g., typecheck, lint, test - use whatever your project requires)
12-
6. If checks pass, commit ALL changes with message: `feat: [Story ID] - [Story Title]`
13-
7. Update the PRD to set `passes: true` for the completed story
14-
8. Append your progress to `{{PROGRESS_PATH}}`
7+
Your current story:
8+
<story>
9+
{{STORY_CONTEXT}}
10+
</story>
11+
12+
1. Read `{{PROGRESS_PATH}}` if it exists (check Codebase Patterns section first)
13+
2. After determining which story to work on, output exact story id, e.g.: <ralph-status>US-056</ralph-status>
14+
3. Implement the user story above
15+
4. Run quality checks (e.g., typecheck, lint, test - use whatever your project requires)
16+
5. If checks pass, commit ALL changes with message: `feat: [Story ID] - [Story Title]`
17+
6. Update the PRD at `{{PRD_PATH}}` to set `passes: true` for the completed story
18+
7. Append your progress to `{{PROGRESS_PATH}}`
1519

1620
## Progress Report Format
1721

internal/loop/loop.go

Lines changed: 51 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -37,18 +37,19 @@ func DefaultRetryConfig() RetryConfig {
3737

3838
// Loop manages the core agent loop that invokes Claude repeatedly until all stories are complete.
3939
type Loop struct {
40-
prdPath string
41-
workDir string
42-
prompt string
43-
maxIter int
44-
iteration int
45-
events chan Event
46-
claudeCmd *exec.Cmd
47-
logFile *os.File
48-
mu sync.Mutex
49-
stopped bool
50-
paused bool
51-
retryConfig RetryConfig
40+
prdPath string
41+
workDir string
42+
prompt string
43+
buildPrompt func() (string, error) // optional: rebuild prompt each iteration
44+
maxIter int
45+
iteration int
46+
events chan Event
47+
claudeCmd *exec.Cmd
48+
logFile *os.File
49+
mu sync.Mutex
50+
stopped bool
51+
paused bool
52+
retryConfig RetryConfig
5253
}
5354

5455
// NewLoop creates a new Loop instance.
@@ -76,10 +77,30 @@ func NewLoopWithWorkDir(prdPath, workDir string, prompt string, maxIter int) *Lo
7677
}
7778

7879
// NewLoopWithEmbeddedPrompt creates a new Loop instance using the embedded agent prompt.
79-
// The PRD path placeholder in the prompt is automatically substituted.
80+
// The prompt is rebuilt on each iteration to inline the current story context.
8081
func NewLoopWithEmbeddedPrompt(prdPath string, maxIter int) *Loop {
81-
prompt := embed.GetPrompt(prdPath, prd.ProgressPath(prdPath))
82-
return NewLoop(prdPath, prompt, maxIter)
82+
l := NewLoop(prdPath, "", maxIter)
83+
l.buildPrompt = promptBuilderForPRD(prdPath)
84+
return l
85+
}
86+
87+
// promptBuilderForPRD returns a function that loads the PRD and builds a prompt
88+
// with the next story inlined. This is called before each iteration so that
89+
// newly completed stories are skipped.
90+
func promptBuilderForPRD(prdPath string) func() (string, error) {
91+
return func() (string, error) {
92+
p, err := prd.LoadPRD(prdPath)
93+
if err != nil {
94+
return "", fmt.Errorf("failed to load PRD for prompt: %w", err)
95+
}
96+
97+
storyCtx := p.NextStoryContext()
98+
if storyCtx == nil {
99+
return "", fmt.Errorf("all stories are complete")
100+
}
101+
102+
return embed.GetPrompt(prdPath, prd.ProgressPath(prdPath), *storyCtx), nil
103+
}
83104
}
84105

85106
// Events returns the channel for receiving events from the loop.
@@ -130,6 +151,21 @@ func (l *Loop) Run(ctx context.Context) error {
130151
return nil
131152
}
132153

154+
// Rebuild prompt if builder is set (inlines the current story each iteration)
155+
if l.buildPrompt != nil {
156+
prompt, err := l.buildPrompt()
157+
if err != nil {
158+
l.events <- Event{
159+
Type: EventComplete,
160+
Iteration: currentIter,
161+
}
162+
return nil
163+
}
164+
l.mu.Lock()
165+
l.prompt = prompt
166+
l.mu.Unlock()
167+
}
168+
133169
// Send iteration start event
134170
l.events <- Event{
135171
Type: EventIterationStart,

internal/loop/manager.go

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import (
66
"sync"
77
"time"
88

9-
"github.com/minicodemonkey/chief/embed"
109
"github.com/minicodemonkey/chief/internal/config"
1110
"github.com/minicodemonkey/chief/internal/prd"
1211
)
@@ -225,14 +224,14 @@ func (m *Manager) Start(name string) error {
225224
// Create a new loop instance, using worktree-aware constructor if WorktreeDir is set.
226225
// When no worktree is configured, run from the project root (baseDir) so that
227226
// CLAUDE.md and other project-level files are visible to Claude.
228-
prompt := embed.GetPrompt(instance.PRDPath, prd.ProgressPath(instance.PRDPath))
229227
workDir := instance.WorktreeDir
230228
if workDir == "" {
231229
m.mu.RLock()
232230
workDir = m.baseDir
233231
m.mu.RUnlock()
234232
}
235-
instance.Loop = NewLoopWithWorkDir(instance.PRDPath, workDir, prompt, m.maxIter)
233+
instance.Loop = NewLoopWithWorkDir(instance.PRDPath, workDir, "", m.maxIter)
234+
instance.Loop.buildPrompt = promptBuilderForPRD(instance.PRDPath)
236235
m.mu.RLock()
237236
instance.Loop.SetRetryConfig(m.retryConfig)
238237
m.mu.RUnlock()

internal/prd/prd_test.go

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package prd
22

33
import (
4+
"encoding/json"
5+
"fmt"
46
"os"
57
"path/filepath"
68
"testing"
@@ -303,3 +305,141 @@ func TestPRD_Save_PreservesInProgress(t *testing.T) {
303305
t.Error("expected InProgress to be preserved as true")
304306
}
305307
}
308+
309+
func TestPRD_NextStoryContext_ReturnsHighestPriority(t *testing.T) {
310+
p := &PRD{
311+
Project: "Test",
312+
UserStories: []UserStory{
313+
{ID: "US-001", Title: "Low priority", Priority: 3, Passes: false},
314+
{ID: "US-002", Title: "High priority", Priority: 1, Passes: false},
315+
{ID: "US-003", Title: "Mid priority", Priority: 2, Passes: false},
316+
},
317+
}
318+
319+
ctx := p.NextStoryContext()
320+
if ctx == nil {
321+
t.Fatal("expected non-nil context")
322+
}
323+
324+
// Parse the JSON to verify it's the highest-priority story
325+
var story UserStory
326+
if err := json.Unmarshal([]byte(*ctx), &story); err != nil {
327+
t.Fatalf("failed to parse story context JSON: %v", err)
328+
}
329+
if story.ID != "US-002" {
330+
t.Errorf("expected highest-priority story US-002, got %s", story.ID)
331+
}
332+
}
333+
334+
func TestPRD_NextStoryContext_ReturnsNilWhenAllComplete(t *testing.T) {
335+
p := &PRD{
336+
Project: "Test",
337+
UserStories: []UserStory{
338+
{ID: "US-001", Passes: true},
339+
{ID: "US-002", Passes: true},
340+
},
341+
}
342+
343+
ctx := p.NextStoryContext()
344+
if ctx != nil {
345+
t.Errorf("expected nil when all stories complete, got %q", *ctx)
346+
}
347+
}
348+
349+
func TestPRD_NextStoryContext_SkipsPassingStories(t *testing.T) {
350+
p := &PRD{
351+
Project: "Test",
352+
UserStories: []UserStory{
353+
{ID: "US-001", Title: "Done", Priority: 1, Passes: true},
354+
{ID: "US-002", Title: "Pending", Priority: 2, Passes: false},
355+
},
356+
}
357+
358+
ctx := p.NextStoryContext()
359+
if ctx == nil {
360+
t.Fatal("expected non-nil context")
361+
}
362+
363+
var story UserStory
364+
if err := json.Unmarshal([]byte(*ctx), &story); err != nil {
365+
t.Fatalf("failed to parse story context JSON: %v", err)
366+
}
367+
if story.ID != "US-002" {
368+
t.Errorf("expected US-002 (only pending story), got %s", story.ID)
369+
}
370+
}
371+
372+
func TestPRD_NextStoryContext_EmptyPRD(t *testing.T) {
373+
p := &PRD{
374+
Project: "Empty",
375+
UserStories: []UserStory{},
376+
}
377+
378+
ctx := p.NextStoryContext()
379+
if ctx != nil {
380+
t.Errorf("expected nil for empty PRD, got %q", *ctx)
381+
}
382+
}
383+
384+
func TestPRD_NextStoryContext_ValidJSON(t *testing.T) {
385+
p := &PRD{
386+
Project: "Test",
387+
UserStories: []UserStory{
388+
{
389+
ID: "US-001",
390+
Title: "Test Story",
391+
Description: "A test description",
392+
AcceptanceCriteria: []string{"AC1", "AC2"},
393+
Priority: 1,
394+
Passes: false,
395+
},
396+
},
397+
}
398+
399+
ctx := p.NextStoryContext()
400+
if ctx == nil {
401+
t.Fatal("expected non-nil context")
402+
}
403+
404+
var story UserStory
405+
if err := json.Unmarshal([]byte(*ctx), &story); err != nil {
406+
t.Fatalf("NextStoryContext did not return valid JSON: %v", err)
407+
}
408+
if story.ID != "US-001" {
409+
t.Errorf("expected ID US-001, got %s", story.ID)
410+
}
411+
if story.Title != "Test Story" {
412+
t.Errorf("expected title 'Test Story', got '%s'", story.Title)
413+
}
414+
if len(story.AcceptanceCriteria) != 2 {
415+
t.Errorf("expected 2 acceptance criteria, got %d", len(story.AcceptanceCriteria))
416+
}
417+
}
418+
419+
func TestPRD_NextStoryContext_PromptSizeUnder10KB(t *testing.T) {
420+
// Create a 300-story PRD to verify the context stays small
421+
stories := make([]UserStory, 300)
422+
for i := range stories {
423+
stories[i] = UserStory{
424+
ID: fmt.Sprintf("US-%03d", i+1),
425+
Title: fmt.Sprintf("Story %d with a reasonably long title for realism", i+1),
426+
Description: "This is a description that is moderately long to simulate realistic PRD content for testing purposes.",
427+
AcceptanceCriteria: []string{"Criterion A", "Criterion B", "Criterion C"},
428+
Priority: i + 1,
429+
Passes: i > 0, // Only first story is pending
430+
}
431+
}
432+
p := &PRD{
433+
Project: "Large Project",
434+
Description: "A large PRD with 300 stories",
435+
UserStories: stories,
436+
}
437+
438+
ctx := p.NextStoryContext()
439+
if ctx == nil {
440+
t.Fatal("expected non-nil context for 300-story PRD")
441+
}
442+
if len(*ctx) > 10*1024 {
443+
t.Errorf("story context is %d bytes, expected under 10KB", len(*ctx))
444+
}
445+
}

0 commit comments

Comments
 (0)