Summary
plan.json is documented as the structured source of truth and plan.md as "a markdown render of plan.json's stories with prose context" (plan-docs/SKILL.md §Artifacts). But nothing regenerates plan.md from the sidecar, and nothing verifies the two agree. Any edit to plan.json after the initial /plan run has to be mirrored into plan.md by hand, with no safety net.
The result: a sidecar edit can silently fail to reach the markdown. plan.json says one thing, plan.md (and the rendered HTML, and anything a human reads) says another — and every existing check passes.
Why the existing validators don't catch it
| Check |
What it covers |
validate_plan.py |
JSON Schema validation of the sidecar only — never opens plan.md |
validate_trd.py / validate_trd_drift.py |
TRD-side concerns |
rerender_all.py / render-output.sh |
Renders .md → .html; faithfully renders whatever the stale markdown says |
So the sidecar is validated in isolation, the markdown is rendered without question, and the relationship between them is unchecked.
Observed failure
While editing an existing plan (adding acceptance criteria to stories in plan.json, then mirroring into plan.md), the mirror step silently skipped the last story in the file — twice, in two different features.
The cause was a section-matching regex whose lookahead needed a following heading:
r'#### %s — .*?(?=\n#### |\n### |\n## )' # never matches the final section
r'#### %s — .*?(?=\n#### |\n### |\n## |\Z)' # fix
That's my bug, not Shield's — but the point is that nothing in Shield would have told me. validate_plan.py passed. The render succeeded. A heading-level check (does a #### {id} — {name} section exist?) also passed, because the heading was there and only the body was stale. It was caught only by an ad-hoc body-level diff I happened to write.
Any hand-edit of either file has the same exposure.
Proposed fix
A plan.md ↔ plan.json consistency check. Either extend validate_plan.py with an optional --plan-md PATH flag, or add validate_plan_md_sync.py alongside it. For each story in the sidecar, assert:
- a
#### {id} — {name} section exists in plan.md
- every
tasks[] string appears in that section
- every
acceptance_criteria[] string appears in that section
- the header counts (
**Epics:** N · **Stories:** M) match the sidecar
- each milestone's table has one row per story with matching name / priority / status
Body-level, not heading-level — heading-only matching is exactly what let this through.
Wire it into:
/plan — after step 5 writes {plan_md}, as a self-check
/plan-review — next to the existing validate_plan.py call, so a stale plan can't be reviewed as if current
/shield render — refuse to render (or warn loudly) when sources disagree
Secondary finding: plan.md generation isn't deterministic
Attempting to write a regenerator surfaced this. Milestone story-table row ordering is not consistent across features generated by the same /plan version:
observability-stack-deployment M2 orders rows by priority — EPIC-13-S4 (high), EPIC-13-S3 (medium), EPIC-13-S5 (low)
instrumentation M3 orders rows by epic then story id, with low-priority EPIC-6-S2/S3 appearing before high-priority EPIC-7-S1
Same generator, same schema version, two different orderings — so plan.md can't currently be reproduced from plan.json byte-for-byte, and "markdown render of plan.json" is not mechanically true today.
This matters for the fix: a deterministic generator (plan.md fully derived, regenerate-on-change) would be strictly better than a checker, and would make this class of bug impossible rather than merely detectable. But it requires pinning the ordering rule first. Suggest shipping the checker now (cheap, catches drift from any cause) and treating the generator as a follow-up.
Repro
Take any feature with a committed plan.json + plan.md. Append an acceptance criterion to the last story in the file in plan.json only. Run validate_plan.py and /shield render — both pass, and the rendered HTML omits the new criterion.
Summary
plan.jsonis documented as the structured source of truth andplan.mdas "a markdown render of plan.json's stories with prose context" (plan-docs/SKILL.md§Artifacts). But nothing regeneratesplan.mdfrom the sidecar, and nothing verifies the two agree. Any edit toplan.jsonafter the initial/planrun has to be mirrored intoplan.mdby hand, with no safety net.The result: a sidecar edit can silently fail to reach the markdown.
plan.jsonsays one thing,plan.md(and the rendered HTML, and anything a human reads) says another — and every existing check passes.Why the existing validators don't catch it
validate_plan.pyplan.mdvalidate_trd.py/validate_trd_drift.pyrerender_all.py/render-output.sh.md→.html; faithfully renders whatever the stale markdown saysSo the sidecar is validated in isolation, the markdown is rendered without question, and the relationship between them is unchecked.
Observed failure
While editing an existing plan (adding acceptance criteria to stories in
plan.json, then mirroring intoplan.md), the mirror step silently skipped the last story in the file — twice, in two different features.The cause was a section-matching regex whose lookahead needed a following heading:
That's my bug, not Shield's — but the point is that nothing in Shield would have told me.
validate_plan.pypassed. The render succeeded. A heading-level check (does a#### {id} — {name}section exist?) also passed, because the heading was there and only the body was stale. It was caught only by an ad-hoc body-level diff I happened to write.Any hand-edit of either file has the same exposure.
Proposed fix
A
plan.md↔plan.jsonconsistency check. Either extendvalidate_plan.pywith an optional--plan-md PATHflag, or addvalidate_plan_md_sync.pyalongside it. For each story in the sidecar, assert:#### {id} — {name}section exists inplan.mdtasks[]string appears in that sectionacceptance_criteria[]string appears in that section**Epics:** N · **Stories:** M) match the sidecarBody-level, not heading-level — heading-only matching is exactly what let this through.
Wire it into:
/plan— after step 5 writes{plan_md}, as a self-check/plan-review— next to the existingvalidate_plan.pycall, so a stale plan can't be reviewed as if current/shield render— refuse to render (or warn loudly) when sources disagreeSecondary finding:
plan.mdgeneration isn't deterministicAttempting to write a regenerator surfaced this. Milestone story-table row ordering is not consistent across features generated by the same
/planversion:observability-stack-deploymentM2 orders rows by priority —EPIC-13-S4(high),EPIC-13-S3(medium),EPIC-13-S5(low)instrumentationM3 orders rows by epic then story id, with low-priorityEPIC-6-S2/S3appearing before high-priorityEPIC-7-S1Same generator, same schema version, two different orderings — so
plan.mdcan't currently be reproduced fromplan.jsonbyte-for-byte, and "markdown render of plan.json" is not mechanically true today.This matters for the fix: a deterministic generator (
plan.mdfully derived, regenerate-on-change) would be strictly better than a checker, and would make this class of bug impossible rather than merely detectable. But it requires pinning the ordering rule first. Suggest shipping the checker now (cheap, catches drift from any cause) and treating the generator as a follow-up.Repro
Take any feature with a committed
plan.json+plan.md. Append an acceptance criterion to the last story in the file inplan.jsononly. Runvalidate_plan.pyand/shield render— both pass, and the rendered HTML omits the new criterion.