Skip to content

shield: no plan.md ↔ plan.json consistency check — sidecar edits can silently not reach the markdown #76

Description

@ashwinimanoj

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.mdplan.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:

  1. /plan — after step 5 writes {plan_md}, as a self-check
  2. /plan-review — next to the existing validate_plan.py call, so a stale plan can't be reviewed as if current
  3. /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 priorityEPIC-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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions