Summary
extractJSONObject picks the first brace group in the model's response,
including braces that appear in prose. When the entry text (or the text of a
--closes target) contains a brace literal such as User{ID: userID}, the
model quotes it, extraction latches onto that group instead of the real JSON
object, and json.Unmarshal fails. Pre-flight then aborts with a JSON parse
error and the entry cannot be written at all.
This is not an LLM quality problem — the checker does its job. The extraction
step picks the wrong candidate.
Environment
- sdd 0.16.2 (latest stable at time of report); defect also present in
main
(441 commits ahead of v0.16.2, verified by reading the source)
- macOS 26.4, arm64
- LLM provider
anthropic, model claude-sonnet-4-6
- Also reproducible with a plain
--dry-run, so no write is needed to trigger it
Reproduction
Any brace literal in the entry description is enough:
sdd new s ops "The config reads Options{Zebra: true}." \
--kind done --closes <any-entry-id> --dry-run
✕ pre-flight error: parsing pre-flight result: parsing pre-flight JSON:
invalid character 'Z' looking for beginning of object key string
Evidence that the wrong group is parsed
The character named in the error tracks the first character after the brace
in the input text — proof that the parsed group is the literal from the material
under review, not the checker's JSON object:
| Brace literal in the text |
Reported error |
User{ID: userID} |
invalid character 'I' |
Options{Zebra: true} |
invalid character 'Z' |
Control cases, same command shape, all fine — pre-flight runs and returns
proper findings:
| Case |
Result |
No brace literal anywhere, --closes a gap |
✓ findings returned |
No brace literal, --closes a plan (5 ACs) |
✓ findings returned, ACs checked |
No brace literal, --closes a plan (9 ACs) |
✓ findings returned, ACs checked |
Root cause
internal/llm/preflight.go
// extractJSONObject returns the first balanced {...} in the input, skipping
// any surrounding prose or code fences.
func extractJSONObject(output string) (string, error) {
...
start := strings.Index(output, "{") // line 590
The brace matcher is string-escape aware, so {Zebra: true} balances cleanly
and is returned as the "JSON object". parsePreflightResult then unmarshals it
and fails at line 553.
The doc comment's promise — "skipping any surrounding prose" — does not hold for
prose that itself contains braces, which is exactly what happens when the
checker quotes code from the entry it is reviewing.
Per the comment, extractJSONObject is shared by every JSON-shaped LLM check
(pre-flight and writing guide), so the blast radius is wider than pre-flight.
Impact
Any entry whose own body — or whose closes/supersedes target's body —
contains a brace literal is unwritable through the normal path. Go composite
literals, JS/TS object literals and Rust struct literals all trigger it, which
makes it likely in exactly the entries a code-focused graph wants to record.
When the trigger sits in an already-written immutable entry — an acceptance
criterion quoting a struct literal, say — rewording is not an option: every
future entry closing that target fails deterministically. The only way out is
--skip-preflight, which discards the validation entirely for an entry that has
no actual problem.
Suggested fix
Try candidate brace groups until one unmarshals into the expected shape, instead
of committing to the first:
- iterate over each
{ position, extract the balanced group, attempt
json.Unmarshal into the target struct, and return the first that succeeds;
- or prefer the last balanced group, since models put prose before the
payload, not after;
- or require the payload to carry the expected top-level key (
findings) and
skip groups that do not.
The third point matters on its own: a bare {} in the preamble decodes without
complaint, so a candidate loop that accepts the first decodable group would
report a clean pass and let the entry through unchecked. Requiring the key
avoids that.
Secondary: the failure is not diagnosable
Two things made this take much longer than it should have:
- The raw model response is nowhere.
.sdd/stats/llm.jsonl records only
op, provider, model, token counts and duration. --extra-verbose adds
nothing either — the response body is never logged, so from the outside the
error looks like a broken LLM or a broken key.
- The error surfaces as a bare parse error.
parsing pre-flight JSON: invalid character 'Z' looking for beginning of object key string gives no
hint that a candidate selection went wrong. Including the offending
extracted snippet in the error would point straight at the cause:
parsing pre-flight JSON: invalid character 'Z' ... (extracted candidate: "{Zebra: true}").
Both would also help when a checker genuinely misbehaves, which is
indistinguishable from this bug today.
Summary
extractJSONObjectpicks the first brace group in the model's response,including braces that appear in prose. When the entry text (or the text of a
--closestarget) contains a brace literal such asUser{ID: userID}, themodel quotes it, extraction latches onto that group instead of the real JSON
object, and
json.Unmarshalfails. Pre-flight then aborts with a JSON parseerror and the entry cannot be written at all.
This is not an LLM quality problem — the checker does its job. The extraction
step picks the wrong candidate.
Environment
main(441 commits ahead of v0.16.2, verified by reading the source)
anthropic, modelclaude-sonnet-4-6--dry-run, so no write is needed to trigger itReproduction
Any brace literal in the entry description is enough:
Evidence that the wrong group is parsed
The character named in the error tracks the first character after the brace
in the input text — proof that the parsed group is the literal from the material
under review, not the checker's JSON object:
User{ID: userID}invalid character 'I'Options{Zebra: true}invalid character 'Z'Control cases, same command shape, all fine — pre-flight runs and returns
proper findings:
--closesa gap--closesa plan (5 ACs)--closesa plan (9 ACs)Root cause
internal/llm/preflight.goThe brace matcher is string-escape aware, so
{Zebra: true}balances cleanlyand is returned as the "JSON object".
parsePreflightResultthen unmarshals itand fails at line 553.
The doc comment's promise — "skipping any surrounding prose" — does not hold for
prose that itself contains braces, which is exactly what happens when the
checker quotes code from the entry it is reviewing.
Per the comment,
extractJSONObjectis shared by every JSON-shaped LLM check(pre-flight and writing guide), so the blast radius is wider than pre-flight.
Impact
Any entry whose own body — or whose
closes/supersedestarget's body —contains a brace literal is unwritable through the normal path. Go composite
literals, JS/TS object literals and Rust struct literals all trigger it, which
makes it likely in exactly the entries a code-focused graph wants to record.
When the trigger sits in an already-written immutable entry — an acceptance
criterion quoting a struct literal, say — rewording is not an option: every
future entry closing that target fails deterministically. The only way out is
--skip-preflight, which discards the validation entirely for an entry that hasno actual problem.
Suggested fix
Try candidate brace groups until one unmarshals into the expected shape, instead
of committing to the first:
{position, extract the balanced group, attemptjson.Unmarshalinto the target struct, and return the first that succeeds;payload, not after;
findings) andskip groups that do not.
The third point matters on its own: a bare
{}in the preamble decodes withoutcomplaint, so a candidate loop that accepts the first decodable group would
report a clean pass and let the entry through unchecked. Requiring the key
avoids that.
Secondary: the failure is not diagnosable
Two things made this take much longer than it should have:
.sdd/stats/llm.jsonlrecords onlyop, provider, model, token counts and duration.--extra-verboseaddsnothing either — the response body is never logged, so from the outside the
error looks like a broken LLM or a broken key.
parsing pre-flight JSON: invalid character 'Z' looking for beginning of object key stringgives nohint that a candidate selection went wrong. Including the offending
extracted snippet in the error would point straight at the cause:
parsing pre-flight JSON: invalid character 'Z' ... (extracted candidate: "{Zebra: true}").Both would also help when a checker genuinely misbehaves, which is
indistinguishable from this bug today.