Skip to content

Planning canon: index vs. live plan

The canon, stated once

  • .planning/ROADMAP.md = milestone index. A flat list of milestones with status markers (✅ shipped, 🟡 in-progress). It is never phase-CRUD'd — it must never contain a ### Phase N: detail section.
  • .planning/milestones/v<X.Y>-ROADMAP.md = the live phase plan. One per milestone, hand-authored and maintained by Orion for the whole life of the milestone (kickoff through ship). This is the file with Goal / Branch / Phase Details / Success Criteria.
  • .planning/STATE.md = crew-maintained, single-milestone scalar. Its milestone: frontmatter field can only name one milestone at a time. It cannot express the concurrency this project actually runs at (routinely 2-3 milestones in flight) — see "Concurrent milestones" below.

These are three different files with three different owners. GSD (the upstream get-shit-done-cc npm package, installed via gsd-sdk/the /gsd-* commands) has no awareness of this convention — it hardcodes a different one.

Why this needed writing down (OBJ-1421)

.planning/STATE.md was found stale on 2026-07-13 — it still reported milestone: v2.2, status: Awaiting next milestone, last_updated: 2026-05-22, while the live milestone was v4.0 Teams V1. The immediate cause was simple neglect: nothing in the crew's actual workflow writes STATE.md, and the last write was the last milestone that went through GSD's real /gsd-complete-milestone (v2.2). Every milestone since — v2.3 through v4.4 — shipped crew-driven, bypassing GSD's state machine, and STATE.md was never told.

The deeper problem is a structural conflict between GSD's file contract and this repo's convention, and it's silent:

GSD (upstream tool)Enkidu crew convention
Live phase list.planning/ROADMAP.md.planning/milestones/v<X.Y>-ROADMAP.md
.planning/milestones/*Archive, written once by /gsd-complete-milestone at closeLive plan, hand-written by Orion from kickoff
Concurrent milestonesNot supported — one milestone: scalar in STATE.mdRoutinely 2-3 in flight
Phase numberingGlobal, monotonicPer-milestone, 1-based

GSD's roadmap path is hardcoded, with no config override:

  • ~/.claude/get-shit-done/bin/lib/planning-workspace.cjs:71roadmap: path.join(base, 'ROADMAP.md')
  • ~/.claude/get-shit-done/bin/lib/phase.cjs:214/gsd-phase resolves and writes to that same path

/gsd-phase's only safety gate is roadmap_exists — it doesn't check whether the file is an index or a phase list, so it passes every time in this repo and would append a ### Phase N: section straight into the index. This isn't hypothetical: Orion caught it by inspection while formalizing Phase 9 on OBJ-1403 and hand-edited the milestone ROADMAP directly to avoid it.

Do-not-run list

None of these commands are safe to run in this repo. Each misresolves .planning/ROADMAP.md as if it held live phase detail, when it never does here.

CommandWhy it's unsafe hereDo this instead
/gsd-phase (default, --insert, --remove, --edit)Writes/edits a ### Phase N: section directly into .planning/ROADMAP.md — the index, not a phase list.Edit the milestone's own .planning/milestones/v<X.Y>-ROADMAP.md directly.
/gsd-new-milestoneResets .planning/STATE.md and writes the new milestone's full phase list into .planning/ROADMAP.md (~/.claude/get-shit-done/workflows/new-milestone.md:176,488,533). Would pollute the index with phase detail on the very next epic kickoff.Hand-author .planning/milestones/v<X.Y>-ROADMAP.md, matching the format of a recent milestone (e.g. v1.8-ROADMAP.md). Update .planning/ROADMAP.md's milestone list by hand — one line, no phase detail.
/gsd-complete-milestone's archive stepExtracts phase detail out of .planning/ROADMAP.md into .planning/milestones/v<X.Y>-ROADMAP.md (~/.claude/get-shit-done/workflows/complete-milestone.md:21). In this repo the index never held that detail — Orion wrote it straight into the milestones file from kickoff — so the extraction has nothing real to read and would overwrite the hand-authored plan with an empty or bogus one.Run the milestone close ritual below — verify master containment first, then hand-edit .planning/ROADMAP.md's milestone list entry to ✅ shipped YYYY-MM-DD, folding detail into a collapsed <details> block matching prior shipped entries (e.g. the v2.2 entry). The milestone's own ROADMAP file needs no further edit — it's already the complete record.

/gsd-audit-milestone and /gsd-milestone-summary are safe — both read .planning/ROADMAP.md and the milestone's own ROADMAP for content but only write new files (*-MILESTONE-AUDIT.md, .planning/reports/MILESTONE_SUMMARY-v<X.Y>.md); neither overwrites the index or the milestone plan.

The milestone close ritual

A milestone is not shipped when its phases are merged. Phase PRs squash-merge into the milestone's own gsd/v<X.Y>-<slug> integration branch — promoting that branch to master is a separate, hand-created "integration merge to master" issue (v4.11 = OBJ-1622 → PR #1687) with no automation and no owner. Skip it and the milestone reads complete everywhere — every phase PR merged, every ticket closed, PostHog flags GA'd at 100% — while master carries none of the code.

That is not hypothetical. gsd/v4.15-dashboard-hierarchy completed all 5 phases on 2026-07-26, its dashboard_hierarchy flag GA'd to 100% and was then retired from code by a later phase PR on the same branch, and it was never promoted. Nothing reported a problem; it was found only when a human asked why a shipped feature wasn't visible in the app. Six other branches were stranded identically. See Branch Hygiene: Poisoned First-Commit — OBJ-1766 Post-mortem for the sibling failure mode on the same branch model, and CLAUDE.md's stranded-milestone-branch gotcha for the full write-up.

Before marking a milestone ✅ shipped YYYY-MM-DD in .planning/ROADMAP.md, in order:

  1. Verify master containment. Every file the milestone branch created must be present in master's tree:

    bash
    node scripts/check-branch-promotion.mjs contains gsd/v<X.Y>-<slug>

    The bare branch name resolves to origin/gsd/v<X.Y>-<slug> automatically (resolveRef in scripts/check-branch-promotion.mjs); write it explicitly as origin/gsd/v<X.Y>-<slug> if you want the ref unambiguous without relying on that fallback.

    contains gates the one branch you name and carries the verdict in its exit code — 0 and a line if every file it created is present in master, 1 and the absent files if not. Use it, not report: report describes every branch in the repo and always exits 0, so it can only be eyeballed, never scripted. A non-zero absent-file count means the promotion PR has not landed — stop here. Ancestry (git branch --merged) is not a valid substitute: every phase PR is squash-merged, so a fully-promoted branch is never an ancestor of master.

  2. Only then hand-edit .planning/ROADMAP.md's milestone list entry to ✅ shipped YYYY-MM-DD, folding detail into a collapsed <details> block matching prior shipped entries (e.g. the v2.2 entry). The milestone's own .planning/milestones/v<X.Y>-ROADMAP.md needs no further edit — it's already the complete record. The entry must also carry an exact-filename link to that file — [milestones/v<X.Y>-<slug>-ROADMAP.md](milestones/v<X.Y>-<slug>-ROADMAP.md) — and the link must live inside the ## Milestones section, not ## Current Milestone. This is not just a close-time nicety: a milestone ROADMAP file existing on the tree is itself the obligation to index it, so an in-flight milestone's 🟡 row needs the same link from kickoff. scripts/check-planning-canon.mjs's rule 3 (OBJ-2161) enforces both requirements on every PR and push to master.

  3. Retire the milestone's feature flags from PostHog via scripts/sync-posthog-feature-flags.mjs — never the API directly. A flag left at 100% with no consuming code in master is the exact signal that hid the v4.15 strand. Check for exactly that:

    bash
    POSTHOG_PERSONAL_API_KEY=phx_... node scripts/sync-posthog-feature-flags.mjs --reverse-drift

    Anything it reports is either a retired flag still awaiting manual archival in the PostHog UI (the script has no archive operation — see feature flags) or an unpromoted milestone, which step 1 will confirm.

  4. Delete the integration branch once promotion is confirmed. A stranded gsd/* branch is also a live cross-branch-overlap blocker for every other branch trying to promote, via pr-scope-guard.yml.

.github/workflows/branch-promotion-guard.yml runs step 1 daily and files a tracking issue for anything unpromoted past 14 days; .github/workflows/posthog-flag-reverse-drift.yml runs step 3 weekly and files its own. Both are nags, not gates — deliberately not required PR checks, because several branches are legitimately parked pending external work and several flags are legitimately retired pending archival. They catch a missed close; they do not perform one.

Milestone-close ordering — the ledger flip must not land before the milestone's own phase tick

Step 2's ✅ shipped flip on .planning/ROADMAP.md is a promise that .planning/milestones/v<X.Y>-<slug>-ROADMAP.md has no unticked ### Phase N: sections left. Nothing mechanically enforced that promise until OBJ-2947: PR #2631 (the v4.45 close) merged 2026-08-24, ~29 minutes before PR #2628 (the PR that ticked v4.45's own Phase 4) — inverting the intended tick-then-close order. A reviewer was requested on #2631, but a review request cannot gate a merge here (required_approving_review_count: 0 on the master CI gate ruleset), so it changed nothing. pr-scope-guard's cross-branch-overlap check couldn't see the race either: it flags file-set overlaps between branches, and the two PRs touch disjoint files by design (the tick PR touches only the milestone's own phase ROADMAP; the close PR touches .planning/ROADMAP.md plus the audit and narrative docs) — a clean, non-overlapping diff is exactly what let the race through undetected. The same shape had already happened once before, undetected: PR #2386 (v4.37 close, 2026-08-16) shipped with its own Phase 6 never ticked.

Guardrail (shipped): scripts/check-milestone-close-ordering.mjs (.github/workflows/milestone-close-ordering-guard.yml) runs on every PR, unfiltered — it fails any PR that flips a .planning/ROADMAP.md entry to ✅ shipped while that milestone's own .planning/milestones/v<X.Y>-*-ROADMAP.md still has an unticked phase at the PR's head. Bypass only via the milestone-close-unticked-ok label plus an Unticked-Justification: <reason> line in the PR body — both required, for the rare legitimate case of closing a milestone with a phase deliberately dropped (the v4.37 shape). The guard is not yet in master CI gate's required contexts (ruleset 17927598) — a red run currently shows, it doesn't block; promoting it to required is a repo-admin decision, tracked separately. General rule: a requested reviewer is not a merge gate on this repo's default branch-protection settings — don't rely on one to catch a sequencing problem a script can check instead. Full incident write-up: gotchas.md § Nothing mechanically coupled a milestone's ROADMAP ✅ flip to its own phase tick.

Parking a flagged branch

branch-promotion-guard.yml's daily nag flags any branch that carries orphaned files past the idle threshold — but not every flagged branch is abandoned. Some are deliberately blocked on external work (a vendor secret, an App Store review); some are deliberately preserved as a reference and never meant to promote. Before this existed, the only way to quiet the nag was to delete the branch — which throws away exactly the branches you meant to keep. scripts/check-branch-promotion.mjs now reads a human adjudication recorded as a git tag at the branch's exact tip, so check/report/body stop re-flagging a decision that's already been made.

Park, shelve, or delete — the decision, stated once:

  • Delete the branch if the work is genuinely abandoned or superseded (step 4 above, once promotion is confirmed, or the OBJ-2160 ABANDON/DELETE verdicts). This is still the default for dead work.
  • Park the branch if it's blocked on something outside the repo that will resolve — a vendor secret, an App Store review — and the branch should promote once that clears.
  • Shelve the branch if it's a deliberate, permanent decision to keep a reference without ever promoting it.

Two prefixes, two different lifetimes:

Tag prefixMeaningExpires?
parked/<slug>Temporarily blocked on external work.Yes — PARKED_MAX_DAYS (default 90) days after the tag's creation date, then the branch falls back into the stranded report and names the lapsed tag.
shelved/<slug>Deliberately preserved reference branch; the decision is permanent.No.

To park or shelve a branch, tag its exact tip and push the tag — no PR, no CI cycle:

bash
git tag -a parked/<slug> <sha> -m "OBJ-XXXX: blocked on <what>"
git push origin parked/<slug>

Swap parked for shelved for a permanent decision. Two things to get right:

  • The tag must be annotated (-a), not lightweight. A lightweight tag has no creation date or message of its own — git falls back to the pointed-at commit's date and subject line, which can make an old tip read as already-expired the moment the tag lands, and attributes the park reason to an unrelated commit message.
  • Tag the exact tip, not an ancestor. Suppression is matched by exact commit sha, not reachability — deliberately, so any new commit on the branch moves the tip off the tag and the branch re-enters the report with zero manual steps. A tag on an older commit on the same branch does not suppress its current tip.

This suppression is honored by check, report, and body. It is not honored by contains — the milestone-close gate in step 1 above stays byte-identical regardless of any tag, so parking or shelving a branch can never let a milestone close claim master containment it doesn't have.

To un-park a branch early (the external blocker cleared, or the decision changes), delete the tag: git push origin :refs/tags/parked/<slug>.

See scripts/check-branch-promotion.mjs's module header and CLAUDE.md's stranded-milestone-branch gotcha for the full mechanics and the incident that motivated the daily nag in the first place.

Concurrent milestones

STATE.md's milestone: field can only name one milestone. When more than one is in flight (the norm here), name the project's canonical anchor in the frontmatter and list every other live milestone in STATE.md's body under a "Concurrent milestones" section — see .planning/STATE.md for the current live set. Do not try to encode more than one milestone in the frontmatter itself; the schema doesn't support it.

Milestone version derivation

The milestone MINOR component is derived, not chosen — scripts/derive-milestone-version.mjs is the single source of the number:

bash
node scripts/derive-milestone-version.mjs   # prints "vMAJOR.MINOR" (e.g. "v4.39") to stdout on success; nothing on failure

Full doctrine — why a disk-only scan isn't enough, why the version is reserved from the moment a milestone issue exists (not from when its ROADMAP file merges), and why the claim check has to run as part of deriving the number rather than as a later re-check — lives in docs/guides/multica-agent-crew.md's "Milestone version derivation" and "Reserved ≠ released" entries under Orion's Task-cutting rules. This section covers only the two CLI gotchas hit deriving it (OBJ-2615) — a collision between OBJ-2606 and OBJ-2590, both derived to v4.37.

Gotchas

  • multica issue list --output json returns an object, not a bare array. The shape is {issues, total, limit, offset, has_more}. jq -r '.[] | select(.title | test("..."))' iterates the object's valueshas_more (a boolean) first — so jq throws (Cannot index boolean with string "title"), exits non-zero, and stdout is silently empty. Verified live:

    bash
    multica issue list --output json | jq -r '.[] | select(.title | test("^Milestone v4.37:")) | .id'
    # jq: error: Cannot index boolean with string "title"
    
    multica issue list --output json | jq -r '.issues[] | select(.title | test("^Milestone v4\\.37:")) | .id'
    # OBJ-2590
  • The server caps a page at 100 and echoes back whatever --limit you asked for. --limit 3000 reports "limit": 3000 in the response but still returns only 100 rows — a raised limit looks honored without being honored. Page with --offset until has_more is false:

    bash
    multica issue list --output json --limit 3000 | jq -r '{limit, count: (.issues|length), has_more}'
    # {"limit":3000,"count":100,"has_more":true}

Open question: adopt GSD workstreams? (deliberately deferred)

GSD already ships a mechanism that would make this repo's convention legal rather than a divergence to keep managing by hand: workstreams. planningDir(cwd, ws) (~/.claude/get-shit-done/bin/lib/planning-workspace.cjs:56-58) resolves to .planning/workstreams/<ws>/{STATE,ROADMAP}.md when a workstream is set, and gsd-workstreams ships with GSD. Migrating each live milestone into its own workstream would give each one its own STATE + ROADMAP pair, make per-milestone phase numbering correct by construction, and restore /gsd-phase and /gsd-new-milestone as safe commands — inside their own workstream.

That migration is milestone-sized in its own right — every live milestone's state, roadmap, and phase directories, plus every crew agent's assumptions about where files live — and is explicitly not attempted here. This document makes the current divergence safe and honest (resynced STATE.md, a CI guard against index corruption, this canon written down) so the workstreams decision can be made deliberately later, not rediscovered as a fresh bug. Raised by Orion on OBJ-1421 (2026-07-13); no decision has been made as of this writing.

  • .planning/STATE.md — the resynced state file, with its own "Planning canon" and "Concurrent milestones" sections
  • scripts/check-planning-canon.mjs — CI guard that fails if .planning/ROADMAP.md ever gets a ### Phase N: section, if STATE.md's milestone: names an already-shipped (audited) milestone, or if a .planning/milestones/v<X.Y>-*-ROADMAP.md file has no matching ledger link in .planning/ROADMAP.md's ## Milestones section (rule 3, OBJ-2161)
  • scripts/derive-milestone-version.mjs — the milestone version derivation script; see "Milestone version derivation" above
  • .claude/skills/generating-project-roadmaps/SKILL.md — Orion/Dori's milestone workflow skill, corrected under OBJ-1421 to stop recommending the unsafe commands above

Last updated: 2026-08-16

Loading…