Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions docs/roadmap/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,34 @@ pull request. They are not retroactively certified under today's evidence
contract. Incomplete foundations—including the live entity, collision, random,
and dispatch paths formerly hidden by closed issue #14—remain open leaves.

## Recording delivery after a slice merges

Merging a slice's pull request closes its leaf issues on GitHub, but nothing
back-propagates into the manifest — the manifest is the source of truth, and a
merge never edits it. Until you record the delivery, those leaves still read
`status: "planned"`, so `reconcile` reports drift (a closed issue whose manifest
status is not `history`) and — critically — the next `apply` would force those
merged issues **back open** and reset their board cards to Backlog.

After a slice PR merges, record it and re-project:

```bash
python3 tools/roadmap.py deliver --pr <PR#> # manifest: its leaves → history + delivered_by
python3 tools/roadmap.py apply # GitHub: keep issues closed, board Status → Done
python3 tools/roadmap.py reconcile # verify live state matches the manifest
```

Then commit the changed `manifest.json` and `migration.json`.

`deliver` reads the pull request's computed closing issues, maps each to a leaf
through the journal, and sets that leaf `status: "history"` with
`delivered_by: <PR#>` — a minimal per-line edit that preserves the manifest's
style. It refuses a pull request that is not merged, skips leaves already
recorded, and re-validates before writing.

**Order matters: record delivery first.** Running `apply` while a merged leaf is
still `planned` reopens its issue. Always `deliver` before `apply`.

## Migration and recovery

