Skip to content

Commit 2b21b2a

Browse files
fix(prd): prevent non-JSON output from conversion by disabling tools
Claude was attempting to use the Write tool during PRD conversion, and when denied, fell back to conversational output with preamble text before the JSON. Fix by disabling all tools via --tools "" and passing the prompt through stdin, updating the prompt to explicitly forbid tool use, and hardening cleanJSONOutput to extract JSON objects from any surrounding preamble text.
1 parent 6a1c6bb commit 2b21b2a

3 files changed

Lines changed: 79 additions & 3 deletions

File tree

embed/convert_prompt.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ Here is the PRD content:
66
{{PRD_CONTENT}}
77
</prd>
88

9-
Return ONLY valid JSON — no markdown fences, no explanation, no commentary. The JSON must follow this exact structure:
9+
Do NOT use any tools. Do NOT write any files. Output ONLY the raw JSON to stdout — no markdown fences, no explanation, no preamble, no commentary. The JSON must follow this exact structure:
1010

1111
{
1212
"project": "Project Name",

internal/prd/generator.go

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -189,8 +189,9 @@ func runClaudeConversion(absPRDDir string) (string, error) {
189189

190190
prompt := embed.GetConvertPrompt(string(content))
191191

192-
cmd := exec.Command("claude", "-p", prompt)
192+
cmd := exec.Command("claude", "-p", "--tools", "")
193193
cmd.Dir = absPRDDir
194+
cmd.Stdin = strings.NewReader(prompt)
194195

195196
var stdout, stderr bytes.Buffer
196197
cmd.Stdout = &stdout
@@ -592,7 +593,8 @@ func NeedsConversion(prdDir string) (bool, error) {
592593
return mdInfo.ModTime().After(jsonInfo.ModTime()), nil
593594
}
594595

595-
// cleanJSONOutput removes markdown code blocks and trims whitespace from Claude's output.
596+
// cleanJSONOutput removes markdown code blocks, conversational preamble, and trims
597+
// whitespace from Claude's output to extract the JSON object.
596598
func cleanJSONOutput(output string) string {
597599
output = strings.TrimSpace(output)
598600

@@ -607,6 +609,55 @@ func cleanJSONOutput(output string) string {
607609
output = strings.TrimSuffix(output, "```")
608610
}
609611

612+
output = strings.TrimSpace(output)
613+
614+
// If output doesn't start with '{', Claude may have added preamble text.
615+
// Extract the JSON object by finding the first '{' and matching closing '}'.
616+
if len(output) > 0 && output[0] != '{' {
617+
start := strings.Index(output, "{")
618+
if start == -1 {
619+
return output // No JSON object found, return as-is for error handling
620+
}
621+
// Find the matching closing brace by counting brace depth
622+
depth := 0
623+
inString := false
624+
escaped := false
625+
end := -1
626+
for i := start; i < len(output); i++ {
627+
if escaped {
628+
escaped = false
629+
continue
630+
}
631+
ch := output[i]
632+
if ch == '\\' && inString {
633+
escaped = true
634+
continue
635+
}
636+
if ch == '"' {
637+
inString = !inString
638+
continue
639+
}
640+
if inString {
641+
continue
642+
}
643+
if ch == '{' {
644+
depth++
645+
} else if ch == '}' {
646+
depth--
647+
if depth == 0 {
648+
end = i
649+
break
650+
}
651+
}
652+
}
653+
if end != -1 {
654+
output = output[start : end+1]
655+
} else {
656+
// No matching closing brace; take from first '{' to end
657+
output = output[start:]
658+
}
659+
}
660+
610661
return strings.TrimSpace(output)
611662
}
612663

internal/prd/generator_test.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,31 @@ func TestCleanJSONOutput(t *testing.T) {
3434
input: " \n{\"project\": \"test\"}\n ",
3535
expected: `{"project": "test"}`,
3636
},
37+
{
38+
name: "with conversational preamble",
39+
input: "Since the file write is being denied, here's the JSON output directly:\n\n{\"project\": \"test\"}",
40+
expected: `{"project": "test"}`,
41+
},
42+
{
43+
name: "with preamble and nested objects",
44+
input: "Here is the JSON:\n{\"project\": \"test\", \"userStories\": [{\"id\": \"US-001\"}]}",
45+
expected: `{"project": "test", "userStories": [{"id": "US-001"}]}`,
46+
},
47+
{
48+
name: "with preamble and trailing text",
49+
input: "Here you go:\n{\"project\": \"test\"}\nLet me know if you need changes.",
50+
expected: `{"project": "test"}`,
51+
},
52+
{
53+
name: "with code fence and preamble",
54+
input: "Here is the output:\n```json\n{\"project\": \"test\"}\n```",
55+
expected: `{"project": "test"}`,
56+
},
57+
{
58+
name: "JSON with escaped quotes in preamble scenario",
59+
input: "Output:\n{\"project\": \"test \\\"quoted\\\"\"}",
60+
expected: `{"project": "test \"quoted\""}`,
61+
},
3762
}
3863

3964
for _, tt := range tests {

0 commit comments

Comments
 (0)