[`migration.json`](migration.json) is the resumable journal. The migration:
Expand Down Expand Up @@ -98,6 +126,7 @@ python3 tools/roadmap.py plan
python3 tools/roadmap.py snapshot
python3 tools/roadmap.py apply
python3 tools/roadmap.py reconcile
python3 tools/roadmap.py deliver --pr <PR#>
python3 tools/roadmap.py handoff
```

Expand Down
20 changes: 10 additions & 10 deletions docs/roadmap/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,18 +59,18 @@
{"key":"area.scheduler-data","parent":"area","title":"History: ordered scheduler representation","slice":"5","milestone":"Campaign","status":"history","delivered_by":28,"criteria":["AREA-02.data"],"proof":"historical"},
{"key":"area.schedules-loop","parent":"area","title":"History: all normal schedules and the 1–16→7 loop","slice":"6","milestone":"Campaign","status":"history","delivered_by":32,"criteria":["AREA-03.tables","AREA-04.loop"],"proof":"historical"},

{"key":"difficulty.models-live-state","parent":"difficulty","title":"Slice 7: difficulty and formation models plus observable live state","slice":"7","milestone":"Campaign","status":"planned","criteria":["DIF-01.model","DIF-02.model","DIF-03.model","FORM-01.model"],"proof":"playable","records":["DIF-01","DIF-02","DIF-03","FORM-01"]},
{"key":"difficulty.models-live-state","parent":"difficulty","title":"Slice 7: difficulty and formation models plus observable live state","slice":"7","milestone":"Campaign","status":"history","delivered_by":34,"criteria":["DIF-01.model","DIF-02.model","DIF-03.model","FORM-01.model"],"proof":"playable","records":["DIF-01","DIF-02","DIF-03","FORM-01"]},

{"key":"core.entity-lifecycle","parent":"core","title":"Complete entity lifecycle with live combat participants","slice":"8","milestone":"Enemies","status":"planned","blocked_by":["air.toroid"],"criteria":["SYS-02.live"],"proof":"playable","records":["SYS-02"]},
{"key":"core.collision-resolution","parent":"core","title":"Complete collision groups and single-hit resolution in live combat","slice":"8","milestone":"Enemies","status":"planned","blocked_by":["air.toroid"],"criteria":["SYS-03.live"],"proof":"playable","records":["SYS-03"]},
{"key":"core.random-consumer","parent":"core","title":"Prove the shared random stream through a live enemy consumer","slice":"8","milestone":"Enemies","status":"planned","blocked_by":["air.toroid"],"criteria":["SYS-04.consumer"],"proof":"playable","records":["SYS-04"]},
{"key":"core.entity-lifecycle","parent":"core","title":"Complete entity lifecycle with live combat participants","slice":"8","milestone":"Enemies","status":"history","delivered_by":110,"blocked_by":["air.toroid"],"criteria":["SYS-02.live"],"proof":"playable","records":["SYS-02"]},
{"key":"core.collision-resolution","parent":"core","title":"Complete collision groups and single-hit resolution in live combat","slice":"8","milestone":"Enemies","status":"history","delivered_by":110,"blocked_by":["air.toroid"],"criteria":["SYS-03.live"],"proof":"playable","records":["SYS-03"]},
{"key":"core.random-consumer","parent":"core","title":"Prove the shared random stream through a live enemy consumer","slice":"8","milestone":"Enemies","status":"history","delivered_by":110,"blocked_by":["air.toroid"],"criteria":["SYS-04.consumer"],"proof":"playable","records":["SYS-04"]},
{"key":"difficulty.live-pressure","parent":"difficulty","title":"Complete in-game formation selection, adaptive pressure, and fire permission","slice":"10","milestone":"Enemies","status":"planned","blocked_by":["difficulty.models-live-state","air.toroid"],"criteria":["DIF-01.play","DIF-02.play","DIF-03.play","FORM-01.play"],"proof":"playable","records":["DIF-01","DIF-02","DIF-03","FORM-01"]},
{"key":"player.air-collision-death","parent":"player","title":"Complete player death through real aerial collision and bullets","slice":"8","milestone":"Enemies","status":"planned","blocked_by":["air.toroid","air.standard-bullets"],"criteria":["PLY-02.air-trigger"],"proof":"playable","records":["PLY-02"]},
{"key":"player.blaster-air-hit","parent":"player","title":"Complete blaster-to-air hit behavior","slice":"8","milestone":"Enemies","status":"planned","blocked_by":["air.toroid"],"criteria":["WPN-02.air-hit"],"proof":"playable","records":["WPN-02"]},
{"key":"economy.air-awards","parent":"economy","title":"Prove object score awards through live aerial combat","slice":"8","milestone":"Enemies","status":"planned","blocked_by":["air.toroid"],"criteria":["ECO-01.air-awards"],"proof":"playable","records":["ECO-01"]},
{"key":"area.air-dispatch","parent":"area","title":"Dispatch scheduled aerial events into live enemies","slice":"8","milestone":"Enemies","status":"planned","blocked_by":["air.toroid"],"criteria":["AREA-02.air-dispatch"],"proof":"playable","records":["AREA-02"]},
{"key":"air.toroid","parent":"air","title":"Toroid formation, movement, combat, and cleanup","slice":"8","milestone":"Enemies","status":"planned","criteria":["AIR-01.toroid"],"proof":"playable","records":["AIR-01"]},
{"key":"air.standard-bullets","parent":"air","title":"Standard enemy bullets: aim, movement, pulse, hit, and expiry","slice":"8","milestone":"Enemies","status":"planned","criteria":["AIR-12.standard"],"proof":"playable","records":["AIR-12"]},
{"key":"player.air-collision-death","parent":"player","title":"Complete player death through real aerial collision and bullets","slice":"8","milestone":"Enemies","status":"history","delivered_by":110,"blocked_by":["air.toroid","air.standard-bullets"],"criteria":["PLY-02.air-trigger"],"proof":"playable","records":["PLY-02"]},
{"key":"player.blaster-air-hit","parent":"player","title":"Complete blaster-to-air hit behavior","slice":"8","milestone":"Enemies","status":"history","delivered_by":110,"blocked_by":["air.toroid"],"criteria":["WPN-02.air-hit"],"proof":"playable","records":["WPN-02"]},
{"key":"economy.air-awards","parent":"economy","title":"Prove object score awards through live aerial combat","slice":"8","milestone":"Enemies","status":"history","delivered_by":110,"blocked_by":["air.toroid"],"criteria":["ECO-01.air-awards"],"proof":"playable","records":["ECO-01"]},
{"key":"area.air-dispatch","parent":"area","title":"Dispatch scheduled aerial events into live enemies","slice":"8","milestone":"Enemies","status":"history","delivered_by":110,"blocked_by":["air.toroid"],"criteria":["AREA-02.air-dispatch"],"proof":"playable","records":["AREA-02"]},
{"key":"air.toroid","parent":"air","title":"Toroid formation, movement, combat, and cleanup","slice":"8","milestone":"Enemies","status":"history","delivered_by":110,"criteria":["AIR-01.toroid"],"proof":"playable","records":["AIR-01"]},
{"key":"air.standard-bullets","parent":"air","title":"Standard enemy bullets: aim, movement, pulse, hit, and expiry","slice":"8","milestone":"Enemies","status":"history","delivered_by":110,"criteria":["AIR-12.standard"],"proof":"playable","records":["AIR-12"]},

{"key":"player.ground-targeting","parent":"player","title":"Complete crosshair target lock and bomb-to-ground resolution","slice":"9","milestone":"Enemies","status":"planned","blocked_by":["ground.barra","ground.logram"],"criteria":["WPN-03.target-lock","WPN-04.bomb-flight","WPN-05.ground-hit"],"proof":"playable","records":["WPN-03","WPN-04","WPN-05"]},
{"key":"economy.ground-awards","parent":"economy","title":"Prove object score awards through live ground combat","slice":"9","milestone":"Enemies","status":"planned","blocked_by":["ground.barra","ground.logram"],"criteria":["ECO-01.ground-awards"],"proof":"playable","records":["ECO-01"]},
Expand Down
80 changes: 80 additions & 0 deletions tests/test_roadmap.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
import importlib.util
import json
from pathlib import Path
import shutil
import sys
import tempfile
import unittest
from unittest import mock

Expand Down Expand Up @@ -203,5 +205,83 @@ def test_manual_closure_requires_merged_pr_that_closes_the_leaf(self, load_pr, _
self.assertTrue(any("merged delivering" in item for item in failures))


class DeliverTests(unittest.TestCase):
"""`roadmap deliver --pr N` records a merged PR's leaves as delivered in the manifest."""

# A tiny manifest in the real hand-authored one-line-per-leaf style; validation is mocked so it
# need not carry the full schema, only be valid JSON with locatable leaf lines.
MANIFEST_TEXT = (
"{\n"
' "version": 1,\n'
' "leaves": [\n'
' {"key":"done","status":"history","proof":"historical","delivered_by":8},\n'
' {"key":"ready","status":"planned","proof":"playable"},\n'
' {"key":"other","status":"planned","proof":"playable"}\n'
" ]\n"
"}\n"
)

def setUp(self) -> None:
self.tmp = tempfile.mkdtemp()
self.manifest_path = Path(self.tmp) / "manifest.json"
self.manifest_path.write_text(self.MANIFEST_TEXT, encoding="utf-8")
# The journal holds the issue-number mapping; manifest leaves carry no issue number.
self.journal = {
"parents": {"cap": {"number": 10}},
"leaves": {"done": {"number": 11}, "ready": {"number": 12}, "other": {"number": 14}},
}

def tearDown(self) -> None:
shutil.rmtree(self.tmp, ignore_errors=True)

def _merged(self, *numbers: int) -> dict:
return {"state": "MERGED", "closingIssuesReferences": [{"number": n} for n in numbers]}

def _leaves(self) -> dict:
return {l["key"]: l for l in json.loads(self.manifest_path.read_text())["leaves"]}

def test_flips_only_the_prs_planned_leaves(self) -> None:
with mock.patch.object(roadmap, "gh", return_value=self._merged(12)), \
mock.patch.object(roadmap, "validate_manifest", return_value=[]):
flipped = roadmap.deliver(self.journal, 20, manifest_path=self.manifest_path)
self.assertEqual(["ready"], flipped)
leaves = self._leaves()
self.assertEqual("history", leaves["ready"]["status"])
self.assertEqual(20, leaves["ready"]["delivered_by"])
# An unrelated leaf the PR did not close stays planned and untouched.
self.assertEqual("planned", leaves["other"]["status"])
self.assertNotIn("delivered_by", leaves["other"])
# The edit stays a minimal, per-line change — `other` and `done` lines are byte-identical.
text = self.manifest_path.read_text()
self.assertIn('{"key":"other","status":"planned","proof":"playable"}', text)
self.assertIn('{"key":"ready","status":"history","delivered_by":20,"proof":"playable"}', text)

def test_skips_parents_and_already_delivered(self) -> None:
with mock.patch.object(roadmap, "gh", return_value=self._merged(10, 11, 12)), \
mock.patch.object(roadmap, "validate_manifest", return_value=[]):
flipped = roadmap.deliver(self.journal, 20, manifest_path=self.manifest_path)
self.assertEqual(["ready"], flipped) # #10 is a parent, #11 already history
self.assertEqual(8, self._leaves()["done"]["delivered_by"]) # untouched

def test_refuses_a_pr_that_is_not_merged(self) -> None:
with mock.patch.object(roadmap, "gh", return_value={"state": "OPEN", "closingIssuesReferences": [{"number": 12}]}):
with self.assertRaises(roadmap.RoadmapError):
roadmap.deliver(self.journal, 20, manifest_path=self.manifest_path)
self.assertEqual(self.MANIFEST_TEXT, self.manifest_path.read_text()) # unchanged

def test_refuses_when_nothing_planned_to_record(self) -> None:
with mock.patch.object(roadmap, "gh", return_value=self._merged(11)):
with self.assertRaises(roadmap.RoadmapError):
roadmap.deliver(self.journal, 20, manifest_path=self.manifest_path)
self.assertEqual(self.MANIFEST_TEXT, self.manifest_path.read_text()) # unchanged

def test_an_invalid_result_aborts_the_write(self) -> None:
with mock.patch.object(roadmap, "gh", return_value=self._merged(12)), \
mock.patch.object(roadmap, "validate_manifest", return_value=["boom"]):
with self.assertRaises(roadmap.RoadmapError):
roadmap.deliver(self.journal, 20, manifest_path=self.manifest_path)
self.assertEqual(self.MANIFEST_TEXT, self.manifest_path.read_text()) # unchanged


if __name__ == "__main__":
unittest.main()
110 changes: 109 additions & 1 deletion tools/roadmap.py
Original file line number Diff line number Diff line change
Expand Up @@ -852,10 +852,107 @@ def render_handoff(manifest: dict[str, Any], journal: dict[str, Any]) -> str:
)


def deliver(journal: dict[str, Any], pr: int, manifest_path: Path = MANIFEST_PATH) -> list[str]:
"""Record the leaves a merged PR closed as delivered, in the manifest only.

Reads the PR's computed closing issues, maps each to a leaf via the journal
(manifest leaves carry no issue number; the journal holds the mapping), and
sets that leaf ``status: "history"`` with ``delivered_by: <pr>`` — the manifest's
representation of a delivered leaf, which ``apply`` then projects to a closed
issue and a Done board card. Returns the leaf keys flipped.

The edit is a targeted, per-line text substitution so it preserves the manifest's
hand-authored one-line-per-leaf style (a JSON round-trip would reorder and reflow
the whole file). It re-parses and re-validates the result before writing, and
performs no GitHub or journal mutation. Refuses unless the PR is merged, so it
cannot mark work delivered before it lands.
"""
detail = gh("pr", "view", str(pr), "--json", "state,closingIssuesReferences")
if not isinstance(detail, dict):
raise RoadmapError(f"could not read PR #{pr}")
if detail.get("state") != "MERGED":
raise RoadmapError(
f"PR #{pr} is {detail.get('state', 'unknown')}, not MERGED — "
"deliver records delivery only after a PR merges"
)
closed_numbers = {
ref.get("number")
for ref in detail.get("closingIssuesReferences", [])
if isinstance(ref, dict) and ref.get("number")
}
if not closed_numbers:
raise RoadmapError(f"PR #{pr} closes no issues; nothing to record as delivered")

text = manifest_path.read_text(encoding="utf-8")
manifest = json.loads(text)
leaf_by_number = {
entry.get("number"): key
for key, entry in journal.get("leaves", {}).items()
if isinstance(entry, dict) and entry.get("number")
}
parent_numbers = {
entry.get("number")
for entry in journal.get("parents", {}).values()
if isinstance(entry, dict) and entry.get("number")
}
status_by_key = {
leaf.get("key"): leaf.get("status")
for leaf in manifest.get("leaves", [])
if isinstance(leaf, dict)
}

targets: list[str] = []
for number in sorted(closed_numbers):
key = leaf_by_number.get(number)
if key is None:
reason = "maps to a parent, which has no delivery status" if number in parent_numbers else "not a roadmap leaf in the journal"
print(f"skip #{number}: {reason}", file=sys.stderr)
continue
if key not in status_by_key:
print(f"skip #{number} ({key}): key not found in the manifest", file=sys.stderr)
continue
if status_by_key[key] == "history":
print(f"skip {key} (#{number}): already recorded delivered", file=sys.stderr)
continue
targets.append(key)

if not targets:
raise RoadmapError(f"PR #{pr}: no planned leaves to record; manifest unchanged")

# Per-line text edit: on each target leaf's own line, turn `"status":"planned"` into
# `"status":"history","delivered_by":<pr>` (matching the field order of existing history leaves).
lines = text.split("\n")
needle = '"status":"planned"'
for key in targets:
marker = f'"key":"{key}"'
matched = [i for i, line in enumerate(lines) if marker in line]
if len(matched) != 1:
raise RoadmapError(f"expected exactly one manifest line for {key}, found {len(matched)}")
i = matched[0]
if lines[i].count(needle) != 1:
raise RoadmapError(f"{key}: could not locate a single planned status on its line")
lines[i] = lines[i].replace(needle, f'"status":"history","delivered_by":{pr}')
new_text = "\n".join(lines)

try:
new_manifest = json.loads(new_text)
except json.JSONDecodeError as exc:
raise RoadmapError(f"recording delivery produced invalid JSON (not written): {exc}") from exc
failures = validate_manifest(new_manifest)
if failures:
raise RoadmapError(
"manifest would be invalid after recording delivery (not written):\n"
+ "\n".join(f"- {failure}" for failure in failures)
)
manifest_path.write_text(new_text, encoding="utf-8")
return targets


def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser()
parser.add_argument("command", choices=["validate", "snapshot", "plan", "apply", "reconcile", "handoff"])
parser.add_argument("command", choices=["validate", "snapshot", "plan", "apply", "reconcile", "handoff", "deliver"])
parser.add_argument("--live", action="store_true", help="include a read-only live mutation diff")
parser.add_argument("--pr", type=int, help="for deliver: the merged pull request whose leaves to record delivered")
args = parser.parse_args(argv)
manifest = read_json(MANIFEST_PATH)
journal = read_json(MIGRATION_PATH) if MIGRATION_PATH.exists() else migration_template(manifest)
Expand Down Expand Up @@ -894,6 +991,17 @@ def main(argv: list[str] | None = None) -> int:
if args.command == "handoff":
print(render_handoff(manifest, journal))
return 0
if args.command == "deliver":
if not args.pr:
print("deliver requires --pr <merged PR number>", file=sys.stderr)
return 1
flipped = deliver(journal, args.pr)
print(f"marked delivered (status=history, delivered_by={args.pr}):")
for key in flipped:
number = journal.get("leaves", {}).get(key, {}).get("number", "?")
print(f" {key} (#{number})")
print("next: run `roadmap.py apply` then `roadmap.py reconcile` to project this to GitHub")
return 0
raise AssertionError(args.command)


Expand Down
Loading